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

Пошаговый пример: плагин Hello

Сделаем из шаблона настоящий плагин. Он будет уметь:

  • создавать своего бота Hello Bot;
  • по команде /hello Анна публиковать от имени бота приветствие «Привет, Анна! 👋»;
  • считать, сколько раз вызвали /hello, и хранить число на сервере;
  • ставить ❤️ на сообщения со словом «спасибо»;
  • показывать счётчик в правой панели Loop и обновлять его на лету, без перезагрузки страницы;
  • брать текст приветствия из настроек, которые администратор меняет в Системной консоли.

По дороге вы познакомитесь со всеми основными инструментами: настройками, ботом, slash-командой, хранилищем, хуком, HTTP-запросами, событиями WebSocket и компонентами интерфейса.

Перед началом

Пройдите Быстрый старт: у вас должен быть проект из шаблона, который собирается командой make dist и устанавливается в Loop. В примерах ID плагина — ru.mycompany.hello.

Шаг 1. Настройки плагина​

Настройки — это поля, которые системный администратор заполняет в Системная консоль > Плагины > Hello. Их список задаётся в plugin.json, в блоке settings_schema.

Замените в plugin.json строку "settings_schema": {} на:

plugin.json
"settings_schema": {
"header": "Настройки учебного плагина Hello.",
"settings": [
{
"key": "Greeting",
"display_name": "Текст приветствия",
"type": "text",
"help_text": "Что бот напишет в ответ на /hello. Вместо {name} подставится имя.",
"default": "Привет, {name}! :wave:"
},
{
"key": "ThanksReaction",
"display_name": "Ставить ❤️ на «спасибо»",
"type": "bool",
"help_text": "Если включено, бот ставит реакцию на сообщения со словом «спасибо».",
"default": true
}
]
}

Теперь серверная часть должна уметь эти настройки прочитать. Откройте server/plugin/configuration.go и добавьте поля в структуру Configuration:

server/plugin/configuration.go
type Configuration struct {
Greeting string
ThanksReaction bool
}

Имя поля должно совпадать с key из plugin.json (регистр букв не важен). Больше ничего делать не нужно: код шаблона сам загружает настройки при запуске плагина и при каждом их изменении (функция OnConfigurationChange). В любом месте серверного кода текущие настройки берутся так:

greeting := p.GetConfiguration().Greeting

Шаг 2. Бот​

Сообщения плагина удобнее публиковать не от имени живого человека, а от бота. Создадим его при запуске плагина.

Откройте server/plugin/plugin.go. Добавьте в структуру Plugin поле для ID бота:

server/plugin/plugin.go
type Plugin struct {
plugin.MattermostPlugin
// ...поля, которые уже были в шаблоне...
botUserID string
}

И допишите в OnActivate создание бота — перед строкой p.IsReady = true:

server/plugin/plugin.go
botUserID, err := p.sdk.Bot.EnsureBot(&model.Bot{
Username: "hello-bot",
DisplayName: "Hello Bot",
Description: "Бот учебного плагина Hello",
})
if err != nil {
return errors.Wrap(err, "не удалось создать бота")
}
p.botUserID = botUserID

В блок import в начале файла добавьте два пакета:

"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"

EnsureBot означает «убедись, что бот есть»: при первом запуске он создаст бота, а при следующих — найдёт уже созданного и вернёт его ID. Поэтому вызывать его при каждом запуске безопасно.

подсказка
Что такое p.API и p.sdk

В шаблоне есть два способа обращаться к Loop. p.API — базовый набор функций, p.sdk — удобная обёртка над ним с готовыми помощниками вроде EnsureBot. Пользуйтесь тем, что удобнее; подробнее — в разделе Серверная часть.

Шаг 3. Хранилище для счётчика​

Каждому плагину Loop выдаёт своё KV-хранилище — простую базу данных вида «ключ → значение». Сохраним в нём счётчик вызовов.

Замените содержимое server/plugin/store.go:

server/plugin/store.go
package plugin

import "encoding/json"

const counterKey = "hello_counter"

// incrementCounter атомарно увеличивает счётчик и возвращает новое значение.
func (p *Plugin) incrementCounter() (int, error) {
var newValue int
err := p.sdk.KV.SetAtomicWithRetries(counterKey, func(oldValue []byte) (any, error) {
var current int
if oldValue != nil {
if err := json.Unmarshal(oldValue, &current); err != nil {
return nil, err
}
}
newValue = current + 1
return newValue, nil
})
return newValue, err
}

func (p *Plugin) getCounter() (int, error) {
var count int
err := p.sdk.KV.Get(counterKey, &count)
return count, err
}

Почему не просто «прочитать, прибавить 1, записать»? Если двое вызовут /hello в одну и ту же секунду, оба прочитают, скажем, 5 и оба запишут 6 — одно приветствие потеряется. SetAtomicWithRetries записывает новое значение, только если старое за это время не изменилось, а иначе повторяет попытку.

Шаг 4. Slash-команда /hello​

Команду нужно зарегистрировать (сказать Loop, что она существует) и обработать (написать, что делать при вызове).

Регистрация — в OnActivate файла server/plugin/plugin.go, сразу после создания бота:

server/plugin/plugin.go
if err := p.API.RegisterCommand(&model.Command{
Trigger: "hello",
DisplayName: "Hello",
Description: "Поздороваться с ботом",
AutoComplete: true,
AutoCompleteDesc: "Бот поздоровается в канале",
AutoCompleteHint: "[имя]",
}); err != nil {
return errors.Wrap(err, "не удалось зарегистрировать /hello")
}

AutoComplete: true включает подсказку: когда пользователь наберёт /, команда появится в списке вместе с описанием.

Обработка — в новом файле server/plugin/command.go:

server/plugin/command.go
package plugin

import (
"strings"

"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)

// ExecuteCommand вызывается, когда пользователь отправляет /hello.
func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
// args.Command — вся строка целиком, например "/hello Анна"
name := strings.TrimSpace(strings.TrimPrefix(args.Command, "/hello"))
if name == "" {
user, appErr := p.API.GetUser(args.UserId)
if appErr != nil {
return nil, appErr
}
name = user.Username
}

text := strings.ReplaceAll(p.GetConfiguration().Greeting, "{name}", name)
if text == "" {
text = "Привет, " + name + "!"
}

if _, appErr := p.API.CreatePost(&model.Post{
UserId: p.botUserID,
ChannelId: args.ChannelId,
RootId: args.RootId,
Message: text,
}); appErr != nil {
return nil, appErr
}

count, err := p.incrementCounter()
if err != nil {
p.API.LogError("не удалось обновить счётчик", "error", err.Error())
} else {
// Сообщаем всем открытым вкладкам Loop, что счётчик изменился.
p.API.PublishWebSocketEvent("counter_updated", map[string]any{"count": count}, &model.WebsocketBroadcast{})
}

// Пустой ответ: Loop ничего не допишет в канал от себя.
return &model.CommandResponse{}, nil
}

Разберём, что тут происходит:

  1. ExecuteCommand — это хук. Функцию с таким именем Loop вызывает, когда пользователь отправляет любую команду, зарегистрированную плагином. Если команд несколько, различайте их по началу args.Command.
  2. В args лежит всё о вызове: кто вызвал (UserId), в каком канале (ChannelId), в каком треде (RootId), полный текст (Command).
  3. CreatePost публикует сообщение. Раз мы указали UserId: p.botUserID, автором будет бот. RootId нужен, чтобы при вызове команды в треде ответ попал в тот же тред.
  4. PublishWebSocketEvent отправляет событие в браузеры пользователей. Пустой WebsocketBroadcast{} значит «всем». Клиентскую часть, которая это событие поймает, напишем на шаге 7.

Шаг 5. Реакция на «спасибо»​

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

Создайте файл server/plugin/hooks.go:

server/plugin/hooks.go
package plugin

import (
"strings"

"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)

// MessageHasBeenPosted вызывается после публикации любого сообщения.
func (p *Plugin) MessageHasBeenPosted(_ *plugin.Context, post *model.Post) {
if !p.GetConfiguration().ThanksReaction {
return
}
// Не реагируем на сообщения самого бота, иначе можно получить бесконечный цикл.
if post.UserId == p.botUserID {
return
}
if !strings.Contains(strings.ToLower(post.Message), "спасибо") {
return
}

if _, appErr := p.API.AddReaction(&model.Reaction{
UserId: p.botUserID,
PostId: post.Id,
EmojiName: "heart",
}); appErr != nil {
p.API.LogError("не удалось поставить реакцию", "error", appErr.Error())
}
}
Хуки сообщений вызываются очень часто

MessageHasBeenPosted срабатывает на каждое сообщение на сервере — во всех каналах всех команд. Сначала отсекайте лишнее самыми дешёвыми проверками и не делайте здесь долгих операций вроде запросов во внешние сервисы. Если без них никак — запускайте их в отдельной горутине (go func() { ... }()).

Шаг 6. HTTP-запрос за счётчиком​

Клиентской части нужно как-то узнать текущее значение счётчика. Для этого у серверной части плагина есть собственное HTTP API: все запросы к адресу /plugins/<ID плагина>/... Loop передаёт плагину.

Откройте server/plugin/api.go. В функции InitApi рядом с примером /example добавьте маршрут:

server/plugin/api.go
p.router.HandleFunc("/stats", p.handleStats).Methods("GET")

И в конец файла — обработчик:

server/plugin/api.go
func (p *Plugin) handleStats(w http.ResponseWriter, r *http.Request) {
count, err := p.getCounter()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
p.sendError(w, err.Error())
return
}
p.sendRes(w, httpResponse{Status: "OK", Data: map[string]int{"count": count}})
}

Теперь GET https://your-loop-server.ru/plugins/ru.mycompany.hello/stats вернёт {"status":"OK","data":{"count":3}}. Запрос доступен только вошедшим пользователям: об этом заботится проверка, которая уже есть в шаблоне (подробнее — в разделе Серверная часть).

Серверная часть готова. Проверьте, что она собирается:

make server

Шаг 7. Панель в интерфейсе​

Переходим к клиентской части (папка webapp/). Сделаем:

  • кнопку в интерфейсе Loop, которая открывает правую панель;
  • саму панель со счётчиком;
  • обработчик события counter_updated, чтобы число менялось само.

Хранилище состояния​

Клиентская часть Loop построена на Redux — это общее хранилище данных приложения. У плагина в нём свой уголок. Опишем, что там будет лежать.

webapp/src/types/store.ts — поменяйте тип PluginStore:

webapp/src/types/store.ts
export type PluginStore = {
count: number
}

webapp/src/store/reducers.ts — замените целиком:

webapp/src/store/reducers.ts
import { combineReducers } from 'redux';
import { PluginStore } from '../types/store';

export enum ACTIONS {
COUNTER_RECEIVED = 'COUNTER_RECEIVED',
}

export type ActionType = {
type: ACTIONS
data: PluginActionData
}

export type PluginActionData = {
count?: number;
}

const EmptyState: PluginStore = {
count: 0,
}

function pluginState(state: PluginStore = EmptyState, { type, data }: ActionType): PluginStore {
switch (type) {
case ACTIONS.COUNTER_RECEIVED:
return { ...state, count: data.count ?? 0 };
default:
return state;
}
}

export default combineReducers({
pluginState,
});

webapp/src/store/actions.ts — замените целиком:

webapp/src/store/actions.ts
import { ACTIONS } from './reducers';

export function counterReceived(count: number) {
return { type: ACTIONS.COUNTER_RECEIVED, data: { count } };
}

Если вы не работали с Redux, запомните суть: action — это сообщение «что случилось» (COUNTER_RECEIVED, пришло число 7), а reducer — функция, которая по этому сообщению обновляет данные. Компоненты подписываются на данные и перерисовываются, когда те меняются.

Запрос к серверной части​

В webapp/src/utils/api.ts добавьте в класс ApiClient метод рядом с exampleRequest:

webapp/src/utils/api.ts
async getStats(): Promise<StdApiResp<{ count: number }>> {
try {
// @ts-ignore
return await this.client.doFetch<StdApiResp<{ count: number }>>(`/plugins/${manifest.id}/stats`, {
method: 'GET',
})
} catch (error) {
console.error(error);
return {status: 'error', error: String(error)};
}
}

this.client — это Client4, HTTP-клиент Loop. Он сам подставляет авторизацию текущего пользователя, поэтому серверная часть узнает, кто её вызвал.

Компонент панели​

Создайте webapp/src/components/HelloPanel.tsx:

webapp/src/components/HelloPanel.tsx
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import apiClient from '../utils/api';
import { counterReceived } from '../store/actions';
import { getPluginStoreFromState } from '../utils/utils';
import { GlobalStatePlugin } from '../types/store';

const HelloPanel: React.FC = () => {
const dispatch = useDispatch();
const count = useSelector((state: GlobalStatePlugin) => getPluginStoreFromState(state).count);

// При открытии панели один раз спрашиваем у сервера текущее значение.
useEffect(() => {
apiClient.getStats().then((resp) => {
if (resp.status === 'OK' && resp.data) {
dispatch(counterReceived(resp.data.count));
}
});
}, [dispatch]);

return (
<div style={{ padding: 24 }}>
<h3>
<FormattedMessage id='hello.panel.title' defaultMessage='Счётчик приветствий' />
</h3>
<p style={{ fontSize: 48, margin: 0 }}>{count}</p>
<p>
<FormattedMessage id='hello.panel.hint' defaultMessage='Напишите /hello в любом канале — число увеличится.' />
</p>
</div>
);
};

export default HelloPanel;

FormattedMessage выводит текст на языке пользователя. Переводы положите в webapp/i18n/:

webapp/i18n/ru.json
{
"hello.panel.title": "Счётчик приветствий",
"hello.panel.hint": "Напишите /hello в любом канале — число увеличится."
}
webapp/i18n/en.json
{
"hello.panel.title": "Greetings counter",
"hello.panel.hint": "Type /hello in any channel to increase the number."
}

Регистрация в интерфейсе​

Всё, что плагин добавляет в интерфейс, регистрируется в webapp/src/registerApp.tsx. Замените файл целиком:

webapp/src/registerApp.tsx
import { PluginRegistry } from 'loop-plugin-sdk';
import { getCurrentUserLocale } from 'loop-plugin-sdk/loop/redux/selectors/entities/i18n';
import { Action, Store } from 'redux';
import Plugin from './index';
import manifest from './manifest';
import reducer from './store/reducers';
import * as React from 'react';
import { getTranslations } from './utils/utils';
import { Provider, useSelector } from 'react-redux';
import { IntlProvider } from 'react-intl';
import { GlobalState } from 'loop-plugin-sdk/loop/types/store';
import HelloPanel from './components/HelloPanel';
import { counterReceived } from './store/actions';

export const pluginStoreId = `plugins-${manifest.id}`;

export default async function Initialize(plugin: Plugin, registry: PluginRegistry, store: Store<GlobalState, Action<Record<string, unknown>>>) {
const Providers: React.FC<React.PropsWithChildren<any>> = ({ children }) => {
const locale = useSelector((state) => getCurrentUserLocale(state as GlobalState) || 'en');
return (
<IntlProvider locale={locale} key={locale} messages={getTranslations(locale)}>
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
};

registry.registerReducer(reducer);
registry.registerTranslations(getTranslations);

// 1. Правая панель со счётчиком
const { toggleRHSPlugin } = registry.registerRightHandSidebarComponent(
() => (
<Providers>
<HelloPanel />
</Providers>
),
'Hello',
);

// 2. Кнопка, которая открывает и закрывает панель
registry.registerChannelHeaderButtonAction(
() => <i className='icon icon-emoticon-happy-outline' />,
() => store.dispatch(toggleRHSPlugin),
'Hello',
'Открыть счётчик приветствий',
);

// 3. Событие от сервера: счётчик изменился
registry.registerWebSocketEventHandler(
`custom_${manifest.id}_counter_updated`,
(msg) => store.dispatch(counterReceived(msg.data.count) as any),
);
}

Что здесь важно:

  • registerRightHandSidebarComponent регистрирует панель и возвращает действия, чтобы её показать, скрыть или переключить.
  • registerChannelHeaderButtonAction добавляет кнопку. Где именно она появится, зависит от настроек Loop: обычно — на вертикальной панели приложений справа, иначе — в шапке канала.
  • Имя события в registerWebSocketEventHandler Loop формирует сам: custom_ + ID плагина + _ + имя, которое вы передали в PublishWebSocketEvent на сервере.
  • Компонент RootComponent из шаблона больше не нужен — файл webapp/src/components/RootComponent.tsx можно удалить.

Шаг 8. Собираем и проверяем​

make deploy

(или make dist и загрузка архива через Системную консоль, как в быстром старте).

Обновите страницу Loop и проверьте:

  1. Команда. Наберите /hel — в подсказках появится /hello [имя]. Отправьте /hello Анна: в канале появится сообщение «Привет, Анна! 👋» от бота с меткой БОТ. Бот будет подписан hello-bot или Hello Bot — зависит от настройки отображения имён.
  2. Панель. Нажмите на кнопку с улыбкой — справа откроется панель со счётчиком.
  3. Живое обновление. Не закрывая панель, ещё раз отправьте /hello. Число увеличится само.
  4. Реакция. Напишите в канал «Спасибо!» — бот поставит ❤️.
  5. Настройки. Откройте Системная консоль > Плагины > Hello, поменяйте текст приветствия на Здравствуйте, {name}! и сохраните. Следующий /hello использует новый текст — без переустановки плагина.
Если имя бота или реакция не появились сразу

При самом первом ответе бота интерфейс может подписать его «Кто-то», а ❤️ на вашем «Спасибо!» может не появиться сразу. Это значит, что интерфейс не успел подгрузить данные для события в реальном времени. Обновите страницу — имя и реакция будут на месте.

Если что-то не работает, откройте раздел Отладка и частые ошибки: там описано, где смотреть логи плагина и какие ошибки встречаются чаще всего.

Что дальше​

  • Серверная часть — все хуки, работа с API, хранилищем, ботами, правами и внешними запросами.
  • Клиентская часть — все места в интерфейсе, куда можно встроиться.
  • Манифест — все типы настроек для Системной консоли.