外からイントラ内のWEBダッシュボードを確認する(Slack/Botkit/Puppeteer)

## やりたいこと

社内からしかアクセスできない＝イントラネット内にあるWEBシステムのダッシュボード情報を、社外からでも確認できるようにします。

※Slack Botを経由しているだけなので、技術的な目新しさというよりも活用アイデアみたいな内容です。

![フロー図.png](https://qiita-image-store.s3.amazonaws.com/0/148986/d57c61b3-57c7-60f3-9104-dc97c2688185.png)


## 実践
### 使うもの・環境

- Slack
- Node.js　※2018年12月31日時点で動作確認させたバージョンは`v10.8.0`
    - Botkit
    - Puppeteer

※動作確認用のWEB参照先として使用したのは、KibanaとElasticsearchです。(いずれも7.0.0-alpha1)


### ソースコード

実際に使用した package.json と index.js は下記の通りです。

```json:package.json
{
  "name": "gazou_bot",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "run": "node index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "botkit": "^0.7.0",
    "puppeteer": "^1.11.0"
  }
}
```

```javascript:index.js
const botkit = require('botkit');
const fs = require('fs');
const puppeteer = require('puppeteer');

const SLACK_TOKEN = '＠＠＠＠＠ここにSlackから払い出したトークン＠＠＠＠＠';

// 今回はKibanaをサンプルにしています。
const KIBANA_ID = '＠＠＠＠＠ここにKibanaへのログインID＠＠＠＠＠';
const KIBANA_PASS = '＠＠＠＠＠ここにKibanaへのログインPASS＠＠＠＠＠';

const controller = botkit.slackbot({
    debug: true,
});

const bot = controller.spawn({
    token: SLACK_TOKEN,
    retry: Infinity
}).startRTM();


//  KibanaのスクリーンショットをSlack投稿する
function gazouKibana2Slack(screenName, screenUrl, gazouName, bot, message) {

    (async () => {
        const browser = await puppeteer.launch({ ignoreHTTPSErrors: true, headless: true });
        const page = await browser.newPage();
        await page.setViewport({ width: 1936, height: 1056 });  // 任意のサイズに

        // ログイン処理
        await page.goto('http://localhost:5601/', { waitUntil: 'networkidle0' }); //ログインURLを指定
        await page.type('input[name=username]', KIBANA_ID); // セレクタは各WEBサービスごとに
        await page.type('input[name=password]', KIBANA_PASS); // セレクタは各WEBサービスごとに
        await page.click('button[type="submit"]');
        await page.waitFor(1000);

        // スクリーンショットを画像保存
        await page.goto(screenUrl, { waitUntil: 'networkidle0' });
        await page.screenshot({ path: gazouName, fullPage: true });
        await browser.close();

        // 画像アップロード
        fs.readFile(gazouName, function (err, data) {
            if (err) throw err;
            bot.api.files.upload({
                file: fs.createReadStream(gazouName),
                filename: gazouName,
                channels: message.channel,
                initial_comment: `▼${screenName}<${screenUrl}|Kibanaへのリンク>`
            }, function (err, res) {
                if (err) console.log(err)
            })
        });
    })();
}

//  トリガーワードに反応したことをメッセージ投稿し、指定秒数経過した後にメッセージを削除する
function triggerReaction(screenName, seconds, bot, message) {

    bot.api.chat.postMessage({
        channel: message.channel,
        text: `${screenName} の画像を取得中です。UPまで${seconds}秒の見込みです。`,
        as_user: true
    }, function (err, res) {
        if (err) {
            bot.botkit.log('Failed to postMessage', err);
        }
        setTimeout(() => {
            bot.api.chat.delete({
                channel: message.channel,
                ts: res.ts
            }, function (err, res) {
                if (err) {
                    bot.botkit.log('Failed to delete', err);
                }
            });
        }, seconds * 1000);
    });
}

// ----- 以下はトリガーの設定 -----

// 呼び出しパターン１
controller.hears(['がぞううぇぶ'], 'direct_message,direct_mention,mention,ambient', function (bot, message) {
    let screenName = '[Logs] Web Traffic';
    let screenUrl = 'http://localhost:5601/goto/f4512f3840a91a84c3786b064190ab26';
    let gazouName = 'Web_Traffic_LAST_7days.png';
    let seconds = 15;

    triggerReaction(screenName, seconds, bot, message);
    gazouKibana2Slack(screenName, screenUrl, gazouName, bot, message);
});

// 呼び出しパターン２
controller.hears(['がぞうこまーす'], 'direct_message,direct_mention,mention,ambient', function (bot, message) {
    let screenName = '[eCommerce] Revenue Dashboard';
    let screenUrl = 'http://localhost:5601/goto/64bf26e764a79434476f72d31837f520';
    let gazouName = 'Revenue_Dashboard_LAST_7days.png';
    let seconds = 15;

    triggerReaction(screenName, seconds, bot, message);
    gazouKibana2Slack(screenName, screenUrl, gazouName, bot, message);
});

// 呼び出しパターン３
controller.hears(['がぞうふらいと'], 'direct_message,direct_mention,mention,ambient', function (bot, message) {
    let screenName = '[Flights] Global Flight Dashboard';
    let screenUrl = 'http://localhost:5601/goto/293e9f1106be535c802c504502e728ce';
    let gazouName = 'Global_Flight_Dashboard_LAST_7days.png';
    let seconds = 20;

    triggerReaction(screenName, seconds, bot, message);
    gazouKibana2Slack(screenName, screenUrl, gazouName, bot, message);
});

// トリガーワードの一覧ヘルプ
controller.hears(['がぞうへるぷ'], 'direct_message,direct_mention,mention,ambient', function (bot, message) {
    bot.reply(message, `▼トリガーワード一覧
    がぞううぇぶ：Kibana-[Logs] Web Traffic
    がぞうこまーす：Kibana-[eCommerce] Revenue Dashboard
    がぞうふらいと：Kibana-[Flights] Global Flight Dashboard`);
});
```

※SLACK_TOKENの箇所は、ご自身のSlackワークスペースから払い出したトークンを設定してください(xoxb-〜〜〜のトークンです)

※実際に本格利用する際は、トークンやIDやパスワードなどは環境変数に設定して利用することを推奨します。


### 実行結果

![実行結果.gif](https://qiita-image-store.s3.amazonaws.com/0/148986/d63bca92-a4c1-354d-1747-1444479190b0.gif)

※PCで録画しておりますが、モバイルからの利用も想定しております。


## やってみて気付いたこと

### 画像がアップロードされるまでに時間がかかる
ネットワーク状況や画像のファイルサイズによりますが、トリガーワードを投稿してから画像がアップロードされるまでは時間がかかります。

しばらく反応がないと、ユーザーはトリガーワードを間違えたのかな？とか、Botが落ちているのかな？などと不安になったり、二重投稿してしまうことがあったので、`●●●の画像を取得中です。UPまで◆◆秒の見込みです。` というメッセージをすぐに返すようにしました。

暫定的なお知らせメッセージなので、一定秒数経過した後にメッセージを削除するようにしています。


### トリガーワードは入力しやすく覚えやすく

トリガーワードは全部ひらがなにしています。
英字や漢字やカナ文字などは含めないようにすることで、入力モードの切り替えや漢字変換が不要になるので、モバイルからでも入力しやすいように配慮しました。

また、トリガーワードのパターンは、`共通ワード＋識別ワード`に統一しました。
そうすることで、入力する側もトリガーワードを覚えやすくなるように配慮しています。（例：がぞう〇〇〇、〇〇〇なう、などなど）

`@ボット名 トリガーワード`と入力するのは時間がかかるので、メンションがなくても反応するように、 `ambient` を指定しています。ただ、前述の2つの対策が結果的に、メンションなしの日常メッセージでも誤反応しにくいようになりました。

トリガーワードは覚えられなくても「がぞうへるぷ」とか「へるぷなう」をトリガーにして、トリガーワード一覧を返してくれるようにしておくと、へるぷだけ覚えていてもらえればよいので安心感が増します。

<img width="497" alt="スクリーンショット 2018-12-31 16.36.58.png" src="https://qiita-image-store.s3.amazonaws.com/0/148986/35d26c63-9a90-f0a9-7edf-32a002fc2dee.png">


### リンク付きで最新情報にもアクセスしやすく

イントラネット内で閲覧していた場合に、すぐに最新情報にアクセスできるように、該当ダッシュボードへのURL直リンクをメッセージに含めています。

<img width="487" alt="スクリーンショット_2018-12-31_16_22_43.png" src="https://qiita-image-store.s3.amazonaws.com/0/148986/01e58c4c-d0d2-406c-c711-f75f9c11081b.png">


## 参考情報

- [Botkit](https://github.com/howdyai/botkit)
- [Puppeteer](https://github.com/GoogleChrome/puppeteer)

## あとがき

元々はダッシュボードをよく参照する人が、「アクセス可能な環境」という制約を超えて、出先でも確認しやすいようにするために導入しました。（例：外出中に高レベルのアラート通知を受けた時など）

ところが、実際に始めてみると、これまで積極的にダッシュボードにアクセスしていなかった or ダッシュボードの存在を知らなかったユーザーにも利用されていることが分かりました。

「アクセス可能な環境」という制約以外にも、「URL」「ID&PASS」「画面内の操作」といった情報や意識をスキップして、ただ**トリガーワードを入力すれば、結果が見られるという簡単さ**が良かったようです。

利用されるたびにパブリックなチャンネルで他の人の目にも触れるので、それがBotだけでなく**ダッシュボードそのものの認知度向上や内容の見直し検討につながっていた**ことが、一番大きな収穫でした。

気軽に見たいけど、パブリックチャンネルではトリガーワードを入力しづらい状況にも備えて、Botへのダイレクトメッセージでも反応するように対応しています。

現場からは以上です。