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の機能により、以下の処理をどのように実装しているかについて紹介していきます。

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

matterpoll-command.dio.png

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

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

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

...
// 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を生成するための内部メソッドです。

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として実装します。

// 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へ流し込むことで、投票の作成/削除/終了や投票の管理など様々なリクエストに対応するエンドポイントを生成して処理を実装しています。

...
// 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 に変換した値を格納しています。

...
// 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側の実装について紹介します。

comments powered by Disqus