眠い人を吸い込むブラックホールチャンネルを作った話

[富士通システムズウェブテクノロジー Advent Calendar 2019](https://qiita.com/advent-calendar/2019/fsweb) 13日目の記事です。
実は、私はパートナー会社の者です。

書かせていただけるなんて、懐が深くて感謝の限りでございます！
本当にありがとうございます！！！ :bow_tone1: 

タイトルの通り**ネタ記事**です。
正直こんな記事でごめんなさい。
（先に謝っておくスタイル）

ただ、前日の投稿
「[Google Cloud Vision APIを使って食べ物の写真を判定してみる \- Qiita](https://qiita.com/m-yuumin/items/a679ea45053ba6395b38)」
が良記事過ぎて、書くのがつらいっす。。。

とりあえず全力で頑張りますのでよろしくお願いいたします！

そしてお約束です。
**お断り）記事は全て個人の見解です。会社・組織を代表するものではありません。**

# はじめに

当記事では、チャットをより楽しくするためのお遊び機能を作ったので紹介しています。
真面目に読むよりは息抜き程度にお読みくださると幸いです。

# 私の職場環境

私が働いている場所はチャットが大変普及しています。
素晴らしいことに心理的安全性の高いチャットが実現されており、本音のトークがバンバンやり取りされています。
ともすると、ポジティブな内容ばかりではなく、ネガティブな発言もあったりする訳です。

私自身、仕事がしんどい時は「眠い」「帰りたい」などの発言をしてしまうこともあります。
そんなとき、ふと思うのです。

つらいのは私だけなのだろうか…と

いやいや、そんなはずはない。
きっと同じように苦しんで頑張っている人もいるはず！
皆で分かち合えば、辛さも軽減されて頑張れるはず！

ならば作りましょう！同士を集める**ブラックホール**を！

# ブラックホールを支えるツール達

- [Mattermost](https://mattermost.org/) v5.13.2

> MattermostはOSSのチャットツールです。
> Slackライクな使い心地でMarkdown形式が使えるのが強いところ。
> 個人的には、Slackより好きです。
> 詳しくはググってね :heart: 

- [Node\-RED](https://nodered.org/) v0.18.7

> Node-REDはブラウザ上でGUIベースでロジックをゴリゴリ書ける面白ツールです。
> `ノード`という形で様々な機能を持ったブロックをつなげることで、簡単にロジックが組めます。
> APIとかも簡単に作れたりするので、Bot開発には最適です。
> 詳しくは(ry 


# ブラックホールの仕組み

「Mattermostのパブリックチャンネルで特定ワードを投稿する」（「眠い」「NMI」など）
<img width="600" alt="仕組み-1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/7c90ab4a-3881-09be-05b6-28a4b502f422.png">

「ワードを検知したらNode-REDのロジック発火」
<img width="600" alt="仕組み-2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/964e7aae-6071-a737-1c05-7dba1026b8cf.png">

「Node-REDのロジックで、**強制的にブラックホールチャンネルに招待**する」
<img width="600" alt="仕組み-3.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/9d82cf0d-c66b-ccc2-30c8-632339cfd9b8.png">

補足：
ブラックホールチャンネルは予め用意しておくこと
なるべく、プライベートチャンネルにしましょう
（仕事中に眠い人が集まるので、公開処刑になる可能性が0ではない）
引き込んだら歓迎しましょう

# 実際の様子

「夕方はNMI」
<img width="600" alt="夕方はNMI" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/872646ed-6d9d-4509-a128-f813dd59776f.png">

「昼もNMI」
<img width="600" alt="昼もNMI" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/aa4321c9-6087-85d1-35bd-d260f7f306bd.png">

「仲間をふやそう」
<img width="600" alt="仲間をふやそう" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/3d253f4f-6460-db97-9035-318e8d3f25d6.png">

こんな感じで大盛況です。眠そうなターゲットを引き込めたときの嬉しさはすごいです。

# ブラックホールの作り方

ここからは真面目に技術のお話です。

## 事前準備

- Mattermostの管理者権限を取得
- ブラックホールチャンネルを作成
- Node-RED（Dockerなどを利用すればサクッと建てられます）

## Mattemostで眠いを検知する機能を作る

眠いを検知する機能は、Mattermost統合機能の一つ**外向きのウェブフック**で実現しています。

Mattermostのメニューから、統合機能を作成します
<img width="200" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/f1d99c58-baa3-c2f4-eb91-998be516d535.png">
（ここに表示されていない場合、管理者が禁止している可能性があります。その場合は管理者にネゴって機能を開放してもらう必要があります）

外向きのウェブフックを作成します
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/db0b5a7a-a810-2019-7243-760d33213d07.png">
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/15de754b-7344-17da-118e-6a10bdd65a0c.png">

任意の名前、説明を記載します
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/4800e359-3f10-c1e4-65c4-4519734dfa14.png">

チャンネルを**選択しない**
（選択しないことで、全てのパブリックチャンネルが対象になります）
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/8da4e73b-0f01-4a2e-d5d5-4c5b98572d52.png">

補足するために思いつく限りの眠そうなワードを登録します
（腕の見せ所です）
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/63eac4d4-166a-423f-e0c2-cc6ccb0b48ca.png">

投稿の文頭にこのワードがついていれば補足できるようにします
（正確に一致するにすると、ワードの後ろがスペースでないと反応しません）
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/760d8571-5cac-7d0e-bcd1-eccfd5aa9001.png">

Node-REDのロジックを叩く部分です。
Node-REDのホスト名＋`/api/v1/nmi`という形で登録しておきます。
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/d2c02f7b-475e-de26-3396-e0f2d756ae96.png">

ここまでできたら外向きのウェブフック保存しましょう。

## Mattemostで通知を受ける口を作る

今度はNode-REDからの通知を受け取る場所を、Mattermost統合機能の一つ**内向きのウェブフック**で実現します。

先程と同じ様に統合機能メニューを開き、今度は内向きのウェブフックを選択します
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/a0c86ada-5343-0fbf-3967-a9d05abdebae.png">
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/5b61922d-6ac6-aae1-3d44-1d69671660e8.png">

あとは、設定（名前、説明、デフォルト投稿チャンネル）を入力し、保存します。
（`このチャンネルに固定する`のチェックを外しておけば、汎用的に使えます）
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/ac9c6b58-caac-a017-abd3-fcab6290bfcb.png">

これで通知を受け取る口ができました。
Mattemostの設定はこれで以上です。

## Node-REDで吸い込む機能を作る

全文書くと超大作になってしまうので、ある程度割愛します。。。っ！
（ロジックは後ほど全文載せます）

全体像を元に解説していきます。

「全体像」
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/1722187b-2c04-326c-4f33-fab93a167d65.png">

MattermostからのWebhookを受け取るためにAPIを作成します。
`Node-REDのホスト名/api/v1/nmi`という名前でAPIを定義しています。


「眠い人がすでに眠いチャンネルに存在しているか判定する」
<img width="600" alt="Node-RED全体像_判定.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/f5ecc0f6-6518-1f58-934e-21c81aebc8ea.png">

`存在確認`のノード（Node-REDはノードをつないでロジックを作る）でMattermostにAPIを投げています。
APIを実行するためにはアクセストークンが必要になるので、`認証情報格納`のノードでアクセストークンを詰めています。

「存在している場合は、通知するだけ」
<img width="600" alt="Node-RED全体像_存在有.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/b06c23ad-d562-9ae6-d486-8c32c62f62aa.png">

`テキストセット`のノードで「誰」が「どこ」で「なんと言っているか」を整理して、`http request`のノードでMattermostの内向きWebhookを叩いて、Mattermostに投稿します。

「存在していない場合は、招待してから通知する」
<img width="600" alt="Node-RED全体像_存在無.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/b637b6a8-eb07-9f45-b4ba-73f425b99c21.png">

`招待パラメータ設定`のノードでMattermost用のAPIのパラメータを詰めて、招待を実施しています。
招待後、「誰」が「どこ」で「なんと言っているか」を整理して、Mattermostに投稿します。

これで準備は完了です。

## Node-REDロジック全文

上の図を見てもわからない箇所も多いと思うので、全文を載せておきます。

「ソースコードを読み込むませることもできます」
<img width="200" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/7d01f7ec-1e4d-1fe1-501f-bf2d323dd642.png">
「図のところに貼り付ける」
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/8ce871ad-a194-9f9e-14b3-089b60b6c566.png">


<details>
<summary>Node-REDロジック全文</summary>
<div>

```json
[
    {
        "id": "6136913a.b7dd8",
        "type": "subflow",
        "name": "認証情報格納",
        "info": "またもすのアクセストークンを与えます",
        "in": [
            {
                "x": 60,
                "y": 60,
                "wires": [
                    {
                        "id": "82b68cf6.84efd"
                    }
                ]
            }
        ],
        "out": [
            {
                "x": 500,
                "y": 60,
                "wires": [
                    {
                        "id": "82b68cf6.84efd",
                        "port": 0
                    }
                ]
            }
        ]
    },
    {
        "id": "82b68cf6.84efd",
        "type": "function",
        "z": "6136913a.b7dd8",
        "name": "認証情報格納",
        "func": "\nmsg.headers = {};\n\nmsg.headers['Authorization'] = 'Bearer {Mattermostのアクセストークン}';\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 280,
        "y": 60,
        "wires": [
            []
        ]
    },
    {
        "id": "44865478.e5343c",
        "type": "debug",
        "z": "25e49fa5.4ce1e",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "x": 1290,
        "y": 120,
        "wires": []
    },
    {
        "id": "7b3d351e.b0830c",
        "type": "http request",
        "z": "25e49fa5.4ce1e",
        "name": "招待",
        "method": "POST",
        "ret": "txt",
        "url": "",
        "tls": "",
        "x": 1250,
        "y": 340,
        "wires": [
            [
                "d50c7022.02391",
                "4749d54e.c25dac"
            ]
        ]
    },
    {
        "id": "958cbda1.fbce2",
        "type": "http request",
        "z": "25e49fa5.4ce1e",
        "name": "存在確認",
        "method": "GET",
        "ret": "obj",
        "url": "",
        "tls": "",
        "x": 1000,
        "y": 120,
        "wires": [
            [
                "a9e034e9.115308"
            ]
        ]
    },
    {
        "id": "85eae35e.3f8b9",
        "type": "subflow:6136913a.b7dd8",
        "z": "25e49fa5.4ce1e",
        "name": "",
        "x": 820,
        "y": 120,
        "wires": [
            [
                "958cbda1.fbce2"
            ]
        ]
    },
    {
        "id": "a9e034e9.115308",
        "type": "function",
        "z": "25e49fa5.4ce1e",
        "name": "存在判定",
        "func": "\nmsg.exist = false;\nfor (var key in msg.payload) {\n    var user = msg.payload[key];\n    if (user.user_id === msg.prePayload.user_id) {\n        msg.exist = true;\n    }\n}\n\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 1160,
        "y": 120,
        "wires": [
            [
                "30759ba3.5bdb24",
                "44865478.e5343c"
            ]
        ]
    },
    {
        "id": "30759ba3.5bdb24",
        "type": "switch",
        "z": "25e49fa5.4ce1e",
        "name": "存在判定",
        "property": "exist",
        "propertyType": "msg",
        "rules": [
            {
                "t": "true"
            },
            {
                "t": "false"
            }
        ],
        "checkall": "true",
        "repair": false,
        "outputs": 2,
        "x": 680,
        "y": 300,
        "wires": [
            [
                "76525710.751268"
            ],
            [
                "ff82c853.54b4c8"
            ]
        ]
    },
    {
        "id": "ff82c853.54b4c8",
        "type": "function",
        "z": "25e49fa5.4ce1e",
        "name": "招待パラメータ設定",
        "func": "msg.payload = {\n    user_id: msg.prePayload.user_id\n}\nmsg.url = \"http://{Mattermost_Host}/api/v4/channels/\" + msg.target_channel + \"/members\";\n\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 900,
        "y": 340,
        "wires": [
            [
                "33f97f1f.f2fc8"
            ]
        ]
    },
    {
        "id": "33f97f1f.f2fc8",
        "type": "subflow:6136913a.b7dd8",
        "z": "25e49fa5.4ce1e",
        "name": "",
        "x": 1100,
        "y": 340,
        "wires": [
            [
                "7b3d351e.b0830c"
            ]
        ]
    },
    {
        "id": "d50c7022.02391",
        "type": "debug",
        "z": "25e49fa5.4ce1e",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "x": 1410,
        "y": 340,
        "wires": []
    },
    {
        "id": "4749d54e.c25dac",
        "type": "function",
        "z": "25e49fa5.4ce1e",
        "name": "またもす返却設定",
        "func": "msg.payload = {};\nmsg.payload.response_type = \"in_channel\";\nmsg.url = \"{Mattermost_Webhook}\";\n\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 870,
        "y": 440,
        "wires": [
            [
                "c1514d40.3717d"
            ]
        ]
    },
    {
        "id": "c1514d40.3717d",
        "type": "function",
        "z": "25e49fa5.4ce1e",
        "name": "テキストセット",
        "func": "msg.payload.text = \"@\" + msg.prePayload.user_name + \"が ~\" + msg.prePayload.channel_name + \"で「\" + msg.prePayload.text + \"」と叫んだため、引き込まれました。\";\nmsg.payload.username = msg.target_icon_name;\nmsg.payload.icon_url = msg.target_icon_url;\n\nmsg.url = msg.webhook;\n\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 1060,
        "y": 440,
        "wires": [
            [
                "e18e06cf.4c2cf8"
            ]
        ]
    },
    {
        "id": "e18e06cf.4c2cf8",
        "type": "http request",
        "z": "25e49fa5.4ce1e",
        "name": "",
        "method": "POST",
        "ret": "txt",
        "url": "",
        "tls": "",
        "x": 1230,
        "y": 440,
        "wires": [
            [
                "f71a1baf.9365f8"
            ]
        ]
    },
    {
        "id": "f71a1baf.9365f8",
        "type": "debug",
        "z": "25e49fa5.4ce1e",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "x": 1370,
        "y": 440,
        "wires": []
    },
    {
        "id": "76525710.751268",
        "type": "function",
        "z": "25e49fa5.4ce1e",
        "name": "またもす返却設定",
        "func": "msg.payload = {};\nmsg.payload.response_type = \"in_channel\";\nmsg.url = \"{Mattermost_Webhook}\";\n\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 890,
        "y": 280,
        "wires": [
            [
                "8a3f0824.242e08"
            ]
        ]
    },
    {
        "id": "8a3f0824.242e08",
        "type": "function",
        "z": "25e49fa5.4ce1e",
        "name": "テキストセット",
        "func": "msg.payload.text = \"@\" + msg.prePayload.user_name + \"が ~\" + msg.prePayload.channel_name + \"で「\" + msg.prePayload.text + \"」と叫んでいます。\";\nmsg.payload.username = msg.target_icon_name;\nmsg.payload.icon_url = msg.target_icon_url;\n\nmsg.url = msg.webhook;\n\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 1100,
        "y": 280,
        "wires": [
            [
                "9dde8ac3.c97968"
            ]
        ]
    },
    {
        "id": "9dde8ac3.c97968",
        "type": "http request",
        "z": "25e49fa5.4ce1e",
        "name": "",
        "method": "POST",
        "ret": "txt",
        "url": "",
        "tls": "",
        "x": 1270,
        "y": 280,
        "wires": [
            [
                "760308c4.4f00d8"
            ]
        ]
    },
    {
        "id": "760308c4.4f00d8",
        "type": "debug",
        "z": "25e49fa5.4ce1e",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "x": 1410,
        "y": 280,
        "wires": []
    },
    {
        "id": "bf6d4801.a6ac88",
        "type": "function",
        "z": "25e49fa5.4ce1e",
        "name": "テキストセット",
        "func": "msg.url = \"http://{Mattermost_Host}/api/v4/channels/\" + msg.target_channel + \"/members\";\n\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 600,
        "y": 120,
        "wires": [
            [
                "85eae35e.3f8b9"
            ]
        ]
    },
    {
        "id": "a1552db2.f9daa",
        "type": "http in",
        "z": "25e49fa5.4ce1e",
        "name": "",
        "url": "/api/v1/nmi",
        "method": "post",
        "upload": false,
        "swaggerDoc": "",
        "x": 140,
        "y": 200,
        "wires": [
            [
                "c1e67f2.6805a8"
            ]
        ]
    },
    {
        "id": "c1e67f2.6805a8",
        "type": "change",
        "z": "25e49fa5.4ce1e",
        "name": "眠いパラメータ",
        "rules": [
            {
                "t": "set",
                "p": "prePayload",
                "pt": "msg",
                "to": "payload",
                "tot": "msg"
            },
            {
                "t": "set",
                "p": "target_channel",
                "pt": "msg",
                "to": "3p161y7tjfdh9yb4ckbg6zoe7c",
                "tot": "str"
            },
            {
                "t": "set",
                "p": "target_channel_name",
                "pt": "msg",
                "to": "pjct_nmi",
                "tot": "str"
            },
            {
                "t": "set",
                "p": "target_icon_url",
                "pt": "msg",
                "to": "/api/v4/emoji/8gt67j1p9pnq7xbk1gh6xuxtir/image",
                "tot": "str"
            },
            {
                "t": "set",
                "p": "target_icon_name",
                "pt": "msg",
                "to": "NMI調査団",
                "tot": "str"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 360,
        "y": 200,
        "wires": [
            [
                "bf6d4801.a6ac88"
            ]
        ]
    }
]
```
</div>
</details>

「手動で変更が必要な場所」
赤丸でくくった場所にURLなりアクセストークンなりを記載してくれれば動作する（はず）です。
<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/f114be39-4cdc-230f-3d59-ff44f685fffd.png">


# 最後に

当初の想定と違い、吸い込むことが目的になりつつあったりもしますが、業務中の息抜きとしては最高です。
ぜひ、あなたのチャット環境にもブラックホールを導入しましょう！

そうすれば眠いときでも、仲間と共に楽しく頑張って仕事が出来るはずです。
**Enjoy Working!!**

<img width="600" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286339/bf1e2018-3510-0d7b-8ead-095548fe2648.png">