Redmineからメンション付きでSlackにメッセージを送信する

#### View Customize Pluginを使って、Redmineの注記をSlackで共有する機能をつけてみました。####

処理の手順は、以下のようになっています。

- View Customize Pluginを使ってSlackに送信するデータを準備する
- クライアント側から、サーバ側に配置したコントローラをAjax通信で呼び出して実行する
- サーバ側はデータをSlackにPOSTするだけ
  
機能のイメージは以下のようになります。

Redmineのチケット詳細画面・履歴の注記に追加した「Slackで共有」ボタンをクリック
![注記コメント内容.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/761560/3b77c1b9-e5be-e433-fdf3-3e17f532d062.png)
Slackにメンション付きでメッセージが送信される
![Slackメッセージ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/761560/acb00498-4c6f-efd9-b140-b9acc02807dc.png)
##### １．SlackのIncoming WebHook URLとSlackのメンバーIDが必要になりますので、以下のページを参考にして取得しておいてください。

SlackのWebhook URL取得手順
https://qiita.com/vmmhypervisor/items/18c99624a84df8b31008

SlackのメンバーID取得手順
https://help.receptionist.jp/?p=1100#memberid

##### ２．次に、Redmineの「管理 » カスタムフィールド » 新しいカスタムフィールド » ユーザー 」で、「SlackメンバーID」の入力項目を作成します。
![カスタムフィールド作成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/761560/bf06b846-1f06-a903-44f4-b13155f38c7e.png)
上記１．で取得したSlackのメンバーIDをRedmineの個人設定で登録してください。
![個人設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/761560/df35c5f1-a811-3c12-2c3c-1f5621ca2291.png)
##### ３．View Customize Pluginでのコーディング
ユーザー情報をまとめて取得するために、Redmine Shared APIプラグインを使用します。

Redmine Shared API
https://www.redmine.org/plugins/redmine_shared_api

取得したユーザ情報を、メンション名として注記のテキストエリアに入力補完するための処理を追加します。「＠」の入力に対してメニューを表示し、選択したユーザーをメンションする。

テキストエリアで入力補完 (Redmine View Customize Plugin)
https://blog.enjoyxstudy.com/entry/2019/03/22/000000

```html
<!--
パスのパターン:/issues/[0-9]+
挿入位置:全ページのヘッダ
種別:HTML
コメント:Slackで注記を共有(メンション名を入力自動補完)
-->
<style>
    /*メンション名自動補完用*/
    .atwho-view {
        position: absolute;
        top: 0;
        left: 0;
        display: none;
        margin-top: 18px;
        background: white;
        color: black;
        border: 1px solid #DDD;
        border-radius: 3px;
        box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
        min-width: 120px;
        z-index: 11110 !important;
    }

    .atwho-view .atwho-header {
        padding: 5px;
        margin: 5px;
        cursor: pointer;
        border-bottom: solid 1px #eaeff1;
        color: #6f8092;
        font-size: 11px;
        font-weight: bold;
    }

    .atwho-view .atwho-header .small {
        color: #6f8092;
        float: right;
        padding-top: 2px;
        margin-right: -5px;
        font-size: 12px;
        font-weight: normal;
    }

    .atwho-view .atwho-header:hover {
        cursor: default;
    }

    .atwho-view .cur {
        background: #3366FF;
        color: white;
    }

    .atwho-view .cur small {
        color: white;
    }

    .atwho-view strong {
        color: #3366FF;
    }

    .atwho-view .cur strong {
        color: white;
        font: bold;
    }

    .atwho-view ul {
        /* width: 100px; */
        list-style: none;
        padding: 0;
        margin: auto;
        max-height: 200px;
        overflow-y: auto;
    }

    .atwho-view ul li {
        display: block;
        padding: 5px 10px;
        border-bottom: 1px solid #DDD;
        cursor: pointer;
        /* border-top: 1px solid #C8C8C8; */
    }

    .atwho-view small {
        font-size: smaller;
        color: #777;
        font-weight: normal;
    }
</style>

<script src="https://cdnjs.cloudflare.com/ajax/libs/at.js/1.5.2/js/jquery.atwho.min.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Caret.js/0.3.1/jquery.caret.min.js" defer></script>

<script>
$(function() {
    const apiKey = ViewCustomize.context.user.apiKey;
    const slacknames = []; //取得したユーザー・SlackメンバーID情報を格納しておく

    //チケット詳細画面表示時にユーザーリストを準備
    getslackuserList().done(function(results) {
        results.forEach(function(result) {
           const username = result.firstname + '_' + result.lastname;
           //カスタムフィールドの番号は環境に合わせてください
           const memberid = result.custom_fields[7].value; 
           if (memberid !== '') {
               slacknames.push({
                   name: username,
                   id: memberid
               });
           }
        })

        //新規の注記コメントでメンション名を自動補完 
        autocompletesuggestion();

        //履歴の注記コメントでメンション名を自動補完
        $(document).on('click', 'div[id^="journal-"] .contextual .icon-edit', function() {
            $(document).ajaxComplete(function() {
                autocompletesuggestion();
            });
        });
    })

    //ユーザー・SlackメンバーID情報取得(Redmine Shared APIプラグイン)
    function getslackuserList() {
        const deferred = new $.Deferred();
        $.ajax({
            type: "GET",
            url: '/shared/users.json?limit=1000',
            headers: {
                'X-Redmine-API-Key': apiKey
            },
            dataType: "text",
            contentType: 'application/json',
        }).done(function(data) {
            data = JSON.parse(data);
            data = data["users"];
            deferred.resolve(data);
        });
        return deferred;
    }

    //メンション入力自動補完
    function autocompletesuggestion() {
        $('textarea.wiki-edit').atwho({
            at: '@',
            data: slacknames,
            limit: 5,
            insertTpl: '@' + '${name}',
            suffix: ''
        });
    }
})
</script>
```

「Slackで共有」ボタンを画面に追加します。アイコン画像はフリー素材からダウンロードして、Redmineサーバの「../images/jstoolbar/」 などに配置してください。

```html
<style>
    /*Slack共有ボタン用*/
    .slack {
        background-image: url(/images/jstoolbar/slack_btn.png);
    }

    .slack {
        margin-right: 4px;
        padding: 4px;
        vertical-align: middle;
        width: 24px;
        height: 24px;
        border-style: none;
        background-color: #FFFFFF;
        background-position: 50% 50%;
        background-repeat: no-repeat;
    }
</style>

<script>
    //画面初期表示時
    addSlack_btn(); //Slack共有ボタンを表示

    //履歴を変更して保存した際にSlackボタンを再表示
    $(document).on('click', 'div[id*="note-"] input[type="submit"]', function() {
        $(this).parents('div[id*="note-"]').find('.slack').remove();
        $(document).ajaxComplete(function() {
            addSlack_btn();
        })
    })
    //コンテキストメニュー（履歴）にSlackで共有ボタンを追加
    function addSlack_btn() {
        var slack_btn = '<button type="button" title="' + "Slackで共有" + '" class="slack">';
        $('div[id^="journal-"] .contextual').each(function() {
            if ($(this).find('.slack').length === 0) {
                $(this).append(slack_btn);
            }
        })
   }
</script>
```

送信用のデータを準備して、「Slackで共有」ボタンクリック時に送信します。

SlackのIncoming Webhooksを使い倒す
https://qiita.com/ik-fib/items/b4a502d173a22b3947a0

```html
<script>
    //Slackにメッセージを送信
    $(document).on('click', '.slack', function() {
        //Slackのチャンネル名とRedmineのプロジェクト識別子を同じに設定してください
        const channel = ViewCustomize.context.project.identifier;
        const issueIdno = location.pathname.split('/').pop();
        const issueIdtxt = ' #' + issueIdno + ': ';
        const subject = $('#issue_subject').val();
        const noteId = '#' + $(this).parents('div[id^="note-"]').attr('id');
        const linkUrl = location.protocol + "//" + location.hostname + "/issues/" + issueIdno + noteId;
        const journalId = $(this).parents('div[id^=journal-]').attr('id').split('-')[1];
        get_note(issueIdno).done(function(result) {
            const trackername = result.issue.tracker.name;
            const projectName = '[' + result.issue.project.name + '] ';
            const journals = result.issue.journals;
            journals.forEach(function(journal) {
                if (journal.id !== Number(journalId)) {
                    return;
                }
                const notes = journal.notes;
                const regex = new RegExp('(?<=^|\\s)(@\\w+)', 'g');
                const mentions = notes.match(regex);
                let mentionList = '';
                if (mentions !== null) {
                    for (let i in mentions) {
                        const mentionId = convertSlackid(mentions[i]);
                        if (mentionId !== undefined) {
                            mentionList += ' <' + mentionId + '>';
                        }
                    }
                }
                const authorName = journal.user.name;
                const title = trackername + issueIdtxt + subject;
                const message = projectName + authorName + ' updated' + ' <' + linkUrl + '|' + title + '>' + mentionList;
                const pretext = "*内容を確認してください。*";
                const data = {
                    "channel": "#" + channel,
                    "text": message,
                    "attachments": [{
                        "pretext": pretext,
                        "color": "#00FF00",
                        "text": notes
                    }]
                }
                //Slack通信用のコントローラ呼び出し 
                $.get('/shared/slack', {
                    data: data
                }).done(function(resp) {
                    console.log("メッセージを送信しました");
                }).fail(function(error) {
                    console.log("メッセージを送信できませんでした");
                })
            })
        })
    })

    //チケット・注記情報を取得
    function get_note(issueIdno) {
        const deferred = new $.Deferred();
        $.ajax({
            type: "GET",
            url: '/issues/' + issueIdno + '.json?include=journals',
            headers: {
                'X-Redmine-API-Key': apiKey
            },
            dataType: "text",
            contentType: 'application/json',
        }).done(function(data) {
            data = JSON.parse(data);
            deferred.resolve(data);
        });
        return deferred;
    }

    //メンション名をSlackメンバーIDに変換
    function convertSlackid(mentionName) {
        for (let j in slacknames) {
            const slackName = '@' + slacknames[j].name;
            if (mentionName === slackName) {
               return '@' + slacknames[j].id;
            }
        }
    }        
</script>
````
＊コードを分けて記述していますが、$(function(){}); 内にまとめてください。

##### ４．Rails（サーバー側）で必要なコード

jQueryでAjax通信 × Railsでサーバー処理
https://qiita.com/beanbeenzou/items/54f1a6f0170ab48a0999

コントローラファイルの配置は、上記３．でインストールしたRedmine Shared APIプラグインのフォルダを共有することにします。

SlackへのPOST先は、上記１．で取得したWebhook URLを環境変数 .env に格納するか、 コードに直打ちしてください。

```slack_api_controller.rb
# ../redmine/plugins/redmine_shared_api/app/controllers/slack_api_controller.rb

class SlackApiController < ApplicationController
  require 'net/http'
  require 'uri'
  require 'json'

  before_action :require_login
  accept_api_auth :slack

  def slack
    data = params[:data]    
    uri = URI.parse(ENV['SLACK_URL']) #.envに格納した場合
    # Webhook URLを直打ちの場合: uri = URI.parse('https://hooks.slack.com/services/...')
    res = Net::HTTP.post_form(uri, {"payload" => data.to_json})
    @slack = res.body
    render plain: @slack.to_json
  end

end
```
ルーティングは、View Customize Pluginのコードに記述したリクエストの送信先（/shared/slack）を設定します。Redmine Shared APIプラグインのroutesファイル（../redmine/plugins/redmine_shared_api/config/routes.rb）の最後に追記してください。

```routes.rb
    match 'shared/slack', :controller => 'slack_api', :action => 'slack', :via => [:get]
```

＊最後に、設定を有効にするためRedmineを再起動してください。

以上で、Redmineからメンション付きでSlackにメッセージを送信することができるようになったと思います。