Mattermost をフル活用するために Mattermost の拡張機能を調べてみた

## この記事の目的

以前 Slack の拡張機能を調べたので，Mattermost の拡張機能も確認します．

https://qiita.com/caunu-s/items/5981d6faff2a3736abb0

本記事は Mattermost の起動方法，拡張機能の検証，Slack との比較をまとめたものです．

:::note info
こちらの記事を参考にして，Mattermost サーバーの構築および Plugin や Apps framework の開発を行うことができます．
:::

## 構成

![mattermost-network-architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/431a7fb7-74b7-2d38-5dd6-affe1dbf6276.png)

本記事では様々な経路・構成を用いて接続を検証しています．
(結果として図が複雑化してしまいましたが...) 通信経路の自由度が高いことがよくわかると思います．

## サービスの紹介

### [Mattermost](https://mattermost.com/)

![mattermost_logo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/1035b538-42fc-68d9-3565-3a5fdbcb2ec7.png)

Mattermost について，野村総合研究所が提供する [aslead](https://aslead.nri.co.jp/) によって以下の様に説明されています．

> Mattermostはオープンソースのチャットツールで、セルフホスティングでも、クラウドサービスとしても利用できます。
> SaaSのチャットツールであるSlackの代替プロダクトとして利用することができ、使い方もSlackに似ているため、Slackを使用したことがあれば同じような感覚ですぐに使えるでしょう。
> また、オープンソースのMattermostはカスタマイズなどの自由性があり、導入コストを抑えられる点も優れたポイントと言えます。[^ASLEAD/MATTERMOST]

[^ASLEAD/MATTERMOST]: https://aslead.nri.co.jp/products/mattermost/column/how-to-use-mattermost.html

Mattermost は組織や企業内部でのセキュアな OSS チャットツールを提供します．
ソースコードは [Mattermost Inc.](https://mattermost.com/about-us/) によって管理されており，認証認可に関する機能を追加することで有償での提供も行われています．[^MATTERMOST/PRICING]
Mattermost Inc. の有償サービスを利用する場合は，Mattermost Inc. によるプライベートクラウドまたは自組織で管理する self-hosted から選択することになります．

[^MATTERMOST/PRICING]: https://mattermost.com/pricing/

## 動作確認環境

動作確認を行った環境情報を以下に示します．

| Name | Version |
|----|----|
| Windows 11 | 22H2 |
| Ubuntu (WSL2) | 20.04.6 LTS |
| Docker | 20.10.17 |
| Docker Compose | v2.10.2 |
| Mattermost | Enterprise Edition 9.3.0 |
| Nginx | 1.24.0 |
| PostgreSQL | 16.1-bullseye |
| Go | 1.21.3 |
| React | 18.2.0 |
| [github.com/mattermost/mattermost/server/public](https://pkg.go.dev/github.com/mattermost/mattermost/server/public) | v0.0.13 |
| [github.com/mattermost/mattermost-plugin-apps](https://pkg.go.dev/github.com/mattermost/mattermost-plugin-apps) | v1.2.2 |

## Mattermost の起動

[公式ドキュメント](https://docs.mattermost.com/install/install-docker.html#deploy-mattermost-on-docker-for-production-use)に従って立ち上げます．

公式では ```git clone``` から作業を始めますが，本記事では自前で ```docker-compose.yml``` と ```.env``` を作成します．

1. ```docker-compose.yml```, ```docker-compose.nginx.yml```, ```.env``` 作成
1. Nginx 用 config ファイル作成
1. 永続化ボリューム作成
1. 証明書発行
1. コンテナ起動

```shell: 1~2. ファイル作成
mkdir -p $HOME/codes/GitHub/mattermost && cd $_ && \
vim docker-compose.yml
vim docker-compose.nginx.yml
vim .env
mkdir -p ./nginx/conf.d
vim ./nginx/conf.d/default.conf
```

```docker-compose.yml``` では Git リポジトリのファイルから PostgreSQL ボリューム設定の修正 (```/data``` を削除) が必要でした．

https://github.com/docker-library/postgres/issues/116

```diff_yaml: docker-compose.yml
# https://docs.docker.com/compose/environment-variables/

version: "2.4"

services:
  postgres:
    image: postgres:${POSTGRES_IMAGE_TAG}
    restart: ${RESTART_POLICY}
    security_opt:
      - no-new-privileges:true
    pids_limit: 100
    read_only: true
    tmpfs:
      - /tmp
      - /var/run/postgresql
    volumes:
-      - ${POSTGRES_DATA_PATH}:/var/lib/postgresql/data
+      - ${POSTGRES_DATA_PATH}:/var/lib/postgresql
    environment:
      # timezone inside container
      - TZ

      # necessary Postgres options/variables
      - POSTGRES_USER
      - POSTGRES_PASSWORD
      - POSTGRES_DB

  mattermost:
    depends_on:
      - postgres
    image: mattermost/${MATTERMOST_IMAGE}:${MATTERMOST_IMAGE_TAG}
    restart: ${RESTART_POLICY}
    security_opt:
      - no-new-privileges:true
    pids_limit: 200
    read_only: ${MATTERMOST_CONTAINER_READONLY}
    tmpfs:
      - /tmp
    volumes:
      - ${MATTERMOST_CONFIG_PATH}:/mattermost/config:rw
      - ${MATTERMOST_DATA_PATH}:/mattermost/data:rw
      - ${MATTERMOST_LOGS_PATH}:/mattermost/logs:rw
      - ${MATTERMOST_PLUGINS_PATH}:/mattermost/plugins:rw
      - ${MATTERMOST_CLIENT_PLUGINS_PATH}:/mattermost/client/plugins:rw
      - ${MATTERMOST_BLEVE_INDEXES_PATH}:/mattermost/bleve-indexes:rw
      # When you want to use SSO with GitLab, you have to add the cert pki chain of GitLab inside Alpine
      # to avoid Token request failed: certificate signed by unknown authority 
      # (link: https://github.com/mattermost/mattermost-server/issues/13059 and https://github.com/mattermost/docker/issues/34)
      # - ${GITLAB_PKI_CHAIN_PATH}:/etc/ssl/certs/pki_chain.pem:ro
    environment:
      # timezone inside container
      - TZ

      # necessary Mattermost options/variables (see env.example)
      - MM_SQLSETTINGS_DRIVERNAME
      - MM_SQLSETTINGS_DATASOURCE

      # necessary for bleve
      - MM_BLEVESETTINGS_INDEXDIR

      # additional settings
      - MM_SERVICESETTINGS_SITEURL

+      #  enable plugin uploads
+      - MM_PLUGINSETTINGS_ENABLEUPLOADS

# If you use rolling image tags and feel lucky watchtower can automatically pull new images and
# instantiate containers from it. https://containrrr.dev/watchtower/
# Please keep in mind watchtower will have access on the docker socket. This can be a security risk.
#
#  watchtower:
#    container_name: watchtower
#    image: containrrr/watchtower:latest
#    restart: unless-stopped
#    volumes:
#      - /var/run/docker.sock:/var/run/docker.sock
```

```diff_yaml: docker-compose.nginx.yml
version: "2.4"

services:
  nginx:
    depends_on:
      - mattermost
    container_name: nginx_mattermost
    image: nginx:${NGINX_IMAGE_TAG}
    restart: ${RESTART_POLICY}
    security_opt:
      - no-new-privileges:true
    pids_limit: 100
    read_only: true
    tmpfs:
      - /var/run
      - /var/cache
      - /var/log/nginx
    volumes:
      - ${NGINX_CONFIG_PATH}:/etc/nginx/conf.d:ro
-      - ${NGINX_DHPARAMS_FILE}:/dhparams4096.pem
+      - ${NGINX_DHPARAMS_FILE}:/dhparam.pem
      - ${CERT_PATH}:/cert.pem:ro
      - ${KEY_PATH}:/key.pem:ro
      - shared-webroot:/usr/share/nginx/html
    environment:
      # timezone inside container
      - TZ
    ports:
      - ${HTTPS_PORT}:443
      - ${HTTP_PORT}:80
  mattermost:
    ports:
      - ${CALLS_PORT}:${CALLS_PORT}/udp
      - ${CALLS_PORT}:${CALLS_PORT}/tcp

# Shared volume for Let's Encrypt certificate renewal with a webroot
volumes:
  shared-webroot:
    name: shared-webroot

# This network name is being used for Let's Encrypt certificate renewal
networks:
  default:
    name: mattermost
```

```.env``` では
- ```DOMAIN``` はローカル環境で作業するので ```localhost``` としておきます．
- ```xxx_IMAGE_TAG``` はお好みのものを指定します．Mattermost は [Mattermost Enterprise Edition](https://docs.mattermost.com/about/editions-and-offerings.html#mattermost-enterprise-edition) を利用しても無料で使い続けることができます． 
- 永続化ボリュームと証明書の配置場所も適宜修正します．
- Plugin を System Console からアップロード&有効化できるようにしたいので ```MM_PLUGINSETTINGS_ENABLEUPLOADS``` を設定しておきます．

```diff_shell: .env
# Domain of service
- DOMAIN=mm.example.com
+ DOMAIN=localhost

# Container settings
## Timezone inside the containers. The value needs to be in the form 'Europe/Berlin'.
## A list of these tz database names can be looked up at Wikipedia
## https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
TZ=UTC
RESTART_POLICY=unless-stopped

# Postgres settings
## Documentation for this image and available settings can be found on hub.docker.com
## https://hub.docker.com/_/postgres
## Please keep in mind this will create a superuser and it's recommended to use a less privileged
## user to connect to the database.
## A guide on how to change the database user to a nonsuperuser can be found in docs/creation-of-nonsuperuser.md
- POSTGRES_IMAGE_TAG=13-alpine
- POSTGRES_DATA_PATH=./volumes/db/var/lib/postgresql/data
+ POSTGRES_IMAGE_TAG=16.1-bullseye
+ POSTGRES_DATA_PATH=${HOME}/volumes/mattermost/db/var/lib/postgresql

POSTGRES_USER=mmuser
POSTGRES_PASSWORD=mmuser_password
POSTGRES_DB=mattermost

# Nginx
## The nginx container will use a configuration found at the NGINX_MATTERMOST_CONFIG. The config aims
## to be secure and uses a catch-all server vhost which will work out-of-the-box. For additional settings
## or changes ones can edit it or provide another config. Important note: inside the container, nginx sources
## every config file inside */etc/nginx/conf.d* ending with a *.conf* file extension.

## Inside the container the uid and gid is 101. The folder owner can be set with
## `sudo chown -R 101:101 ./nginx` if needed.
- NGINX_IMAGE_TAG=alpine
+ NGINX_IMAGE_TAG=1.24.0

## The folder containing server blocks and any additional config to nginx.conf
NGINX_CONFIG_PATH=./nginx/conf.d
- NGINX_DHPARAMS_FILE=./nginx/dhparams4096.pem
+ NGINX_DHPARAMS_FILE=${HOME}/.ssh/mattermost/dhparam.pem

- CERT_PATH=./volumes/web/cert/cert.pem
- KEY_PATH=./volumes/web/cert/key-no-password.pem
+ CERT_PATH=${HOME}/.ssh/mattermost/cert.pem
+ KEY_PATH=${HOME}/.ssh/mattermost/key.pem
#GITLAB_PKI_CHAIN_PATH=<path_to_your_gitlab_pki>/pki_chain.pem
#CERT_PATH=./certs/etc/letsencrypt/live/${DOMAIN}/fullchain.pem
#KEY_PATH=./certs/etc/letsencrypt/live/${DOMAIN}/privkey.pem

## Exposed ports to the host. Inside the container 80, 443 and 8443 will be used
HTTPS_PORT=443
HTTP_PORT=80
CALLS_PORT=8443

# Mattermost settings
## Inside the container the uid and gid is 2000. The folder owner can be set with
## `sudo chown -R 2000:2000 ./volumes/app/mattermost`.
- MATTERMOST_CONFIG_PATH=./volumes/app/mattermost/config
- MATTERMOST_DATA_PATH=./volumes/app/mattermost/data
- MATTERMOST_LOGS_PATH=./volumes/app/mattermost/logs
- MATTERMOST_PLUGINS_PATH=./volumes/app/mattermost/plugins
- MATTERMOST_CLIENT_PLUGINS_PATH=./volumes/app/mattermost/client/plugins
- MATTERMOST_BLEVE_INDEXES_PATH=./volumes/app/mattermost/bleve-indexes
+ MATTERMOST_CONFIG_PATH=${HOME}/volumes/mattermost/app/mattermost/config
+ MATTERMOST_DATA_PATH=${HOME}/volumes/mattermost/app/mattermost/data
+ MATTERMOST_LOGS_PATH=${HOME}/volumes/mattermost/app/mattermost/logs
+ MATTERMOST_PLUGINS_PATH=${HOME}/volumes/mattermost/app/mattermost/plugins
+ MATTERMOST_CLIENT_PLUGINS_PATH=${HOME}/volumes/mattermost/app/mattermost/client/plugins
+ MATTERMOST_BLEVE_INDEXES_PATH=${HOME}/volumes/mattermost/app/mattermost/bleve-indexes

## Bleve index (inside the container)
MM_BLEVESETTINGS_INDEXDIR=/mattermost/bleve-indexes

## This will be 'mattermost-enterprise-edition' or 'mattermost-team-edition' based on the version of Mattermost you're installing.
MATTERMOST_IMAGE=mattermost-enterprise-edition
- MATTERMOST_IMAGE_TAG=7.8
+ MATTERMOST_IMAGE_TAG=9.3.0

## Make Mattermost container readonly. This interferes with the regeneration of root.html inside the container. Only use
## it if you know what you're doing.
## See https://github.com/mattermost/docker/issues/18
MATTERMOST_CONTAINER_READONLY=false

## The app port is only relevant for using Mattermost without the nginx container as reverse proxy. This is not meant
## to be used with the internal HTTP server exposed but rather in case one wants to host several services on one host
## or for using it behind another existing reverse proxy.
APP_PORT=8065

## Configuration settings for Mattermost. Documentation on the variables and the settings itself can be found at
## https://docs.mattermost.com/administration/config-settings.html
## Keep in mind that variables set here will take precedence over the same setting in config.json. This includes
## the system console as well and settings set with env variables will be greyed out.

## Below one can find necessary settings to spin up the Mattermost container
MM_SQLSETTINGS_DRIVERNAME=postgres
MM_SQLSETTINGS_DATASOURCE=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable&connect_timeout=10

## Example settings (any additional setting added here also needs to be introduced in the docker-compose.yml)
MM_SERVICESETTINGS_SITEURL=https://${DOMAIN}

+ ## Enable Plugin Uploads
+ MM_PLUGINSETTINGS_ENABLEUPLOADS=true
```

Plugins のアップロード容量に合せて ```client_max_body_size``` も調整しておきます．

```diff_conf: default.conf
# mattermost
# config can be tested on https://www.ssllabs.com/ssltest/ and a good nginx config generator
# can be found at https://ssl-config.mozilla.org/

# proxy cache
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mattermost_cache:10m max_size=3g inactive=120m use_temp_path=off;

# upstream used in proxy_pass below
upstream backend {
    # ip where Mattermost is running; this relies on a working DNS inside the Docker network
    # and uses the hostname of the mattermost container (see service name in docker-compose.yml)
    server mattermost:8065;
    keepalive 64;
}

# vhosts definitions
server {
    server_name _;
    listen 80 default_server;
    listen [::]:80 default_server;

    # redirect all HTTP requests to HTTPS with a 301 Moved Permanently response.
    return 301 https://$host$request_uri;
}

server {
    server_name _;
    listen 443 ssl http2 default_server;
    listen [::]:443 ssl http2 default_server;

    # logging
    access_log /var/log/nginx/mm.access.log;
    error_log /var/log/nginx/mm.error.log warn;

    # gzip for performance
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;

    ## ssl
-    ssl_dhparam /dhparams4096.pem;
+    ssl_dhparam /dhparam.pem;
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;
    ssl_session_tickets off;

    # intermediate configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    # certs sent to the client in SERVER HELLO are concatenated in ssl_certificate
    ssl_certificate /cert.pem;
    ssl_certificate_key /key.pem;

    # enable TLSv1.3's 0-RTT. Use $ssl_early_data when reverse proxying to prevent replay attacks.
    # https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_early_data
    ssl_early_data on;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    #resolver 1.1.1.1;

    # verify chain of trust of OCSP response using Root CA and Intermediate certs
    #ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;

    ## security headers
    # https://securityheaders.com/
    # https://scotthelme.co.uk/tag/security-headers/
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy no-referrer;
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header Permissions-Policy "interest-cohort=()";

    ## locations
    # ACME-challenge
    location ^~ /.well-known {
        default_type "text/plain";
        root /usr/share/nginx/html;
        allow all;
    }

    # disable Google bots from indexing this site
    location = /robots.txt {
        add_header Content-Type text/plain;
        return 200 "User-agent: *\nDisallow: /\n";
    }

    location ~ /api/v[0-9]+/(users/)?websocket$ {
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
-        client_max_body_size 50M;
+        client_max_body_size 200M;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Frame-Options SAMEORIGIN;
        proxy_set_header Early-Data $ssl_early_data;
        proxy_buffers 256 16k;
        proxy_buffer_size 16k;
        client_body_timeout 60;
        send_timeout 300;
        lingering_timeout 5;
        proxy_connect_timeout 90;
        proxy_send_timeout 300;
        proxy_read_timeout 90s;
        proxy_http_version 1.1;
        proxy_pass http://backend;
    }

    location / {
-        client_max_body_size 50M;
+        client_max_body_size 200M;
        proxy_set_header Connection "";
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Frame-Options SAMEORIGIN;
        proxy_set_header Early-Data $ssl_early_data;
        proxy_buffers 256 16k;
        proxy_buffer_size 16k;
        proxy_read_timeout 600s;
        proxy_cache mattermost_cache;
        proxy_cache_revalidate on;
        proxy_cache_min_uses 2;
        proxy_cache_use_stale timeout;
        proxy_cache_lock on;
        proxy_http_version 1.1;
        proxy_pass http://backend;
    }
}
```

```.env``` で設定したように永続化ボリュームは ```$HOME/volumes/mattermost``` 配下に作成します．

```shell: 3. 永続化ボリューム作成
mkdir -p $HOME/volumes/mattermost/app/mattermost/{config,data,logs,plugins,client/plugins,bleve-indexes}
sudo chown -R 2000:2000 $HOME/volumes/mattermost/app/mattermost
```

次に (自己署名) 証明書発行します．

```shell: 4. 証明書発行
mkdir -p $HOME/.ssh/mattermost && cd $_ && \
openssl req -newkey rsa:2048 -nodes -keyout key.pem -x509 -days 365 -out cert.pem
openssl dhparam -out dhparam.pem 4096
```

起動します．

```shell: 5. コンテナ起動
cd $HOME/codes/GitHub/mattermost
docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
```

https://localhost で接続すると Mattermost に接続できます．

![mattermost_top.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/1b89bfdb-c0eb-9388-74d6-b0f013dd2027.png)

本記事ではテストユーザーとして以下を使用します．

- Email address: ```test@example.foobar.com```
- Username: ```mattermost-test```
- Password: ```test-password```

またサンプル Organization を ```test-org``` としておきます．

## [ユーザー追加](https://docs.mattermost.com/collaborate/invite-people.html)

Mattermost のワークスペースにユーザーを追加する方法には

- 招待メールを送信する
- Invite Link を発行して渡す

があります．
ユーザーの招待に関する設定/制限は ```System Console``` > ```Signup```/```Email``` から行います．

![mattermost-auth.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/d8baab53-96f0-c167-f667-9a335b810aa2.png)

- ユーザー追加の可/不可
- ユーザー登録なしでの利用可/不可
- メールによる招待の可/不可
- 招待可能なメールアドレスドメイン登録
- Eメールでの有効化の要/不要
- ユーザ名でのログイン可/不可
- メールアドレスでのログイン可/不可

有効化されていない招待を一括ですべて無効化する機能もあります．
LDAP/SSO/OIDC によるログインは Mattermost Professional を利用しないと有効化できません．
ただし，GitLab での認証は Free 版でも有効化することができるようです．[^MATTERMOST/SSO-GITLAB]

[^MATTERMOST/SSO-GITLAB]: https://docs.mattermost.com/onboard/sso-gitlab.html

GitLab 連携については後に別記事で触れようと思います．

GitLab を利用しておらず Mattermost サーバーをメールアドレスを送信できないクローズドな環境で利用することを想定している場合は，Eメールでの有効化が不要になっていることを確認後 ```invite link``` を渡してユーザー追加することができます．

![mattermost-invite-link.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/092f398f-b647-015f-63a5-6d19b05fe328.png)

本記事での追加ユーザーは以下で設定しておきます．

- Email address: ```test-2@example.foobar.com```
- Username: ```mattermost-test-2```
- Password: ```test-password-2```

## Slack と Mattermost の比較

### 機能

#### 特定のチャンネル・チャットにメッセージを送る

Slack, Mattermost ともに Incoming Webhook および API を利用して実現できます．

#### 特定のチャンネルでメッセージを検索する

Slack, Mattermost ともに API を利用して実現できます．

#### 特定のチャンネルからいろいろなサービスの処理を発生させる

Slack では
- スラッシュコマンド
- Event API

Mattermost では
- スラッシュコマンド
- Outgoing Webhook
- カスタムプラグイン (Apps Framework)

を用いて実現できます．

自動で処理を発生させたい場合，Slack の Event API は様々なイベントに応じて処理を発火させることができますが，Mattermost の Outgoing Webhook では特定文字の投稿にのみ発火させることができます．

スラッシュコマンド (Mattermost ではカスタムプラグインによる機能追加を含む) を使えば，Slack・Mattermost どちらもユーザーの任意のタイミングで任意の処理を発生させることができます．

#### 高級なメッセージ投稿 (Interactivity)

Slack, Mattermost ともに専用の独自サーバーを構築して連携することで，ボタンやモーダルを利用したチャット+αのやり取りを実現することができます．

#### 公開されているプラグイン

Marketplace ([Slack](https://your-workspace.slack.com/apps), [Mattermost](https://mattermost.com/marketplace-category/popular/)) で公開されているプラグインの種類はともに豊富で，(筆者の目線では) 特に遜色がありません．

#### チャット画面の見た目の変更

Mattermost では React/Redux フレームワークを用いて広範囲の画面変更を行うことができますが，Slack は公式で提供されている設定可能な範囲にとどまります．


### ネットワーク構成

Mattermost は self-hosted することで閉じたネットワーク内で全ての機能を完結させることができますが，Slack は SaaS なので完全に閉じることはできません．

### 利用開始のしやすさ

Slack に比べると Mattermost はチャットツール用のサーバーそのものの導入が必要になります．
Slack はメールアドレスとインターネット環境があれば OK なので，Mattermost は若干ハードルが高いです．

## [Webhooks](https://developers.mattermost.com/integrate/webhooks/)

> Mattermost supports webhooks to easily integrate external applications into the server.

- **Incoming webhooks**: Mattermost の任意のチャンネルにメッセージ/DM を送信する
- **Outgoing webhooks**: 外部アプリ/サーバーに Mattermost からリクエストを送信し，レスポンスを受信/処理してもらう

Mattermost では上記いずれの webhook もサポートしています．
Slack は Incoming webhook のみサポートしているため明確な機能差です．
(正確に言うと Slack にもありましたが非推奨になりました[^SLACK/OUTGOING-WEBHOOK])

[^SLACK/OUTGOING-WEBHOOK]: https://api.slack.com/legacy/custom-integrations/outgoing-webhooks

Slack の機能に近いところだと [Event API](https://qiita.com/caunu-s/items/5981d6faff2a3736abb0#event-api) が相当しますが，

- Mattermost の Outgoing Webhook は指定チャンネルに投稿されるメッセージの特定文字列を基準に発火する
- Slack の Event API は特定のメッセージの投稿だけでなく，ユーザの追加など様々なイベントを基準にして発火する

という差があります．

Outgoing webhooks はパブリックチャンネルでのみ有効で，プライベートチャンネルで同じ動作をしたい場合は[スラッシュコマンド](#Slash-Commands)を使うことになります．

スラッシュコマンドと比較してユーザーが無意識に発火するようにしておくことができるので，(様々な倫理的問題はさておき．パブリックチャンネル only なので多少はOK?) ロギングやデータ収集を自動で行うなどの利用方法が考えられます．

### Incoming webhooks を使う

Incoming webhook を利用するために宛先 URL を発行します．
URL 発行は Slack 同様に簡単に行えます．

```System Console``` > ```Integration Management``` から Webhook が有効になっていることを確認し，```Integrations``` > ```Incoming Webhooks``` から作成していきます．

(この後 Outgoing Webhook やスラッシュコマンドも使うので有効であることも確認しておきます)

![mattermost-enable-webhook.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/d345866b-4e15-2241-5afe-31a0eb921154.png)

![mattermost-incoming-webhook.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/419311ab-2f4b-3cc7-d2f1-d2c37e9b606a.png)

URL を取得したら ```POST``` してメッセージを投稿できます．
[リクエストボディを構成するフィールド](https://developers.mattermost.com/integrate/webhooks/incoming/#parameters)は Slack を強く意識して合わせに行っているようです．
```text``` フィールドは[マークダウン](https://docs.mattermost.com/collaborate/format-messages.html)をサポートし，```@here``` などのメンションを含めることができます．表などを表現したい場合も ```\n``` を駆使して ```text``` フィールドに含めます．
```channel``` フィールドを使うと webhook 作成時に指定した宛先チャンネル以外にも投稿できます．
```props``` フィールドを活用することでメッセージに付加情報を含めることができます．

Slack の [Interactivity & Shortcuts](https://qiita.com/caunu-s/items/5981d6faff2a3736abb0#interactivity--shortcuts) と比較すると機能が少ないように見えますが，その分機能習熟なども必要なくスッと受け入れられる範囲としては適度かなと思います．

```shell
curl -X POST 'https://localhost/hooks/xxxxxxxxxx' \
--insecure \
-H "Content-Type: application/json" \
-d @- << EOF
{
  "channel": "town-square",
  "text": "Hello world :tada:",
  "props": {
    "card": "External Link:\n\n [Click Here](https://worldtimeapi.org/)\n\nSite: **World Time API**"
  }
}
EOF
```

![mattermost-incoming-webhook-post.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/6660598c-2fab-c2c6-adc8-44a64f4e27bf.png)

ただし，Slack と異なりスレッド返信 (Slack では ```ts``` 値を使った) はサポートしていないようです．

### Outgoing webhooks を使う

Outgoing Webhook の設定には外部 Web アプリの準備とともに，Mattermost 側に Web アプリとの通信を許可する設定が必要になります．
また，Webhook 処理を発火させるための発火ワードを決めて Mattermost に登録することになります．

Web アプリ側からのレスポンスは Incoming Webhook と同じ構成を取ることで Mattermost にメッセージを投稿することができます．
特に ```response_type``` フィールドを ```comment``` に設定すると発火元のメッセージにスレッド返信することができます．[^MATTERMOST/OUTGOING-PARAM]
```response_type``` フィールドのデフォルト値である ```post``` では新規投稿を返すことになっていますが，```text``` を含むデータを Mattermost に返さなければメッセージ投稿をしないようにすることもできます．

[^MATTERMOST/OUTGOING-PARAM]: https://developers.mattermost.com/integrate/webhooks/outgoing/#parameters

ここでは Slack の例で示した [Event API](https://qiita.com/caunu-s/items/5981d6faff2a3736abb0#event-api) と同じ動作を再現し，Outgoing Webhook から呼び出された Web アプリが Incoming Webhook を使って Mattermost にメッセージを投稿するようにします．
加えて ```response_type``` フィールドを ```comment``` に設定して発火元の投稿にも返信を行います．
返信内容は [WorldTimeAPI](https://worldtimeapi.org/) の返答値 (```datetime```) とします．

ただし，本記事の構成では Mattermost をインターネット公開していないため [Google Apps Script (GAS)](https://script.google.com/home) 等のサービス側から Mattermost に向かっての通信はできないことに注意します．

本記事の Incoming/Outgoing Webhook 構成ではクローズドな環境を想定し，Mattermost および Web アプリをローカルのコンテナで起動します．
各コンテナで名前解決をするため ```localhost``` は封印し，Mattermost と同じ Docker Network に配置した Golang コンテナ上に API サーバを立てて通信を行います．

まずは API サーバを作成します．
```url_mattermost``` には先ほど設定した Incoming Webhook を指定しますが ```localhost``` ではなく，リバースプロキシのコンテナ名である ```nginx_mattermost``` を利用します．

```shell
# コーディング
mkdir -p $HOME/codes/GitHub/mattermost/go && cd $_ && \
vim main.go
# API サーバ立ち上げ
docker run --rm \
--name go-apiserver \
--network mattermost \
-p 8081:8081 \
-it -v $HOME/codes/GitHub/mattermost/go:/go/src \
golang:1.21.3 go run ./src/main.go
```

```golang: main.go
package main

import (
	"fmt"
	"log"
    "net/http"
    "encoding/json"
	"io/ioutil"
	"bytes"
	"crypto/tls"
)

type Response struct {
	Datetime string `json:"datetime"`
}

type Request struct {
	Text          string
	Channel       string
	Response_Type string
}

func Handler(w http.ResponseWriter, r *http.Request) {
	// WorldTimeAPI Response
	url_time := "http://worldtimeapi.org/api/timezone/Asia/Tokyo"
	res_time, err_time := http.Get(url_time)
	if err_time != nil {
		fmt.Println(err_time)
    }

	defer res_time.Body.Close()
	rbody, _ := ioutil.ReadAll(res_time.Body)
	var response Response
	json.Unmarshal(rbody, &response)
	
	// Mattermost Request
	// url_mm := "https://localhost/hooks/xxxxxxxxxx"
	url_mm := "https://nginx_mattermost/hooks/xxxxxxxxxx"
	channel_mm := "town-square"
	response_type := "comment"

	request := &Request{Text: response.Datetime, Channel: channel_mm, Response_Type: response_type}
	jsonData, _ := json.Marshal(request)
	http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
	req, _ := http.NewRequest("POST", url_mm, bytes.NewBuffer(jsonData))
	req.Header.Set("Content-Type", "application/json")
	var client = &http.Client{}
	res_mm, err_mm := client.Do(req)
	if err_mm != nil {
		fmt.Println(err_mm)
    }
	defer res_mm.Body.Close()

	w.WriteHeader(http.StatusOK)
	// w.Write([]byte(fmt.Sprintf("Datetime: %s", request.Text)))
	fmt.Fprint(w, bytes.NewBuffer(jsonData).String())
}

func HandlerIco(w http.ResponseWriter, r *http.Request) {}

func handleRequests() {
	http.HandleFunc("/", Handler)
	http.HandleFunc("/favicon.ico", HandlerIco)
    log.Fatal(http.ListenAndServe(":8081", nil))
}

func main() {
    handleRequests()
}
```

```System Console``` > ```Web Server``` から insecure オプションで接続できるようになっていることを確認し，```System Console``` > ```Developer``` から先ほど作成した ```go-apiserver``` (ドメイン名) または ```docker network inspect mattermost``` で確認できる IP アドレスをホワイトリストに設定しておきます．

![mattermost-insecure-webhook.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/81dab128-9b7d-120d-8675-a0b6379cf910.png)

![mattermost-developer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/ddcfb55f-5a87-c71b-9154-dd50f65fd9a4.png)

ここまで終わったら ```Integrations``` > ```Outgoing Webhooks``` から Webhook を作成していきます．
```Callback URLs``` には ```http://go-apiserver:8081``` を設定しておきます．
Webhook 起動条件はメッセージの最初の単語が ```hoge``` であった場合としておきます．

![mattermost-outgoing-webhook.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/1e925f99-d863-1b8c-164d-7ef2a52973a9.png)

Off-Topic チャンネルで ```hoge``` と投稿してみるとスレッド返信で WorldTimeAPI の ```datetime``` が返ってきていると同時に，Incoming Webhook にて Town Square チャンネルにも同時刻の結果が送信されていることがわかります．

![mattermost-outin-res-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/2c9f02cc-6f2d-e3dc-e794-7878ad300ade.png)

```go-apiserver``` コンテナを停止し，

- ```w.Write([]byte(fmt.Sprintf("Datetime: %s", request.Text)))``` のコメントアウトを外す
- ```fmt.Fprint(w, bytes.NewBuffer(jsonData).String())``` をコメントアウト

をしてコンテナを再起動し，再び Off-Topic チャンネルで ```hoge``` と投稿してみると Incoming Webhook の処理は行われるものの Outgoing Webhook に対するレスポンスには返信情報がないため Town Square チャンネルのみに投稿されることが確認できます．

![mattermost-outin-res-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/added6a3-210b-b866-21bd-dc2f8c7326a5.png)

## [Slash Commands](https://developers.mattermost.com/integrate/slash-commands/)

Slack と同様に Mattermost でもスラッシュコマンドを提供しています．
Outgoing webhooks と異なりプライベートチャンネルや DM でも利用できます．

[組み込み済みのスラッシュコマンド](https://docs.mattermost.com/collaborate/built-in-slash-commands.html)がいくつか準備されているので，既存のもの以上の機能が欲しい場合は新規に登録します．

本記事では [Slack でのスラッシュコマンド](https://qiita.com/caunu-s/items/5981d6faff2a3736abb0#slash-commands) と同じように [Google Apps Script (GAS)](https://script.google.com/home) で WorldTimeAPI を叩くアプリを作成し，Slack のメッセージ作成ボックスから ```/time``` コマンドを打ち込むことで WorldTimeAPI の ```datetime``` を取得します．
また，引数には任意の timezones[^TIMEAPI/TIMEZONES] を付与できるものとし，引数がない場合は ```Asia/Tokyo``` で問い合わせを行うものとします．

[^TIMEAPI/TIMEZONES]: https://worldtimeapi.org/timezones

サーバー側で接続を検証するための token は，スラッシュコマンド作成後画面に表示されます．
最初は適当な Request URL を入力しておいて，token を取得後サーバーを立てて URL を発行してからスラッシュコマンドの Request URL を変更する手順で作業します．

![mattermost-slash-command.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/30e5e608-6c7a-a1fa-71e9-f0dd52e940c3.png)

```javascript: postTime.gs
function doPost(e) {
  const url = "http://worldtimeapi.org/api/timezone/";
  const token = e.parameter.token;
  const timezone = e.parameters.text[0] || "Asia/Tokyo";

  // Verification
  if (token === "xxxxxxxxxxxxxxxxxxxx") {

    // Access WorldTimeAPI
    const res = UrlFetchApp.fetch(url + timezone);
    const jsonRes = JSON.parse(res);
    return ContentService.createTextOutput(JSON.stringify(jsonRes.datetime));

  } else {
    throw new Error("Invalid request.");
  }
}
```

以上で ```/time``` コマンドが Mattermost から使えるようになります．
スラッシュコマンドの実行結果 (応答) はスラッシュコマンド実行者にのみ表示されます．

スラッシュコマンドは Slack とほぼ同じ感覚で構築・利用することができました．

## API

Mattermost では上記のほかにも API を公開しており，

- [認証](https://api.mattermost.com/#tag/authentication)
- [ユーザー追加](https://api.mattermost.com/#tag/users/operation/CreateUser)
- [ユーザー検索](https://api.mattermost.com/#tag/users/operation/SearchUsers)
- [チャンネル追加](https://api.mattermost.com/#tag/channels/operation/CreateChannel)
- [チャンネル検索](https://api.mattermost.com/#tag/channels/operation/GetAllChannels)
- [メッセージ投稿](https://api.mattermost.com/#tag/posts/operation/CreatePost)
 
などチャットツールとしてやりたいことは大方実行することができます．

API を利用するには適切な権限が付与されている必要があります．
[公式ドキュメント](https://api.mattermost.com/)を参照し，```System Console``` > ```Permisions``` の ```All Members``` あるいは ```System Administrators``` から API を叩くユーザーに権限が割り振られているかを確認してください．

![mattermost-permissions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/4993ad50-4d08-36a4-2af9-92b0c190e478.png)

なお，カスタム Role や Group による権限管理は残念ながら Enterprise を利用しなければできません．
OSS 版では ```System Admin``` か ```Member``` のいずれかのロールを使うことになります． 

```shell
# ログイン，レスポンスヘッダに session token が含まれる．以降 Bearer token としてリクエストヘッダに含める
curl -i -d '{"login_id":"mattermost-test","password":"test-password"}' \
https://localhost/api/v4/users/login --insecure

# ユーザー検索
curl -i -H 'Authorization: Bearer xxxxxxxxxx' \
-d '{"term":"test"}' https://localhost/api/v4/users/search --insecure

# チャンネル検索
curl -i -H 'Authorization: Bearer xxxxxxxxxx' \
https://localhost/api/v4/channels --insecure

# メッセージ投稿，channel_id はチャンネル検索結果を参照する，props を入れることもできる
curl -i -H 'Authorization: Bearer xxxxxxxxxx' \
-d '{"channel_id":"7twzyknxibn9zm917m5hppeyxw", "message":"Hello world 2 :tada:", "props": {"card": "External Link:\n\n [Click Here](https://worldtimeapi.org/)\n\nSite: **World Time API**"}}' \
https://localhost/api/v4/posts --insecure

# メッセージ返信，メッセージ投稿時のレスポンスに含まれる id を root_id とすることでぶら下げる
curl -i -H 'Authorization: Bearer xxxxxxxxxx' \
-d '{"channel_id":"7twzyknxibn9zm917m5hppeyxw", "message":"Hello world 3 :tada:", "root_id": "xnjma8dkebd4dgt78kbt6qmuxy"}' \
https://localhost/api/v4/posts --insecure
```

また WebSocket もサポートしており，こちらは別の記事で動作検証を行いたいと思います．

## [Plugins](https://developers.mattermost.com/integrate/plugins/)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/09270fd7-031c-0d34-afba-97121e2dd5b8.png)

Mattermost では Plugin (プラグイン)と Apps framework の概念によって，Slack での [Interactivity & Shortcuts](https://qiita.com/caunu-s/items/5981d6faff2a3736abb0#interactivity--shortcuts) に相当する拡張機能を提供します．
プラグインでは，Mattermost でのイベントに応じたサービス間連携や Mattermost の画面コンポーネントの実装，チャンネルへの自動応答などを組み込むことで Mattermost の機能追加や使用感の改善を行います．

デフォルト状態で GitHub, GitLab, Jira といったサービスとの連携を行うプラグインの有効化が可能ですが，その他にも [Marketplace](https://mattermost.com/marketplace-category/popular/) から欲しい機能を検索して取り込んだり，自作のカスタムプラグインを仕込むことが可能です．

カスタムプラグインでは

- Go によるサーバーコンポーネント
- React/Redux フレームワークを利用した Javascript による (主に) フロントエンドコンポーネント

の一方または両方の実装を行います．

本記事では公式の [mattermost-plugin-starter-template](https://github.com/mattermost/mattermost-plugin-starter-template) から 2 種類のリポジトリを作成し，上記のそれぞれでカスタムプラグインの開発を試します．
(Marketplace から既存のプラグインを検索して組み込む例は [Apps framework](#apps-framework) で示します．)

- (1) Go による WorldTimeAPI への問い合わせ & チャットへの応答プラグイン
- (2) React/Redux フレームワークによるアラート発動ボタンの実装プラグイン

![repo-from-template.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/15539aca-2f01-5755-da93-30317736a498.png)

```shell
# (1) サンプルプラグイン
cd $HOME/codes/GitHub/ && \
git clone https://github.com/caunu-s/mattermost-sample-time-plugin.git && \
cd mattermost-sample-time-plugin

# (2) サンプルプラグイン
cd $HOME/codes/GitHub/ && \
git clone https://github.com/caunu-s/mattermost-sample-alert-plugin.git && \
cd mattermost-sample-alert-plugin
```

### (1) Go による WorldTimeAPI への問い合わせ & チャットへの応答プラグイン

```/webapp``` は使わないので削除しておきます．

```/server/plugin.go``` では ```Plugin``` の構造体を定義するほか，プラグインのエンドポイントに接続したときのレスポンスなどが記載されています．
レスポンス部分だけ少し書き直しておきます．

```golang: /server/plugin.go
...
func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello, WorldtimeAPI!")
}
...
```

プラグインで行う追加の処理については [Hooks](https://developers.mattermost.com/integrate/reference/server/server-reference/#Hooks) を参考にして実装していきます．
例えば [MessageWillBePosted](https://developers.mattermost.com/integrate/reference/server/server-reference/#Hooks.MessageWillBePosted) は以下の実装をします．

```
MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string)
```

投稿されたメッセージに "hoge" が含まれる場合 WorldTimeAPI に問い合わせを行い，投稿されたメッセージに ```datetime``` を付記します．

実装は以下の記事を参考にしています．

https://zenn.dev/kaakaa/articles/qiita-20190101-f65e995eca8a1eff39c7#hooks.go

```golang: /server/hooks.go
package main

import (
	"fmt"
	"strings"
    "net/http"
    "encoding/json"
	"io/ioutil"

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

type Response struct {
	Datetime string `json:"datetime"`
}

func (p *Plugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
	if strings.Index(post.Message, "hoge") == -1 {
		return post, ""
	}

	p.API.LogDebug("Request WorldtimeAPI")

	url_time := "http://worldtimeapi.org/api/timezone/Asia/Tokyo"
	res_time, err_time := http.Get(url_time)
	if err_time != nil {
		fmt.Println(err_time)
    }
	defer res_time.Body.Close()
	rbody, _ := ioutil.ReadAll(res_time.Body)
	var response Response
	json.Unmarshal(rbody, &response)

	post.Message = fmt.Sprintf("%s\n%s", post.Message, response.Datetime)
	return post, ""
}
```

```plugin.json``` を書き換えます．

```json: plugin.json
{
    "id": "com.example.my-go-plugin",
    "name": "My Go Plugin",
    "description": "A plugin to enhance Mattermost by Golang.",
    "homepage_url": "https://github.com/caunu-s/mattermost-sample-time-plugin",
    "support_url": "https://github.com/caunu-s/mattermost-sample-time-plugin/issues",
    "icon_path": "assets/starter-template-icon.svg",
    "version": "0.1.0",
    "min_server_version": "9.3.0",
    "server": {
        "executables": {
            "linux-amd64": "server/dist/plugin-linux-amd64"
        }
    }
}
```

```go.mod``` は削除しておき，改めて作成します．

```shell
go mod init github.com/caunu-s/mattermost-sample-time-plugin
go mod tidy
```

最後に ```Makefile``` を作成します (テストなどは一旦無視します)．

```makefile: Makefile
PLUGIN_ID ?= com.example.my-go-plugin
PLUGIN_VERSION ?= 0.1.0
BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz

.PHONY: server
server:
	mkdir -p ./server/dist
	GOOS=linux GOARCH=amd64 go build -o ./server/dist/plugin-linux-amd64 ./server
	tar -czvf ./server/dist/$(BUNDLE_NAME) ./server/dist/plugin-linux-amd64 plugin.json;
```

```make``` します．

```shell
make server
```

これで ```com.example.my-go-plugin-0.1.0.tar.gz``` ができたはずなので Mattermost に設定します．

```System Console``` > ```Plugin Management``` から ```Enable Plugin``` が ```true``` となっていることを確認し，Upload Plugin から先ほど作成した ```com.example.my-go-plugin-0.1.0.tar.gz``` をインストールします．
完了したらサイドバーに "My Go Plugin" が見えるようになるので移動して ```Enable Plugin``` を ```true``` にします．

![mattermost-custom-plugin-sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/b4cc04ad-b37a-6918-e662-14ac7cc84244.png)

適当なチャンネルで ```hoge``` を含むメッセージ投稿を試すと，メッセージの投稿にあわせて WorldTimeAPI の Datetime が付記されることがわかります．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/7fd7b484-9ad8-2858-371e-f8bf66009248.png)

なお https://localhost/plugins/com.example.my-go-plugin に接続すると，HTTP サーバーとしての応答を確認できます．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/dd350a72-c3b6-58a2-ab9b-a31ae24a2a18.png)

### (2) React/Redux フレームワークによるアラート発動ボタンの実装プラグイン

ここでは[公式ドキュメント](https://developers.mattermost.com/integrate/plugins/components/webapp/hello-world/)を参考に，プラグインでのボタンの組み込みとボタンクリック時の alert 発動を実装します．

```/server``` は使わないので削除しておきます．
また，今回は ```/webapp``` もすべて削除してから開発を開始します．

```shell
# working dir 作成
mkdir webapp
cd webapp

# npm install
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react babel-loader webpack webpack-cli
npm install --save react
```

続いて ```/webapp/webpack.config.js``` を作成します．

```js: /webapp/webpack.config.js
var path = require('path');

module.exports = {
    entry: [
        './src/index.jsx',
    ],
    resolve: {
        modules: [
            'src',
            'node_modules',
        ],
        extensions: ['*', '.js', '.jsx'],
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-react',
                            [
                                "@babel/preset-env",
                                {
                                    "modules": "commonjs",
                                    "targets": {
                                        "node": "current"
                                    }
                                }
                            ]
                        ],
                    },
                },
            },
        ],
    },
    externals: {
        react: 'React',
    },
    output: {
        path: path.join(__dirname, '/dist'),
        publicPath: '/',
        filename: 'main.js',
    },
};
```

```/webapp/src/index.jsx``` で ```Plugin``` を実装していきます．
[```registerChannelHeaderButtonAction```](https://developers.mattermost.com/integrate/reference/webapp/webapp-reference/#registerChannelHeaderButtonAction) を利用してプラグインによるボタンを配置します．

```
/**
    * Add a button to the channel header. If there are more than one buttons registered by any
    * plugin, a dropdown menu is created to contain all the plugin buttons.
    * Accepts the following:
    * - icon - React element to use as the button's icon
    * - action - a function called when the button is clicked, passed the channel and channel member as arguments
    * - dropdownText - string or React element shown for the dropdown button description
    * - tooltipText - string or React element shown for tooltip appear on hover
*/
registerChannelHeaderButtonAction([icon action dropdownText tooltipText])
```

```tsx: /webapp/src/index.jsx
import React from 'react';

const Icon = () => <i className='icon fa fa-plug'/>;

class HelloWorldPlugin {
    initialize(registry, store) {
        registry.registerChannelHeaderButtonAction(
            <Icon />,
            () => {
                alert("Hello World!");
            },
            "Hello World",
        );
    }
}

window.registerPlugin('com.example.my-react-plugin', new HelloWorldPlugin());
```

最後に ```plugin.json``` と ```Makefile``` を準備して ```tar.gz``` を作成していきます．

```json: plugin.json
{
    "id": "com.example.my-react-plugin",
    "name": "My React Plugin",
    "description": "A plugin to enhance Mattermost by React.",
    "homepage_url": "https://github.com/caunu-s/mattermost-sample-alert-plugin",
    "support_url": "https://github.com/caunu-s/mattermost-sample-alert-plugin/issues",
    "icon_path": "assets/starter-template-icon.svg",
    "version": "0.1.0",
    "min_server_version": "9.3.0",
    "webapp": {
        "bundle_path": "webapp/dist/main.js"
    }
}
```

```makefile: Makefile
PLUGIN_ID ?= com.example.my-react-plugin
PLUGIN_VERSION ?= 0.1.0
BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz

.PHONY: webapp
webapp:
	tar -czvf ./webapp/dist/$(BUNDLE_NAME) ./webapp/dist/main.js plugin.json;
```

```shell
# webpack, /webapp/dist/main.js が作成される
./node_modules/.bin/webpack --mode=production

# tar.gz 作成
cd ..
make webapp
```

これで ```com.example.my-react-plugin-0.1.0.tar.gz``` ができたので Mattermost に設定します．
Mattermost 画面右側のバーに ```Hello World``` ボタンができており，クリックすると alert が表示されます．

![mattermost-custom-plugin-sample-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/d1316035-666b-00c9-038a-f7fc3f663bb5.png)

## [Apps framework](https://developers.mattermost.com/integrate/apps/)

Apps framework ではプラグイン実装における一般的なユースケースを実現するインタフェースをカバーした Apps Plugin を活用し，Apps Plugin と任意の言語/環境で作成した HTTP サーバーを連携することで Mattermost の機能拡張や使用感の改善を行います．[^MATTERMOST/APPS-VS-PLUGINS]

[^MATTERMOST/APPS-VS-PLUGINS]: https://mattermost.com/blog/building-mattermost-apps-vs-plugins/

処理を Mattermost サーバーから切り離すことで，拡張性が上がるだけでなく「各サーバーの死活監視のコストは上がる v.s Mattermost サーバーの負荷を下げられる」などのバランスを考慮して導入することになると思います．
また，HTTP サーバーの冗長構成と組み合わせることも考えられます．

[公式ドキュメントで紹介されているサンプル](https://github.com/mattermost/mattermost-app-examples) (```mattermost-enterprise-edition:7.9.0```) では ```MM_FEATUREFLAGS_AppsEnabled=true``` の環境変数を設定することで Apps Plugin を有効化していますが，```mattermost-enterprise-edition:9.3.0``` 現在では [Marketplace](https://mattermost.com/marketplace/app-framework/) から利用可能状況を確認し，[GitHub の releases](https://github.com/mattermost/mattermost-plugin-apps/releases) から取得した ```tar.gz``` を導入して有効化する手順に変わっています．

![mattermost-apps-plugin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/4c6060ff-8271-053d-9daa-3cdd0288936d.png)

公式ではGo, Python, Typescript での Apps framework 利用例を準備していますが，本記事では [golang/hello-world](https://github.com/mattermost/mattermost-app-examples/tree/master/golang/hello-world) を参考に WorldTimeAPI と連携を行う App をたちあげます．

```shell
# コーディング
mkdir -p $HOME/codes/GitHub/mattermost-sample-time-app && cd $_ && \
vim docker-compose.yml
vim main.go

# Go モジュール準備
go mod init github.com/caunu-s/mattermost-sample-time-app
go mod tidy

# 起動
docker compose up
```

アイコン (```icon.png```) は [いらすとや](https://1.bp.blogspot.com/-UZtkSEX0wh4/U5l5_dNcEsI/AAAAAAAAhWs/UzJGVzyiX8Y/s800/kaichu_dokei.png)さんから利用させていただきます．
実装方法の詳細は [Golang driver](https://developers.mattermost.com/integrate/apps/drivers/golang/) のドキュメントと [mattermost-plugin-apps](https://pkg.go.dev/github.com/mattermost/mattermost-plugin-apps) モジュールのドキュメントをご覧ください．

```yaml: docker-compose.yml
version: "3.9"
networks:
  default:
    name: "mattermost"
services:
  mattermost-apps-datetime:
    container_name: mattermost-apps-datetime
    image: "golang:1.21.3"
    restart: "unless-stopped"
    working_dir: "/app"
    ports:
      - "4000:4000"
    volumes:
      - "./:/app"
    command: "bash -c 'go run .'"
```

```golang: main.go
package main

import (
	_ "embed"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"io/ioutil"

	"github.com/mattermost/mattermost-plugin-apps/apps"
	"github.com/mattermost/mattermost-plugin-apps/utils/httputils"
)

//go:embed icon.png
var IconData []byte

var Manifest = apps.Manifest{
	AppID: "datetime",
	Version: "v0.1.0",
	DisplayName: "Datetime All over The World",
	Icon: "icon.png",
	HomepageURL: "https://github.com/caunu-s/mattermost-sample-time-app",
	RequestedPermissions: []apps.Permission{
		apps.PermissionActAsBot,
		apps.PermissionActAsUser,
	},
	RequestedLocations: []apps.Location{
		apps.LocationChannelHeader,
		apps.LocationCommand,
	},
	Deploy: apps.Deploy{
		HTTP: &apps.HTTP{
			RootURL: "http://mattermost-apps-datetime:4000",
		},
	},
}

var Bindings = []apps.Binding{
	{
		Location: apps.LocationChannelHeader,
		Bindings: []apps.Binding{
			{
				Location: "send-button",
				Icon:     "icon.png",
				Label:    "check datetime",
				Form:     &SendForm,
			},
		},
	},
	{
		Location: "/command",
		Bindings: []apps.Binding{
			{
				Icon:        "icon.png",
				Label:       "datetime",
				Description: "Datetime app",
				Hint:        "[send]",
				Bindings: []apps.Binding{
					{
						Label: "send",
						Form:  &SendForm,
					},
				},
			},
		},
	},
}

var SendForm = apps.Form{
	Title: "Datetime All over The World",
	Icon:  "icon.png",
	Fields: []apps.Field{
		{
			Type: "text",
			Name: "Timezone",
		},
	},
	Submit: apps.NewCall("/send").WithExpand(apps.Expand{
		ActingUserAccessToken: apps.ExpandAll,
		ActingUser:            apps.ExpandID,
	}),
}

type Response struct {
	Datetime string `json:"datetime"`
}

func main() {
	http.HandleFunc("/manifest.json",
		httputils.DoHandleJSON(Manifest))
	http.HandleFunc("/static/icon.png",
		httputils.DoHandleData("image/png", IconData))
	http.HandleFunc("/bindings",
		httputils.DoHandleJSON(apps.NewDataResponse(Bindings)))
	http.HandleFunc("/send", Send)

	addr := ":4000"
	fmt.Println("Listening on", addr)
	fmt.Println("Use '/apps install http http://mattermost-apps-datetime" + addr + "/manifest.json' to install the app")
	log.Fatal(http.ListenAndServe(addr, nil))
}

func Send(w http.ResponseWriter, req *http.Request) {
	c := apps.CallRequest{}
	json.NewDecoder(req.Body).Decode(&c)

	url := "http://worldtimeapi.org/api/timezone/"
	v, ok := c.Values["Timezone"]
	if ok && v != nil {
		url += fmt.Sprintf("%s", v)
	} else {
		url += "Asia/Tokyo"
	}

	res_time, _ := http.Get(url)
	defer res_time.Body.Close()
	rbody, _ := ioutil.ReadAll(res_time.Body)
	var response Response
	json.Unmarshal(rbody, &response)

	httputils.WriteJSON(w,
		apps.NewTextResponse(string(response.Datetime)))
}
```

本構成では App との疎通のため Mattermost 側で ```Allow untrusted internal connections``` に ```mattermost-apps-datetime``` を書き加えて Mattermost を再起動する必要があります．

![mattermost-apps-whitelist.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/ca794518-8daa-d9b5-0ec9-5f5bd84aad01.png)

```shell
# 再起動
cd $HOME/codes/GitHub/mattermost
docker compose -f docker-compose.yml -f docker-compose.nginx.yml restart
```

Mattermost 再起動後，スラッシュコマンド ```/apps install http http://mattermost-apps-datetime:4000/manifest.json``` を Mattermost で実行することでインストールします．

![mattermost-install-app-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/37b4fbd1-0dd3-8578-913a-679725dba237.png)

Mattermost 画面右側のバーに ```Datetime``` ボタンができ，クリックするとモーダルが表示されます．
任意の [Timezone](https://worldtimeapi.org/timezones) を入力することができ，入力がなければ ```Asia/Tokyo``` で実行されます．

![mattermost-install-app-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/cafe14fe-5215-404e-59ae-e1e8f46b516e.png)

![mattermost-datetime-result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/070db3f8-71c1-0157-b6ed-021bd5c1e669.png)

Bot ユーザーが作成されており，スラッシュコマンド ```/datetime``` からも実行可能であることも確認できます．

![mattermost-slash-command-datetime.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/36f91306-c8c5-cdb2-75a1-a8778e31292f.png)

## まとめ

かなりの長文になってしまいましたが，一通り触ってみた感想としては Mattermost の拡張性はかなり高いです．
無償で閉じた環境でのチャットサーバーとして稼働できるという点において，通信要件に関する制御はかなり細かくできると感じました．

一方で，やはりインターネット上でも日本語での説明は足りないと感じています．
興味を持たれた方は是非触ってみていただいて，日本でもコミュニティがより活性化するといいなと思います．