📆

[Mattermost Integrations] Plugin (Server hooks)

2021/06/19に公開

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

本記事について

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

本記事では、Mattermost 上の様々な操作に対応した処理を追加できる Mattermost Plugin のServerサイドの機能であるServer Hooksについて紹介します。

Mattermost Plugin についての公式ドキュメントは下記になります。
https://developers.mattermost.com/extend/plugins/overview/

Mattermost Plugin (Server) について

Server Hooksの説明の前に、Mattermost Plugin の本体について紹介します。

Mattermost のServerサイドの Plugin を実装する場合、plugin.MattermostPluginを埋め込んだ構造体が Plugin 本体となります。plugin.MattermostPluginを埋め込んだ構造体は、APIHelperというフィールドを持ち、これらのフィールドを経由して Mattermost のリソースを処理する様々なメソッドを呼び出すことができます。また、plugin.MattermostPluginを埋め込んだ構造体に対してServer Hooksと同じインターフェースを持つメソッドを実装することで、Server Hooksを利用することができるようになります。

package main

import (
	"github.com/mattermost/mattermost-server/v5/plugin"
)

type SamplePlugin struct {
	plugin.MattermostPlugin
}

// OnActivate Hooksの実装
func (p *SamplePlugin) OnActivate() error {
    // `API`フィールドを通じたPlugin APIの呼び出し
	if err := p.API.RegisterCommand(&model.Command{
		Trigger: "sample-command",
	}); err != nil {
		return errors.Wrap(err, "failed to register  command")
	}
    return nil
}

mainメソッドでplugin.MattermostPluginを埋め込んだ構造体を引数としてplugin.ClientMainメソッドを呼ぶことで、プラグインを起動することができます。

package main

import (
	"github.com/mattermost/mattermost-server/v5/plugin"
)

func main() {
	plugin.ClientMain(&SamplePlugin{})
}

Server Hooks

Mattermost Plugin の Server Hooks は、Mattermost 上でユーザーがチャンネルに参加したときや、ユーザーが Mattermost にログインしたときなど、何かのアクションに応じて実行される処理を追加できる機能です。
plugin.MattermostPluginを埋め込んだ構造体に、Server Hooks と同じインターフェースを持つメソッドを実装することで利用可能になります。

Server Hooks の一覧は下記から確認できます。

https://developers.mattermost.com/extend/plugins/server/reference/#Hooks

OnActivate

OnActivate は Plugin が起動したときに呼ばれる Hook です。Bot を使う Plugin の場合はこの Hook 内で Bot ユーザーを作成したり、Slash Command を使う Plugin なら SlashCommand の登録などを行います。(Plugin から登録した Slash Command は、通常の統合機能として作成した Slash Command と違い外部アプリケーションにリクエストは送信されません。Slash Command が実行された時の処理は、Server Hooks のExecuteCommandで実装します。)

`error``が返却された場合は、プラグインが起動されません。

func (p *SamplePlugin) OnActivate() error {
	// Botの登録
	bot := &model.Bot{
		Username:    "test-bot",
		DisplayName: "Sample Bot",
	}
	botUserID, appErr := p.Helpers.EnsureBot(bot)
	if appErr != nil {
		return errors.Wrap(appErr, "failed to ensure bot user")
	}
	p.botUserID = botUserID

	// Slash Commandの登録
	if err := p.API.RegisterCommand(&model.Command{
		Trigger:      "sample",
		AutoComplete: true,
	}); err != nil {
		return errors.Wrap(err, "failed to register  command")
	}

    return nil
}

Implemented

Implementedは、Plugin が実装している Hook の名前を返すための Hooks です。しかし、実装されているのを見たことがないので、用途はないかもしれません。

func (p *MatterpollPlugin) Implemented() ([]string, error) {
    return []string{"OnActivate", "Implemented"}, nil
}

OnDeactivate

OnDeactivateはプラグインが停止された時に実行されます。

func (p *MatterpollPlugin) OnDeactivate() error {
    p.clean()
    return nil
}

OnConfigurationChange

Plugin 専用の設定が変更された際に実行されます。
Mattermost Plugin のManifest ファイルsettingsを記述することで、Plugin 専用の設定画面を持つことができます。

IMAGE

OnConfigurationChange周りの処理は下記の Starter テンプレートのコードを流用すると良いです。
https://github.com/mattermost/mattermost-plugin-starter-template/blob/master/server/configuration.go

type configuration struct {
	SampleSetting string
}

func (p *SamplePlugin) OnConfigurationChange() error {
	var configuration = new(configuration)

	// Load the public configuration fields from the Mattermost server configuration.
	if err := p.API.LoadPluginConfiguration(configuration); err != nil {
		return errors.Wrap(err, "failed to load plugin configuration")
	}

    p.setConfiguration(configuration)

	return nil
}

ServeHTTP

Mattermost Plugin 専用のエンドポイントに対してリクエストが送信された時に実行されます。

Mattermost Plugin には Plugin ごとにエンドポイントが存在します。Mattermost がhttps://example.com:8065で起動していたとすると、https://example.com:8065/plugins/{PLUGING_ID}が Plugin 専用のエンドポイントになります。ここに送られたリクエストを処理するのがServeHTTPです。

func (p *SamplePlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello, world!")
}

Interactive Messageなどのリクエスト送信先を Plugin 用のエンドポイントにするなどの利用方法があります。

ExecuteCommand

Plugin API のRegisterCommandで登録されたコマンドが実行された時に処理されます。

func (p *SamplePlugin) OnActivate() error {
	// Slash Commandの登録
	if err := p.API.RegisterCommand(&model.Command{
		Trigger:      "sample",
		AutoComplete: true,
	}); err != nil {
		return errors.Wrap(err, "failed to register  command")
	}

	return nil
}

func (p *SamplePlugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
	return &model.CommandResponse{Text: "Hello by plugin"}, nil
}

UserHasBeenCreated

UserHasBeenCreatedは、ユーザーが新規に作成された場合に実行されます。
新しく参加したユーザーに Bot からメッセージを送る場合などに使用できます。

IMAGE

func (p *SamplePlugin) UserHasBeenCreated(c *plugin.Context, user *model.User) {
	channel, appErr := p.API.GetDirectChannel(p.botUserID, user.Id)
	if appErr != nil {
		p.API.LogWarn("failed to get direct channel", "user_id1", p.botUserID, "user_id2", user.Id, "details", appErr.Error())
		return
	}
	if _, appErr := p.API.CreatePost(&model.Post{
		ChannelId: channel.Id,
		UserId:    p.botUserID,
		Message:   "Welcome to Our Mattermost!",
	}); appErr != nil {
		p.API.LogWarn("failed to create welcome post.", "channel_id", channel.Id, "details", appErr.Error())
	}
}

UserWillLogIn

UserWillLogInは、ユーザーがログインする直前に実行されます。

空文字以外の文字列を返すとログインが取り消されますが、現在のバージョンでは返却した文字列はユーザーには表示されず、ユーザー名とパスワードが合っていてもEnter a valid email or username and/or passwordというメッセージが表示されてしまうようです。さらに、プラグインを管理できるユーザーがログアウトしてしまうと、前記のエラーでログインできず、利用可能な状態に戻すのが困難になってしまうかもしれないため、使用には注意が必要そうです。

unc (p *SamplePlugin) UserWillLogIn(c *plugin.Context, user *model.User) string {
	if err := p.check(); err != nil {
		return err.Error()
	}
	return ""
}

UserHasLoggedIn

UserHasLoggedInは、ユーザーがログインした直後に実行されます。

前回ログアウト(オフライン)してから、7 日以上経過していた場合に Bot からメッセージを送信する場合などに利用できます。

IMAGE

func (p *SamplePlugin) UserHasLoggedIn(c *plugin.Context, user *model.User) {
	status, appErr := p.API.GetUserStatus(user.Id)
	if appErr != nil {
		p.API.LogWarn("failed to get user status", "user_id", user.Id, "details", appErr.Error())
		return
	}
	t := time.Unix(status.LastActivityAt/1000, status.LastActivityAt%1000)
	if status.Status == model.STATUS_OFFLINE && time.Now().After(t.AddDate(0, 0, 7)) {
		channel, appErr := p.API.GetDirectChannel(p.botUserID, user.Id)
		if appErr != nil {
			p.API.LogWarn("failed to get direct channel", "user_id1", p.botUserID, "user_id2", user.Id, "details", appErr.Error())
			return
		}
		if _, appErr := p.API.CreatePost(&model.Post{
			ChannelId: channel.Id,
			UserId:    p.botUserID,
			Message:   "Hi! :wave:",
		}); appErr != nil {
			p.API.LogWarn("failed to create post.", "channel_id", channel.Id, "details", appErr.Error())
		}
	}
}

MessageWillBePosted

MessageWillBePostedは、投稿されたメッセージがデータベースに保存される前に実行されます。投稿を拒否したり、投稿内容を自動で編集したい場合などに利用できます。投稿作成時に拒否や編集以外の処理を実行する場合は、投稿がデータベースに保存された後に実行されるMessageHasBeenPostedの利用が推奨されています。

投稿を拒否する場合は、2つ目の return 値に空でない文字列を指定します。一つ目の返却値の*model.Postには内容を編集した後の*model.Postを指定します。nilを指定した場合でも、引数で与えられた*model.Postが指定されたものと解釈されます。

func (p *SamplePlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
	if strings.Contains(post.Message, "shit") || strings.Contains(post.Message, "💩") {
		return nil, "You can't use `shit` and 💩 on this server."
	}
	return nil, ""
}

IMAGE

この Hook によって投稿が拒否された場合、ユーザーからはその拒否理由が見えないようなので、拒否基準を明文化したり、拒否理由を Bot から通知するなどの対応が必要そうです。

MessageWillBeUpdated

MessageWillBeUpdatedは、投稿済みのメッセージを編集した際、編集内容がデータベースに保存される直前に実行される処理です。
MessageWillBePostedとほぼ同じ内容のため、例は省略します。

func (p *SamplePlugin) MessageWillBeUpdated(c *plugin.Context, newPost, oldPost *model.Post) (*model.Post, string) {
    ...
}

MessageHasBeenPosted

MessageHasBeenPostedは、投稿がデータベースに保存された直後に実行される処理です。

特定のキーワードを含むメッセージが作成された場合に、特定のチャンネルに通知するようなコードは下記のようになります。
Bot が作成した投稿もこの Hook で処理されるため、考慮が漏れると処理が無限ループしてしまうため注意が必要です。また、非公開チャンネルの投稿なども処理されてしまうため、その点を考慮する必要もあります。

IMAGE

func (p *SamplePlugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
	postUrl := fmt.Sprintf("http://localhost:8065/_redirect/pl/%s", post.Id)
	if strings.Contains(post.Message, "mattermost") && post.UserId != p.botUserID {
		p.API.CreatePost(&model.Post{
			Message:   fmt.Sprintf("Post refered to `mattermost` is created. See [here](%s) ", postUrl),
			UserId:    p.botUserID,
			ChannelId: "su7w9z51atnspjufg1c73ijx8w",
		})
	}
}

MessageHasBeedUpdated

MessageHasBeenUpdatedは、投稿済みのメッセージを編集した際、編集内容がデータベースに保存された直後に実行される処理です。
こちらもMessageHasBeenPostedとほぼ同じ内容のため、例は省略します。

func (p *SamplePlugin) MessageHasBeenUpdated(c *plugin.Context, newPost, oldPost *model.Post) {
    ...
}

ChannelHasBeenCreated

ChannelHasBeenCreatedは、チャンネルが作成された直後に実行されます。

チャンネルが作成されたことをtown-squareチャンネルに通知するコードは下記のようになります。この Hook についても、非公開チャンネルが作成された場合の考慮が必要になります。

IMAGE

func (p *SamplePlugin) ChannelHasBeenCreated(c *plugin.Context, channel *model.Channel) {
	if channel.Type != model.CHANNEL_OPEN {
		return
	}

	u, appErr := p.API.GetUser(channel.CreatorId)
	if appErr != nil {
		p.API.LogError("Failed to get user", "details", appErr)
		return
	}
	townSquare, appErr := p.API.GetChannelByName(channel.TeamId, model.DEFAULT_CHANNEL, false)
	if appErr != nil {
		p.API.LogError("Failed to get channel", "details", appErr)
		return
	}

	if _, appErr := p.API.CreatePost(&model.Post{
		Type:      model.POST_DEFAULT,
		ChannelId: townSquare.Id,
		UserId:    p.botUserID,
		Message:   fmt.Sprintf("Channel ~%s has been created by %s.", channel.Name, u.GetDisplayName(model.SHOW_USERNAME)),
	}); appErr != nil {
		p.API.LogError("Failed to create post", "details", appErr)
	}
}

UserHasJoinedChannel

UserHasJoinedChannelは、ユーザーがチャンネルに参加した直後に実行されます。第3引数のactorは、他のユーザーがユーザーをチャンネルに追加した場合など、ユーザーをチャンネルに追加する処理を実行した人の情報が入ります。

チャンネルに新しく参加したユーザーに読んで欲しいリンクなどを通知する場合に利用できます。

IMAGE

func (p *SamplePlugin) UserHasJoinedChannel(c *plugin.Context, channelMember *model.ChannelMember, actor *model.User) {
	if channelMember.ChannelId != TargetChannelID {
		return
	}
	p.API.SendEphemeralPost(actor.Id, &model.Post{
		ChannelId: channelMember.ChannelId,
		UserId:    p.botUserID,
		Message:   fmt.Sprintf("This chanels is for XXX user. You'd better to read [notes for this channel](%s).", UrlForNotes),
	})
}

UserHasLeftChannel

UserHasLeftChannelは、ユーザーがチャンネルから脱退した直後に実行されます。
UserHasJoinedChannelとほぼ同じ内容のため、例は省略します。

func (p *SamplePlugin) UserHasLeftChannel(c *plugin.Context, channelMember *model.ChannelMember, actor *model.User) {
    ...
}

UserHasJoinedTeam

UserHasJoinedTeamは、ユーザーがチームに参加した直後に実行されます。
UserHasJoinedChannelとほぼ同じ内容のため、例は省略します。

func (p *SamplePlugin) UserHasJoinedTeam(c *plugin.Context, teamMember *model.TeamMember, actor *model.User)  {
    ...
}

UserHasLeftTeam

UserHasLeftTeamは、ユーザーがチャンネルから脱退した直後に実行されます。
UserHasJoinedChannelとほぼ同じ内容のため、例は省略します。

func (p *SamplePlugin) UserHasLeftTeam(c *plugin.Context, teamMember *model.TeamMember, actor *model.User) {
    ...
}

FileWillBeUploaded

FileWillBeUploadedは、メッセージ入力欄にファイルが添付された時に実行されます。ユーザーが投稿作成を実行する前にファイルが変換されます。

添付されたファイルの情報は第 2 引数の*model.FileInfoから、ファイルの内容は第 3 引数のio.Readerから取得できます。添付ファイルに変更を加えた場合は、第 4 引数のio.Writerに書き込みます。
また、ファイルの添付を拒否する場合は、2 つ目の返却値に空でない文字列を指定します。1 つ目の返却値の*model.FileInfoには内容を編集した後の*model.FileInfoを指定します。もしファイルを編集した場合、編集後のファイルサイズについては自動で更新されるため、FileWillBeUploaded内で計算する必要はありません。

画像にフィルタをかける例は下記のようになります。

IMAGE

func (p *SamplePlugin) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
	if info.IsImage() {
		// Decode original image
		img, _, err := image.Decode(file)
		if err != nil {
			p.API.LogWarn("failed to decode uploaded image", "details", err.Error())
			return nil, ""
		}
		// Draw original image
		base := image.NewRGBA(image.Rect(0, 0, img.Bounds().Dx(), img.Bounds().Dy()))
		draw.Draw(base, base.Bounds(), img, image.ZP, draw.Src)

		// Create green filter
		src := image.NewRGBA(image.Rect(0, 0, img.Bounds().Dx(), img.Bounds().Dy()))
		draw.Draw(src, src.Bounds(), &image.Uniform{color.RGBA{255, 128, 255, 128}}, image.ZP, draw.Src)

		// Mask original image
		mask := image.Rect(25, 25, base.Bounds().Dx()-25, base.Bounds().Dy()-25)
		draw.DrawMask(base, base.Bounds(), src, image.ZP, mask, image.ZP, draw.Over)

		// Write masked image
		png.Encode(output, base)
	}
	return info, ""
}

画像ファイルの Exif を削除するなどで利用することもできますが、処理自体は Mattermost サーバーにファイルが送られた後に実行されるため、注意が必要です。

さいごに

本日は、Mattermost Plugin の Server Hooks について紹介しました。
明日も、Mattermost Plugin のServerサイドで使用できる API と Helper 関数について紹介します。

Discussion