LiBz Tech Blog

LiBの開発者ブログ

すぐできる!古今社内Bot活用事例大全【Slack編】

なぜ書くのか

f:id:kazunosuket:20190221145127p:plain:w300

飲みの場でどんなBotが社内で動いてるかって結構盛り上がるネタだったりしませんか? くだらなかわいいBotから実用的なBotまで、SlackBotからIoT的Botまで。(ところでBotとRobotの違いってなんでしょう?🤔)

先日も社外エンジニアの方とひと盛り上がりしたので、社内のBot活用事例をまとめることにしました。

この記事が酒の肴と成ることを切望してやみません。

活用事例

弊社ではコミュニケーションツールにSlackを採用しています。なので通知は基本Slackに飛ばす方針です。

Bot名 できること
おべんと屋さん お弁当屋さんの到着とメニューをおしらせ
カタカタさん マーケ・ISのリード獲得をおしらせ
締めましたdash フロア最終退出時の確認完了をおしらせ
kotaro 本番環境へのデプロイ
slack2sheet 本番エラー時にアクセスログや一次対応方針など提供
KINGOFTIME 出退勤管理と通知

おべんと屋さん

なぜやるのか

社会人のオアシスランチ時、外は寒いし社内で美味しいごはんが食べたい、そんな時ありますよね?そんな時は弁当将軍さんのオフィス向けランチ宅配サービス!将軍自ら届けてくれるので美味くないわけがない。

一方で弊社の受け取りフローがイケてなかった。毎日お昼ごろ弊社へ来て頂き、受付のReceptionistからSlack通知で到着をお知らせしていただく運用でしたが、こんな課題を勝手に感じてました。

🍱弁当将軍さんの課題

  • 日々のお名前入力が大変そう(ベントーショウグン)
  • 配達スタッフ変更時にReceptionistの使い方引き継ぐの大変そう

👩‍💻弊社社員の課題

  • 将軍さまからの通知を総務社員が一次請けし全社員へメンションするフローだったので大変そう
  • そっとおかえりになるので買いに降りたらすでにいらっしゃらない事件が多発
  • 買いに降りたのに「いつもの唐揚げ弁当今日なかった…」と悲しそうな瞳で戻ってくる唐揚げハンター

担当が変わるたびにReceptionistでちょっと困惑される将軍様と念の為と将軍様の凱旋を健気にオフィスで待つお外ランチしたい総務社員唐揚げ好き社員を救うべくBot活用。

どうなったか

来社された将軍さまにAmazonDashボタンを押していただくと当日のメニューと共に凱旋を告げてくれるようになりました。おかえりの際にもう一度Dashボタンを押すとご出陣を通知します。サーバーは会社の遊休PCを使いました。

ボタンを押すだけなのでお名前入力も引き継ぎも簡単になり、お知らせ待機も不要唐揚げ有無のチェックもその場でできるようになりました。

f:id:kazunosuket:20190222141604p:plain:w500

どうやったか

基本的な動きはこのようになっています。

AmazonDashポチ → dasherでブロードキャストをキャッチ → 弁当将軍メニューをスクレイピングでスクショ → Slackにポスト

実際に使ってるAmazonDashボタン↓

f:id:kazunosuket:20190222142249j:plain:w500

サーバーはdasherを採用していてconfigで弁当将軍の状態管理シェルを叩いてます。

  {
    "name": "obento",
    "address": "xx:xx:xx:xx:xx:xx",
    "protocol": "udp",
    "cmd": "/hoge/huga/dasher/obento.sh"
  }

状態管理シェルでは下記を行ってます。

  • 弁当将軍さんが来社中かお帰りになったか(状態)管理
  • 来社された場合スクレイピングシェルを叩きメニューのスクリーンショットoshinagaki.pngを作成
  • 状態に応じてSlackへのポスト

できればボタンのクリックとダブルクリックで来社とおかえりを検知したいのですが、AmazonDashはクリックの1イベントしか発火できずダブルクリックなど他イベントを検知できません。金(かね)の弾丸を使える方はAWS IoT ボタン(ダブルクリックなど検知できる)を使うことをオススメします。

しかたないので今回は日付を名前に持つファイルを作成しファイルの有無を来社状態と紐づけて管理しています。デイリーイベントであれば簡単に制御できるのでよく使っています。

来社時はスクレイピングで保存したメニュー画像を https://slack.com/api/files.upload を叩いてSlackにポストします。

#!/bin/sh
if [ ! -e /hoge/huga/dasher/store/`date '+%Y%m%d'`.txt ]; then
  pushd `pwd`
  cd /hoge/huga/dasher
  /usr/local/bin/phantomjs oshinagaki.js
  sleep 5s
  curl -XPOST -H 'Content-Type:application/json' -d '{"text":"<!here> ちわー三河屋でーす:motor_scooter: お弁当制度存続の危機!必ず当日中に投稿してね:https://xxxxx"}' https://hooks.slack.com/services/[WEBHOOKS]
  sleep 3s
  curl -F file=@oshinagaki.png -F channels=#general -F token=[TOKEN] https://slack.com/api/files.upload
  popd
  touch /hoge/huga/dasher/store/`date '+%Y%m%d'`.txt
else
  curl -XPOST -H 'Content-Type:application/json' -d '{"text":"<!here> まいどありー:motor_scooter: お弁当制度存続の危機!必ず当日中に投稿してね:https://xxxxx"}' https://hooks.slack.com/services/[WEBHOOKS]
  rm /hoge/huga/dasher/store/`date '+%Y%m%d'`.txt
fi

スクレイピング側ではPhantomJSにてスクショを取りに行ってます。

var page = require('webpage').create();

page.open('http://bento-shogun.jp/menu/today/', function(status) {
    console.log("Status: " + status);
    window.setTimeout(function() {
        if (status === 'success') {
            page.viewportSize = { width: 600, height: 600 }
            page.clipRect = { top: 230, left:0, width:600, height: 550 }
            page.render('oshinagaki.png');
            var title = page.evaluate(function() {
                var title = document.title;
                return title;
            });
        }
        phantom.exit();
    }, 4000);
});

カタカタさん

なぜやるのか

ご飯食べる時よく一緒になるメンバーっていませんか?(※ 今度はご飯の話ではない)

ある日忙しくランチ食べる時間がないとその社員が言っていて、大変なんだなあと思っていたのですが、どうやら資料お問い合わせが入るとWebサイトから資料請求頂いた企業様の情報を手動でフォーマット変換してSlack投稿しているではありませんか。しかもリアルタイムに

そりゃ大変ですよね。と言うことで自動化しました。 こういうエンジニア視点で見るとそれ自動化したらいいんじゃないっていう事例よくあるのでアンテナ高くしておきたい。

どうなったか

一定時間ごとにスクレイピングし、更新分データがあった場合は整形してSlackにポストします。完全なリアルタイム性は断念しました。

リードをミスなくそこそこリアルタイムに営業チームに提供できること、資料お問い合わせを気にせずランチに行ける幸せを提供できたことに大満足です。

f:id:kazunosuket:20190222015847p:plain:w500

どうやったか

一定時間ごとにスクレイピング実施してるのでcronで叩いています。

複数媒体から資料お問い合わせがあるとのことでスクレイピング処理とSlack投稿処理を疎結合にしています。代わりに間にキューを置いています。このおかげで横展開がずいぶん楽になりました。

f:id:kazunosuket:20190222122151p:plain:w500

スクレイピングはHeadless Chromeのラッパーであるpuppeteerにて実装しました。リードリストをDLし、前回DLしたリストと比較して新規リードがあった場合パースしてキュー(と言ってもただのtext)にスタックします。パース処理は紙面の都合で省いています。

const puppeteer = require('puppeteer');
const fs = require('fs');

const USERNAME = 'username';
const PASSWORD = 'password';
const URL      = 'https://xxxxx/sign_in';
const DLURL    = 'https://xxxxx/contacts/leads';
const FILENAME = '/hoge/huga/dasher/store/xxxxx.csv';
const OLDFILENAME = '/hoge/huga/dasher/store/xxxxx.old.csv';
const SENDLIST = '/hoge/huga/dasher/send_queue.txt';

(async () => {
    const browser = await puppeteer.launch();
    try {
        const page = await browser.newPage();

        // home access
        await page.goto(URL);
        await page.waitFor(2000)

        // login
        await page.type('#user_email', USERNAME);
        await page.type('#user_password', PASSWORD);
        await page.evaluate(({}) => {
              $('input[name="commit"]').click();
        },{});
        await page.waitFor(2000)

        // download list
        await page.goto(DLURL);
        await page.waitFor(2000)
        await page._client.send('Page.setDownloadBehavior', { behavior: 'allow', downloadPath: './store' });
        await page.click('button[class="download-button"]');
        await page.waitFor(2000)

        // get all stock
        rawCsv = fs.readFileSync(FILENAME, 'utf8');
        newAllLeadCount = Number(rawCsv.split('\n').length);

        // get before all stock
        oldCsv = fs.readFileSync(OLDFILENAME, 'utf8');
        oldAllLeadCount = Number(oldCsv.split('\n').length);

        // calc diff count to post
        var newCount = newAllLeadCount - oldAllLeadCount

        // queue lead
        if ( newCount > 0 ) {
            const scrapingData = await page.evaluate((data, count) => {
                const dataList = [];
                for (var i = 0; i < count; i++) {
                    // パース処理
                    dataList.push(user.join(','));
                    dataList.push('\n');
                }
                return dataList;
            }, rawCsv, newCount);
            // append new lead
            fs.appendFileSync(SENDLIST, scrapingData,(err) => {
                if (err) throw err;
            });
            // update accessed csv
            fs.writeFileSync(OLDFILENAME, rawCsv,(err) => {
                if (err) throw err;
            });
        }
        await browser.close();
    } catch (e) {
        // throw error
        fs.appendFileSync(SENDLIST, 'error\n',(err) => {
            console.log('scraping fail');
        });
        console.log('an expection on ', e);
    } finally {
        await browser.close();
    }
})();

溜まったキューを捌くワーカー側では、定期的にキューを見てスタックがあればslackにポストするようにしています。 エラー処理は紙面の都合上省いています。

#!/bin/sh
set -eu
#Incoming WebHooksのURL
WEBHOOKURL="https://hooks.slack.com/services/[WEBHOOKS]"
#メッセージを保存する一時ファイル
SENDLIST='/hoge/huga/dasher/send_queue.txt'
trap "
echo '' > ${SENDLIST}
" 0

BOTNAME=${BOTNAME:-"カタカタさん"}
FACEICON=${FACEICON:-":keyboard:"}
MESSAGE=${MESSAGE:-""}

cat /hoge/huga/dasher/send_list.txt | while read line
do
    if [ -z "$line" ]; then
        continue;
    fi
    if [ "$line" = 'error' ]; then
        // エラー処理
        // 内容としてはBot管理チャンネルにエラーメッセージを投げます
    fi
    CHANNEL=${CHANNEL:-"#channel"}
    #改行コードをslack用に変換
    WEBMESSAGE=`echo $line | tr ',' '\\' | sed 's/\\\\/\\\\n/g'`
    WEBMESSAGE=`echo "<!channel>\\\\n"`'```'`echo ${WEBMESSAGE}`'```'
    curl -s -S -X POST --data-urlencode "payload={\"channel\": \"${CHANNEL}\", \"username\": \"${BOTNAME}\", \"icon_emoji\": \"${FACEICON}\", \"text\": \"${MESSAGE}${WEBMESSAGE}\" }" ${WEBHOOKURL} >/dev/null
    sleep 5
done

締めましたdash

なぜやるのか

弊社では各フロアの最終退出者は「戸締まり、冷暖房オフ、消灯の確認をした後Slackに◯階しめました」と投稿するルールがあります。

これ大事なルールなのはよくわかっているのですが、この投稿結構面倒なんですよね。会社出てからでいいかと外出ると、寒くてお店着いてからでいいかとなって、飲み会の場に着いたら即乾杯ですぐさま記憶の彼方です。

きっと自分以外にも同じ課題を感じている人はいるに違いない!言えないだけだ!そう信じてトライしました。

どうなったか

なんということでしょう。既存フローの6ステップが1ステップになりました。83%の工数削減です。

▼ 今まで
1. iPhoneをポケットから取り出す
2. ロックを解除する
3. Slackを起動する
4. channelを開く
5. ◯階しめましたと書く
6. 投稿する

▼ これから
1. AmazonDashボタンを押す

f:id:kazunosuket:20190222024055p:plain:w500

どうやったか

dasherの一番簡単なチュートリアルもってきて文字とアイコン変えました(簡単!) すべてdasherにお任せすればできるので凄いサービスです。

  {
    "name": "3fKaerimasuDash",
    "address": "xx:xx:xx:xx:xx:xx",
    "protocol": "udp",
    "url": "https://hooks.slack.com/services/[WEBHOOKS]",
    "method": "POST",
    "json": true,
    "body": {"text": "3fしめてdashしました"}
  }

kotaro

なぜやるのか

基本的なコミュニケーションをSlackでやる以上、リリース時の話し合いからシームレスにDeployできるのがメリットです。エンジニア全員の目に留まる透明性の高さもよい。

実は自分で作ったわけではないのであまり書くことがないです笑。

どうなったか

呪文を唱えるとreleaseブランチ作成からmasterブランチへPR出すところまでやってくれます。 f:id:kazunosuket:20190222142832p:plain:w500

どうやったか

まーきっとHubotですよね。

slack2sheet

なぜやるのか

残念ながら本番エラーが発生してしまった時、その対応はできるだけ早く行いたいものですよね? しかし原因究明のための情報も、パラメータはあっちみて、アクセスログはこっちみて、のように分散していて時間がかかります。なんなら新人メンバーはなにをすればよいかもわからないことあると思います。場合によっては気づかないエンジニアも…。

それを解決してくれるのがこのやる気のない名前のBotです。

どうなったか

本番エラーをフックに5分間誰も反応しなかった場合いろいろ情報提供してくれます。

  • 基本的なエラーの見方
  • アクセスログのクエリー
  • 基準に基づいたエラー原因の提案

チームのエラー対応レベルが一段階上がったGJなBotです。これは弊社の若手エンジニアが作ってくれました。

f:id:kazunosuket:20190222032627p:plain:w500

どうやったか

きっとoutgoing webhooks使ってるはず。

KINGOFTIME

なぜやるのか

出退勤管理のボタンがWeb上にある場合、十中八九押すの忘れちゃいますよね?そのせいでよく上長に怒られています。その辺りにAmazonDashボタン置いとくと僕含め尽くみんな押しまくるのに不思議なものです。ということで押し圧(押したくなる気持ちの大きさの指標)の強いインターフェースに移行しました。

そういえば、押し圧が強いで無限プチプチを思い出しました。

どうなったか

メリットは押し圧の強さで打刻漏れを強力にフォローしてくれることです。デメリットは押し圧の強さで他人も押しちゃうことです。

早くラズパイと指紋認証センサーで再構築しないと今度は打刻取り消し申請の山ができてもっと上長に怒られます。

f:id:kazunosuket:20190222032921p:plain:w500 f:id:kazunosuket:20190222032842p:plain:w500

どうやったか

弊社ではKINGOFTIMEを出退勤管理に利用させていただいています。 実装はこちらもHeadless Chromeでログインして出勤ボタンと退勤ボタンを押すだけなのです。

コードは省きます。というより、そもそもAPI提供しているのでそこから正規に叩くべき😦

最後に

最後までお目通しありがとうございました。

弊社の社名であるLiBはL(Life)とB(Business)の間にi(私)がいるという意味なのですが、BはBotのBなんじゃないかと最近思い始めました。

大きな社会課題を解決するサービスを作ることはもちろんやりがいも楽しさもありますが、身近の困ってる人をちょっと幸せにできるBotはまた違った嬉しさがあります。

この記事で皆さんのLifeとBotの間にiがある生活が少しでも充実すると嬉しいです!

※ この記事ではサーバーサイドにdasherを使いましたが、最近ではamazon-dashのほうがイケてるようです。現在進行系で開発が進んでおりExtensionで外部サービスとの連携も取りやすくなっています。

github.com