Mattermost

コミュニケーションセルフホストプラグイン開発APIBot開発オープンソース

コミュニケーションツール

Mattermost

概要

Mattermostは、セルフホスト可能なオープンソースのメッセージングプラットフォームです。Slack風のUI/UXでありながら、完全に自社環境で運用でき、データ主権とセキュリティを重視する企業に最適です。豊富なプラグインシステム、Bot開発機能、RESTful APIを提供し、高度なカスタマイズが可能です。

詳細

Mattermost(マターモスト)は、2016年にリリースされたオープンソースの企業向けメッセージングプラットフォームです。Slackの代替として設計され、特にセキュリティとプライバシーを重視する企業・組織での採用が拡大しています。GitLabを始めとした多くのDevOpsツールとの統合が豊富で、開発チームでの利用に特化した機能が充実しています。

2024-2025年には、Playbooks v2の導入、AI/LLM統合のAgentsプラグイン、Docker Content TrustからSigstore Cosignへの移行、カスタムプロファイル属性フィールドの追加など、大幅な機能強化が行われました。プラグインシステムはGoとReactで構築でき、サーバー・Web・デスクトップアプリの動作を変更できる強力な拡張性を提供します。

MattermostのREST APIはOpenAPI仕様に準拠し、Bot開発やカスタム統合の構築が容易です。セルフホスト環境では無制限のユーザー数で利用でき、エンタープライズ向けの高度なセキュリティ機能と管理機能を提供します。また、Mattermost Marketplace から事前構築されたプラグインをインストールすることも、独自のプラグインを開発することも可能です。

メリット・デメリット

メリット

  • 完全セルフホスト: 自社環境での運用、データ主権の確保
  • オープンソース: ソースコード公開、カスタマイズ自由度の高さ
  • 豊富なプラグインシステム: Go + React による強力な拡張機能
  • DevOps統合: GitHub、GitLab、Jenkins等との豊富な連携
  • エンタープライズセキュリティ: LDAP/SAML、多要素認証、監査ログ
  • 無制限ユーザー: セルフホスト環境での制限なし利用
  • Bot・API機能: RESTful API、Interactive Bot、Webhook対応
  • AI統合: Agentsプラグインによる複数LLM対応

デメリット

  • 運用コスト: サーバー構築・保守・管理の必要性
  • 学習コスト: セットアップとカスタマイズの技術的要求
  • クラウド版制限: Mattermost Cloudでのカスタムプラグイン制限
  • UI/UX: Slackと比較したユーザビリティの課題
  • モバイルアプリ: ネイティブアプリの機能制限
  • マーケットプレイス: プラグインエコシステムの規模

主要リンク

書き方の例

Mattermostプラグイン開発の基本構造

// server/main.go - プラグインのメインエントリーポイント
package main

import (
    "fmt"
    "encoding/json"
    "github.com/mattermost/mattermost/server/public/plugin"
    "github.com/mattermost/mattermost/server/public/model"
)

type Plugin struct {
    plugin.MattermostPlugin
    configuration *configuration
}

type configuration struct {
    Enabled bool `json:"enabled"`
    APIKey  string `json:"api_key"`
}

// OnActivate プラグインが有効化されたときに呼ばれる
func (p *Plugin) OnActivate() error {
    p.API.LogInfo("プラグインが有効化されました")
    
    // スラッシュコマンドの登録
    if err := p.API.RegisterCommand(&model.Command{
        Trigger:          "hello",
        DisplayName:      "Hello World",
        Description:      "Hello Worldコマンドを実行",
        AutoComplete:     true,
        AutoCompleteDesc: "Hello Worldメッセージを送信",
    }); err != nil {
        return fmt.Errorf("コマンド登録エラー: %w", err)
    }
    
    return nil
}

// ExecuteCommand スラッシュコマンドが実行されたときに呼ばれる
func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
    switch args.Command {
    case "/hello":
        return &model.CommandResponse{
            ResponseType: model.CommandResponseTypeEphemeral,
            Text:         "Hello, World! プラグインから送信されました。",
        }, nil
    default:
        return &model.CommandResponse{
            ResponseType: model.CommandResponseTypeEphemeral,
            Text:         "不明なコマンドです。",
        }, nil
    }
}

// MessageWillBePosted メッセージが投稿される前に呼ばれる
func (p *Plugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
    // メッセージに特定のキーワードが含まれている場合の処理
    if contains(post.Message, "emergency") {
        // 緊急メッセージの場合、管理者に通知
        p.sendAdminNotification(post)
    }
    
    return post, ""
}

func (p *Plugin) sendAdminNotification(originalPost *model.Post) {
    adminPost := &model.Post{
        UserId:    p.botUserID,
        ChannelId: p.adminChannelID,
        Message:   fmt.Sprintf("🚨 緊急メッセージが投稿されました: %s", originalPost.Message),
    }
    
    if _, err := p.API.CreatePost(adminPost); err != nil {
        p.API.LogError("管理者通知の送信に失敗", "error", err.Error())
    }
}

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

Webhook統合の実装

// webhook.go - Webhook処理の実装
package main

import (
    "encoding/json"
    "net/http"
    "github.com/mattermost/mattermost/server/public/plugin"
    "github.com/mattermost/mattermost/server/public/model"
)

type WebhookPayload struct {
    EventType string      `json:"event_type"`
    Data      interface{} `json:"data"`
}

// InitAPI HTTPルートの初期化
func (p *Plugin) InitAPI() *mux.Router {
    r := mux.NewRouter()
    r.HandleFunc("/webhook", p.handleWebhook).Methods("POST")
    r.HandleFunc("/health", p.handleHealth).Methods("GET")
    return r
}

func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
    var payload WebhookPayload
    
    if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
        http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
        return
    }
    
    switch payload.EventType {
    case "deployment.success":
        p.handleDeploymentSuccess(payload.Data)
    case "build.failed":
        p.handleBuildFailed(payload.Data)
    case "security.alert":
        p.handleSecurityAlert(payload.Data)
    default:
        p.API.LogInfo("未処理のWebhookイベント", "event_type", payload.EventType)
    }
    
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status": "success"}`))
}

func (p *Plugin) handleDeploymentSuccess(data interface{}) {
    deploymentData := data.(map[string]interface{})
    
    post := &model.Post{
        UserId:    p.botUserID,
        ChannelId: p.deploymentChannelID,
        Message:   fmt.Sprintf("✅ デプロイメント成功: %s", deploymentData["service"]),
        Props: map[string]interface{}{
            "attachments": []map[string]interface{}{
                {
                    "color":  "#28a745",
                    "title":  "デプロイメント詳細",
                    "fields": []map[string]interface{}{
                        {"title": "サービス", "value": deploymentData["service"], "short": true},
                        {"title": "バージョン", "value": deploymentData["version"], "short": true},
                        {"title": "環境", "value": deploymentData["environment"], "short": true},
                    },
                },
            },
        },
    }
    
    if _, err := p.API.CreatePost(post); err != nil {
        p.API.LogError("投稿作成エラー", "error", err.Error())
    }
}

Interactive Dialog の実装

// dialog.go - インタラクティブダイアログの実装
func (p *Plugin) openTaskCreationDialog(userID, triggerId string) {
    dialog := model.OpenDialogRequest{
        TriggerId: triggerId,
        URL:       fmt.Sprintf("%s/plugins/%s/dialog", p.GetSiteURL(), manifest.Id),
        Dialog: model.Dialog{
            CallbackId:       "create_task_dialog",
            Title:            "新しいタスクを作成",
            IconURL:          p.GetBundlePath() + "/assets/icon.png",
            SubmitLabel:      "作成",
            NotifyOnCancel:   true,
            Elements: []model.DialogElement{
                {
                    DisplayName: "タスク名",
                    Name:        "task_title",
                    Type:        "text",
                    SubType:     "text",
                    MaxLength:   100,
                    Placeholder: "タスクの名前を入力してください",
                },
                {
                    DisplayName: "説明",
                    Name:        "task_description",
                    Type:        "textarea",
                    MaxLength:   500,
                    Placeholder: "タスクの詳細を入力してください",
                    Optional:    true,
                },
                {
                    DisplayName: "優先度",
                    Name:        "priority",
                    Type:        "select",
                    Options: []*model.PostActionOptions{
                        {Text: "低", Value: "low"},
                        {Text: "中", Value: "medium"},
                        {Text: "高", Value: "high"},
                        {Text: "緊急", Value: "urgent"},
                    },
                },
                {
                    DisplayName: "期限",
                    Name:        "due_date",
                    Type:        "text",
                    SubType:     "date",
                    Optional:    true,
                },
            },
        },
    }
    
    if err := p.API.OpenInteractiveDialog(dialog); err != nil {
        p.API.LogError("ダイアログオープンエラー", "error", err.Error())
    }
}

// ダイアログの送信処理
func (p *Plugin) handleDialogSubmission(w http.ResponseWriter, r *http.Request) {
    var request model.SubmitDialogRequest
    if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    if request.CallbackId == "create_task_dialog" {
        task := Task{
            Title:       request.Submission["task_title"].(string),
            Description: request.Submission["task_description"].(string),
            Priority:    request.Submission["priority"].(string),
            DueDate:     request.Submission["due_date"].(string),
            CreatedBy:   request.UserId,
            CreatedAt:   time.Now(),
        }
        
        if err := p.createTask(task); err != nil {
            response := model.SubmitDialogResponse{
                Error: "タスクの作成に失敗しました",
            }
            w.Header().Set("Content-Type", "application/json")
            json.NewEncoder(w).Encode(response)
            return
        }
        
        // 成功応答
        w.WriteHeader(http.StatusOK)
    }
}

Bot API を使用したメッセージ処理

// Node.js でのMattermost Bot実装
const axios = require('axios');

class MattermostBot {
  constructor(serverUrl, botToken) {
    this.serverUrl = serverUrl;
    this.botToken = botToken;
    this.headers = {
      'Authorization': `Bearer ${botToken}`,
      'Content-Type': 'application/json'
    };
  }

  // メッセージ送信
  async sendMessage(channelId, message, props = {}) {
    try {
      const response = await axios.post(
        `${this.serverUrl}/api/v4/posts`,
        {
          channel_id: channelId,
          message: message,
          props: props
        },
        { headers: this.headers }
      );
      
      return response.data;
    } catch (error) {
      console.error('メッセージ送信エラー:', error.response.data);
      throw error;
    }
  }

  // ファイルアップロード
  async uploadFile(channelId, filePath, filename) {
    const FormData = require('form-data');
    const fs = require('fs');
    
    const form = new FormData();
    form.append('files', fs.createReadStream(filePath), filename);
    form.append('channel_id', channelId);
    
    try {
      const response = await axios.post(
        `${this.serverUrl}/api/v4/files`,
        form,
        {
          headers: {
            ...this.headers,
            ...form.getHeaders()
          }
        }
      );
      
      return response.data;
    } catch (error) {
      console.error('ファイルアップロードエラー:', error.response.data);
      throw error;
    }
  }

  // チャンネル情報取得
  async getChannel(channelId) {
    try {
      const response = await axios.get(
        `${this.serverUrl}/api/v4/channels/${channelId}`,
        { headers: this.headers }
      );
      
      return response.data;
    } catch (error) {
      console.error('チャンネル情報取得エラー:', error.response.data);
      throw error;
    }
  }

  // WebSocket接続でリアルタイムイベント監視
  connectWebSocket() {
    const WebSocket = require('ws');
    const ws = new WebSocket(`${this.serverUrl.replace('http', 'ws')}/api/v4/websocket`, {
      headers: { 'Authorization': `Bearer ${this.botToken}` }
    });

    ws.on('open', () => {
      console.log('WebSocket接続確立');
      
      // 認証メッセージ送信
      ws.send(JSON.stringify({
        seq: 1,
        action: 'authentication_challenge',
        data: { token: this.botToken }
      }));
    });

    ws.on('message', (data) => {
      const event = JSON.parse(data);
      this.handleWebSocketEvent(event);
    });

    return ws;
  }

  handleWebSocketEvent(event) {
    switch (event.event) {
      case 'posted':
        this.handleNewPost(event.data.post);
        break;
      case 'user_added':
        this.handleUserAdded(event.data);
        break;
      case 'channel_created':
        this.handleChannelCreated(event.data.channel);
        break;
      default:
        console.log('未処理イベント:', event.event);
    }
  }

  async handleNewPost(postData) {
    const post = JSON.parse(postData);
    
    // Bot自身の投稿は無視
    if (post.user_id === this.botUserId) return;
    
    // メンション検知
    if (post.message.includes(`@${this.botUsername}`)) {
      await this.processCommand(post);
    }
    
    // 特定キーワード監視
    if (post.message.toLowerCase().includes('help')) {
      await this.sendHelpMessage(post.channel_id);
    }
  }

  async processCommand(post) {
    const message = post.message.replace(`@${this.botUsername}`, '').trim();
    const command = message.split(' ')[0].toLowerCase();
    
    switch (command) {
      case 'weather':
        await this.handleWeatherCommand(post);
        break;
      case 'schedule':
        await this.handleScheduleCommand(post);
        break;
      case 'report':
        await this.handleReportCommand(post);
        break;
      default:
        await this.sendMessage(post.channel_id, '不明なコマンドです。`help`と入力してヘルプを表示してください。');
    }
  }
}

// 使用例
const bot = new MattermostBot('https://your-mattermost.com', 'your-bot-token');

// WebSocket接続開始
bot.connectWebSocket();

// 定期的なステータス報告
setInterval(async () => {
  const statusMessage = {
    message: '📊 システムステータス報告',
    props: {
      attachments: [{
        color: '#28a745',
        fields: [
          { title: 'CPU使用率', value: '45%', short: true },
          { title: 'メモリ使用率', value: '67%', short: true },
          { title: 'アクティブユーザー', value: '234', short: true }
        ]
      }]
    }
  };
  
  await bot.sendMessage('status-channel-id', statusMessage.message, statusMessage.props);
}, 3600000); // 1時間ごと

mmctl を使用した管理操作

# Mattermostサーバーへの認証
mmctl auth login https://your-mattermost.com --name production --username admin

# プラグインの管理
mmctl plugin add hovercardexample.tar.gz
mmctl plugin enable hovercardexample
mmctl plugin list
mmctl plugin disable hovercardexample

# Botの作成と管理
mmctl bot create mybot --description "自動化Bot" --display-name "Automation Bot"
mmctl bot list
mmctl bot enable mybot

# Webhook作成
mmctl webhook create-incoming \
  --channel general \
  --user admin \
  --display-name "Deploy Notifications" \
  --description "デプロイメント通知用Webhook"

# チーム・チャンネル管理
mmctl team create --name devops --display_name "DevOps Team"
mmctl channel create devops --name alerts --display_name "アラート通知"

# ユーザー管理
mmctl user create --email [email protected] --username newuser --password password123
mmctl user activate newuser
mmctl team users add devops newuser

環境変数設定

# .env ファイル
# Mattermost サーバー設定
MATTERMOST_SERVER_URL=https://your-mattermost.com
MATTERMOST_BOT_TOKEN=your-bot-access-token
MATTERMOST_BOT_USERNAME=automation-bot

# データベース設定(セルフホスト)
MM_SQLSETTINGS_DRIVERNAME=postgres
MM_SQLSETTINGS_DATASOURCE=postgres://user:password@localhost/mattermost?sslmode=require

# ファイルストレージ設定
MM_FILESETTINGS_DRIVERNAME=local
MM_FILESETTINGS_DIRECTORY=/opt/mattermost/data/

# プラグイン設定
MM_PLUGINSETTINGS_ENABLE=true
MM_PLUGINSETTINGS_ENABLEUPLOADS=true
MM_PLUGINSETTINGS_AUTOMATICPREPACKAGEDPLUGINS=true

# セキュリティ設定
MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS=localhost
MM_SERVICESETTINGS_ENABLELOCALMODE=false

# 外部統合
GITHUB_TOKEN=your-github-token
JIRA_API_KEY=your-jira-api-key
SLACK_WEBHOOK_URL=your-slack-webhook-url

Docker Compose 設定例

# docker-compose.yml
version: '3.8'

services:
  mattermost:
    image: mattermost/mattermost-enterprise-edition:latest
    container_name: mattermost
    ports:
      - "8065:8065"
    environment:
      - MM_SQLSETTINGS_DRIVERNAME=postgres
      - MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:password@postgres:5432/mattermost?sslmode=disable
      - MM_FILESETTINGS_DRIVERNAME=local
      - MM_FILESETTINGS_DIRECTORY=/mattermost/data/
      - MM_PLUGINSETTINGS_ENABLE=true
      - MM_SERVICESETTINGS_SITEURL=https://your-mattermost.com
    volumes:
      - mattermost_data:/mattermost/data
      - mattermost_logs:/mattermost/logs
      - mattermost_config:/mattermost/config
      - mattermost_plugins:/mattermost/plugins
    depends_on:
      - postgres
    networks:
      - mattermost-network

  postgres:
    image: postgres:13
    container_name: mattermost_postgres
    environment:
      - POSTGRES_USER=mattermost
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=mattermost
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - mattermost-network

  nginx:
    image: nginx:alpine
    container_name: mattermost_nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
    depends_on:
      - mattermost
    networks:
      - mattermost-network

volumes:
  mattermost_data:
  mattermost_logs:
  mattermost_config:
  mattermost_plugins:
  postgres_data:

networks:
  mattermost-network:
    driver: bridge

プラグインマニフェスト例

{
  "id": "com.company.automation-plugin",
  "name": "Automation Plugin",
  "description": "DevOps自動化プラグイン",
  "version": "1.0.0",
  "min_server_version": "9.0.0",
  "server": {
    "executables": {
      "linux-amd64": "server/dist/plugin-linux-amd64",
      "darwin-amd64": "server/dist/plugin-darwin-amd64",
      "windows-amd64": "server/dist/plugin-windows-amd64.exe"
    },
    "executable": ""
  },
  "webapp": {
    "bundle_path": "webapp/dist/main.js"
  },
  "settings_schema": {
    "header": "プラグイン設定",
    "footer": "詳細は[ドキュメント](https://docs.company.com/automation-plugin)を参照してください。",
    "settings": [
      {
        "key": "EnableNotifications",
        "display_name": "通知を有効にする",
        "type": "bool",
        "help_text": "自動化イベントの通知を有効にします。",
        "default": true
      },
      {
        "key": "APIEndpoint",
        "display_name": "API エンドポイント",
        "type": "text",
        "help_text": "外部APIのエンドポイントURLを入力してください。",
        "placeholder": "https://api.company.com"
      }
    ]
  }
}