📆

[Mattermost Integrations] Matterpoll Plugin (Server)

2021/06/19に公開

Mattermost 記事まとめ: https://blog.kaakaa.dev/tags/mattermost/

本記事について

Mattermost の統合機能アドベントカレンダーの第 22 日目の記事です。

以前から開発に参加しているMatterpollが Mattermost 公式の Plugin Marketplace で Community Plugin として公開されました。

公式の Plugin Marketplace で公開されたことにより、プラグインをアップロードする必要なくメインメニュー > プラグインマーケットプレースからボタン一つでインストールできるようになりました。

main menu

install button

Matterpoll は Mattermost 上で投票を行うことができる Plugin であり、Mattermost 社の開発者である Hanzei と私を中心に開発が進められています。

screenshot of MatterPoll

以前より Mattermost に投票機能を付けるという提案はMattermost の Feature Request Forum でも票数を集めていたため、以前は絵文字で投票を促すような統合機能を開発していました。

https://github.com/matterpoll/matterpoll-emoji

ただその当時は Mattermost に Plugin 機能がなかったため、この統合機能を利用するには Mattermost とは別にサーバーアプリを起動しておく必要があるのが難点でした。

その後、Mattermost に Plugin 機能が付いたことで、Mattermost のプロセスの上で統合機能を動作させることができるようになり、matterpoll-emoji も Plugin として開発し直すこととなりました。そこで出来たプラグインが、今回 Plugin Marketplace で公開された Matterpoll Plugin になります。

この記事では、まだ日本語ではあまり情報のない Mattermost Plugin について、Matterpoll Plugin の構造を元に紹介していきたいと思います。

本記事では、Mattermost プラグインの Getting Started 的な部分についてはあまり触れません。Mattermost Plugin の基本的な部分については、アドベントカレンダーの他の記事や、公式ドキュメントを参照いただければと思います。

https://developers.mattermost.com/extend/plugins/

また、実際に開発を始める場合は GitHub で公開されている Mattermost Plugin 用のテンプレートリポジトリ https://github.com/mattermost/mattermost-plugin-starter-template を使用するのがおすすめです。テンプレートリポジトリを使った開発の始め方については、下記の記事で紹介しています。(少し情報が古いかもしれません...)

Mattermost プラグイン用のリポジトリテンプレート · kaakaa blog

概要

Matterpoll Plugin は Server 側の機能と Webapp 側の機能が連携して動作しています。

Server 側の機能は投票作成コマンド(/poll)を処理したり、投票が実行の HTTP リクエストを受け取って処理をしたり、投票に関するデータを管理したりしています。Webapp 側ではユーザー毎に表示を変えたい場合など、クライアント側の処理を実装しています。Server 側は Go 言語、WebApp 側は React.js(JavaScript/TypeScript)で書かれています。

Mattermost の基本的な処理の流れは下記のようになります。

architecture

ユーザーが Mattermost 上でスラッシュコマンド /poll を実行する (1) と、そのコマンドを Matterpoll Server 側が受け取り (2)、投票データ(Poll)を作成します (3)。作成された Poll のデータは、プラグイン用の Key-Value Store に格納されます (4)。そして、Mattermost の投稿形式 (Post)へ変換され (5)、通常の投稿と同様に Mattermost のデータベースに格納され (6)、Webapp へ WebSocket を通じて Publish されます (7)。(実際には、諸事情により投稿が作成 (6) されてから投票データを Key-Value ストア に格納 (4) していますが、話を簡単にするため上記の順で話をしています)

Matterpoll Plugin により作成されたメッセージは、Mattermost Webapp 側の処理によってユーザー毎に見た目が変わります (8)。具体的には、自分が投票した回答のボタンの色が変わったり、投票管理系のボタンが権限のないユーザーから見えなくなったりします。この WebApp 側の機能は、現在実験的な機能として提供されており、デフォルトでは Off になっています。Mattermost の System Console の Matterpoll プラグインの設定から On にすることが出来ます。(設定が Off でも、投票機能自体は利用可能)

Server 側の処理

Matterpoll プラグインは Server 側で投票の作成、実行、データの管理などの投票に関わる基本的な処理を実装しています。ここでは、Mattermost Plugin の機能により、以下の処理をどのように実装しているかについて紹介していきます。

  • プラグイン専用のスラッシュコマンドを追加し、投票作成コマンドを実行できるようにする
  • プラグイン専用のエンドポイントを追加し、ユーザーからの投票を受け付ける
  • プラグイン専用の Key-Value ストアで投票データを管理する

投票作成用の独自スラッシュコマンドの追加

matterpoll-command.dio.png

Matterpoll は専用のスラッシュコマンド /poll を実行することで投票を作成します。この /poll コマンドは、プラグイン起動時に登録されます。

Mattermost プラグインでは、プラグイン API の RegisterCommand を実行することで新たなスラッシュコマンドを登録することができます。

実際のコードでは下記のようになります。

server/plugin/configuration.go
...
// OnConfigurationChange loads the plugin configuration, validates it and saves it.
func (p *MatterpollPlugin) OnConfigurationChange() error {
  ...
	// This require a loaded i18n bundle
	if p.isActivated() {
		command, err := p.getCommand(configuration.Trigger)
		if err != nil {
			return errors.Wrap(err, "failed to get command")
		}
    ...
		if err := p.API.RegisterCommand(command); err != nil {
			return errors.Wrap(err, "failed to register new command")
		}
    ...
	}
  ...
}
...

https://github.com/matterpoll/matterpoll/blob/45f095875a98fb1d4f3f166851c86f41b987493e/server/plugin/configuration.go#L43

RegisterCommandの引数には、getCommandメソッドの戻り値 command を指定していますが、getCommandは Slash Command のモデルであるmodel.Commandを生成するための内部メソッドです。

server/plugin/command.go
func (p *MatterpollPlugin) getCommand(trigger string) (*model.Command, error) {
  ...
	return &model.Command{
		Trigger:              trigger,
		AutoComplete:         true,
		AutoCompleteDesc:     p.LocalizeDefaultMessage(localizer, commandAutoCompleteDesc),
		AutoCompleteHint:     p.LocalizeDefaultMessage(localizer, commandAutoCompleteHint),
		AutocompleteIconData: iconData,
	}, nil
}

https://github.com/matterpoll/matterpoll/blob/45f095875a98fb1d4f3f166851c86f41b987493e/server/plugin/command.go#L215

また、getCommandの引数 (configuration.Trigger)は Slash Command のトリガーワード(スラッシュコマンド名)ですが、Matterpoll ではスラッシュコマンド名を設定から変更できるようにしてあるため、設定画面で入力された値を使用するためにconfiguration.Triggerを指定しています。

設定画面

そのため、設定が変更された際に実行される OnConfigurationChange Hook の中で、RegisterCommand を実行しています。


プラグインにより登録されたスラッシュコマンド(/poll)が実行された場合の処理は、ExecuteCommand Hook として実装します。

server/plugin/command.go
// ExecuteCommand parses a given input and creates a poll if the input is correct
func (p *MatterpollPlugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
	msg, appErr := p.executeCommand(args)
	if msg != "" {
		p.SendEphemeralPost(args.ChannelId, args.UserId, args.RootId, msg)
	}
	return &model.CommandResponse{}, appErr
}

https://github.com/matterpoll/matterpoll/blob/45f095875a98fb1d4f3f166851c86f41b987493e/server/plugin/command.go#L84

ExecuteCommandの第二引数である *model.CommandArgs 型の変数にコマンド実行時の引数や、コマンド実行したユーザーのユーザー ID、コマンドが実行されたチャンネルの ID などの情報が格納されているため、これらの値を使ってスラッシュコマンドが実行された時の処理を実装していきます。

executeCommand メソッドで引数を解析して投票を作成する処理が実行されますが、細かい処理が多いためこれ以上の説明は割愛します。

投票リクエストを受け付けるための独自エンドポイントの追加

Matterpoll により作成された投稿では、メッセージに埋め込まれたボタンをクリックすることで投票を行うことができます。

Message Button

このボタンは Mattermost の Interacitve Message 機能を利用して実装されています (Interactive Message については第 14 日目第 15 日目の記事で紹介しています)。Matterpoll では https://github.com/matterpoll/matterpoll/blob/45f095875a98fb1d4f3f166851c86f41b987493e/server/plugin/command.go#L186 のあたりで実装されています。)

Matterpoll が作成する投稿に表示されているボタンは、クリックするとそれぞれ異なる URL へ HTTP リクエストが送信されます。この HTTP リクエストを Matterpoll の Server 側が受け取り、どのユーザーがどの回答に投票したかを解析し、データベースに保存されている投票のデータを更新することで投票処理を実行しています。

matterpoll-command.dio.png

リクエストを受け取るためには Matterpoll プラグイン用のエンドポイントを Mattermost 上に定義する必要がありますが、これは Mattermost プラグインの機能である ServeHTTP Hook により実現しています。ServeHTTPを実装すると、Mattermost に /plugins/{pluginid} というエンドポイントが生成され、このエンドポイントに送信されたリクエストをプラグインで処理することができるようになります。例えば、Mattermost に https://example.com:8065 という URL でアクセスできるとすると、https://example.com:8065/plugins/com.github.matterpoll.matterpollが Matterpoll プラグイン専用のエンドポイントになります。

Matterpoll では、さらにgorilla/mux を使用して Router を生成し、ServeHTTP の引数をそのまま Router へ流し込むことで、投票の作成/削除/終了や投票の管理など様々なリクエストに対応するエンドポイントを生成して処理を実装しています。

server/plugin/api.go
...
// InitAPI initializes the REST API
func (p *MatterpollPlugin) InitAPI() *mux.Router {
	r := mux.NewRouter()
	r.HandleFunc("/", p.handleInfo).Methods(http.MethodGet)
	r.HandleFunc("/"+iconFilename, p.handleLogo).Methods(http.MethodGet)

	apiV1 := r.PathPrefix("/api/v1").Subrouter()
	apiV1.Use(checkAuthenticity)
	apiV1.HandleFunc("/configuration", p.handlePluginConfiguration).Methods(http.MethodGet)

	apiV1.HandleFunc("/polls/create", p.handleSubmitDialogRequest(p.handleCreatePoll)).Methods(http.MethodPost)
	pollRouter := apiV1.PathPrefix("/polls/{id:[a-z0-9]+}").Subrouter()
	pollRouter.HandleFunc("/vote/{optionNumber:[0-9]+}", p.handlePostActionIntegrationRequest(p.handleVote)).Methods(http.MethodPost)
	pollRouter.HandleFunc("/votes/reset", p.handlePostActionIntegrationRequest(p.handleResetVotes)).Methods(http.MethodPost)
	pollRouter.HandleFunc("/option/add/request", p.handlePostActionIntegrationRequest(p.handleAddOption)).Methods(http.MethodPost)
	pollRouter.HandleFunc("/option/add", p.handleSubmitDialogRequest(p.handleAddOptionConfirm)).Methods(http.MethodPost)
	pollRouter.HandleFunc("/end", p.handlePostActionIntegrationRequest(p.handleEndPoll)).Methods(http.MethodPost)
	pollRouter.HandleFunc("/end/confirm", p.handleSubmitDialogRequest(p.handleEndPollConfirm)).Methods(http.MethodPost)
	pollRouter.HandleFunc("/delete", p.handlePostActionIntegrationRequest(p.handleDeletePoll)).Methods(http.MethodPost)
	pollRouter.HandleFunc("/delete/confirm", p.handleSubmitDialogRequest(p.handleDeletePollConfirm)).Methods(http.MethodPost)
	pollRouter.HandleFunc("/metadata", p.handlePollMetadata).Methods(http.MethodGet)
	return r
}

func (p *MatterpollPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
	p.API.LogDebug("New request:", "Host", r.Host, "RequestURI", r.RequestURI, "Method", r.Method)
	p.router.ServeHTTP(w, r)
}
...

https://github.com/matterpoll/matterpoll/blob/45f095875a98fb1d4f3f166851c86f41b987493e/server/plugin/api.go#L70

Key-Value ストア による投票データの管理

ここまで、スラッシュコマンドによる投票の作成、Interactive Message による投票処理について説明してきましたが、投票機能を実現するには、これらの投票に関するユーザーの操作を記録しておく必要があります。Mattermost プラグインでは各プラグイン毎に専用の Key Value ストアが用意されており、Matterpoll では投票に関するデータの保存にこの Key-Value ストアを利用しています。

matterpoll-kv.dio.png

投票データの Key は poll_ を接頭辞にもつ投票 ID であり、投票 ID は投票が作成されるごとに Mattermost 本体のmodel.NewIDメソッドを使って乱数を生成しています。Mattermost Plugin の Key Value ストアには[]byte型のデータのみ格納できるため、投票状態を持つPoll構造体を JSON 形式の []byte に変換した値を格納しています。

server/plugin/poll.go
...
// Poll stores all needed information for a poll
type Poll struct {
	ID            string
	PostID        string `json:"post_id,omitempty"`
	CreatedAt     int64
	Creator       string
	Question      string
	AnswerOptions []*AnswerOption
	Settings      Settings
}
...
// EncodeToByte returns a poll as a byte array
func (p *Poll) EncodeToByte() []byte {
	b, _ := json.Marshal(p)
	return b
}
...

https://github.com/matterpoll/matterpoll/blob/45f095875a98fb1d4f3f166851c86f41b987493e/server/poll/poll.go#L23

Key Value ストアは、単純にKVSet で値の格納、KVGetで値の取得を行うことができますが、Matterpoll では同時に投票操作が行われた場合などに不整合が起きないようにするため、データを格納する際は Atomic オプションを付けて実行しています。

...
// Update updates an existing a poll in the KV Store.
func (s *PollStore) Update(prev *poll.Poll, new *poll.Poll) error {
	opt := model.PluginKVSetOptions{
		Atomic:   true,
		OldValue: prev.EncodeToByte(),
	}
	ok, err := s.api.KVSetWithOptions(pollPrefix+prev.ID, new.EncodeToByte(), opt)
	if err != nil {
		return err
	}
  ...
}
...

また、Matterpoll では、Key Value ストア への処理は全て interface を用意していますが、これはテスト用に mockery でモックオブジェクトを自動で作成するためです。

さいごに

本日は、Mattermost 上で投票を行えるようにする Matterpoll Plugin の概要と Server 側の実装について紹介しました。
明日は、Matterpoll Plugin の Webapp 側の実装について紹介します。

Discussion