SEEDS Creator's Blog

SlackAPIを使って簡単なTODOチェックアプリを作成してみた

WEBエンジニアの石田です。 さて、僕は前回もSlackネタでしたが、今回もSlackネタです。

弊社では、お掃除部という部活(?)がありまして、拭き掃除・ゴミ出し・換気などのオフィス内の掃除、あと朝一のコーヒー作りを有志が毎日行っています。
当番とかは決まっておらず、できるメンバーでやろう!というスタンスなのですが、部員の大半を占めるエンジニア達はフレックス制で出社時間がバラバラ…
となると、チェックリストは欲しいですよね。

紙とかホワイトボードでチェック!となるのがまあ普通だと思いますが、弊社はIT企業。そして社内のコミュニケーションにSlackを使ってるんだから、せっかくならオンラインで済ませてしまいたい。という欲が僕の中で沸々と湧き上がってきたので、SlackAPIを使ってアプリ作りました(・ω・)b

まずは出来上がったものをご紹介します。

f:id:seeds-std:20190425000202g:plain
ボタンをクリックすると名前が登録され、同じボタンを押すと登録が解除されます

仕組み

  • AM9:00になるとタスク一覧と、項目ごとの絵文字のボタンが並んでいるメッセージが自動投稿される。
    • cronで平日毎日9:00にpostするAPIを叩く
  • タスクの絵文字ボタンをクリックすると、クリックしたユーザーのIDがタスク一覧の下に表示される。
  • 既にボタンをクリック済みのユーザーが、もう一度同じ絵文字のボタンをクリックするとタスク一覧の下に表示されていた名前が消える。
    • 絵文字のボタンを押すとSlack から指定したURLにリクエストが送られ、リクエストに応じて処理し、メッセージを書き換えるAPIを叩く

今回つかったもの

  • PHP
  • MySQL
  • Slack API
    • Interactive Components

やってみる

1. Slack APIでAppを作成

  • Interactive Components メッセージにボタンやメニューなどを付与し、ユーザーからのアクションに応じて応答できる機能です。 GitHubやBitBucketのAppをはじめ、最近とても頻繁に使われているので多分見たことあるのではないでしょうか。

https://api.slack.com/apps?new_app=1

f:id:seeds-std:20190610212654p:plain
AppNameは後から変更も可能でした。
上記のURLにアクセスすると、SlackAppの作成画面が出るので AppName (アプリ名) と Development Slack Workspace (使用するSlackのワークスペース) を指定し、 Create App します。
SlackでAPIを作成すると、FeaturesにInteractive Componentsという項目があり、Onにすると設定が可能になります。 f:id:seeds-std:20190610213057p:plain 設定は色々とありそうですが、今回は Request URLに任意のURL( example.com/api/hogehoge.php など)を貼ればOK。

あと、投稿用にSlackのトークンを取得しておきます。 OAuth & Permissions からScopesを絞って Install App。 Permission Scopeはchat:write:botを選択でOKです。

f:id:seeds-std:20190725210251p:plain
権限はchatだけでok
f:id:seeds-std:20190725210349p:plain
Permissionを設定したらInstall App

これでSlack上の設定は完了。

2. サーバー側の設定

とりあえずmysqlでDBを用意。型は適当につけてます

CREATE TABLE polls
(
    id          INT AUTO_INCREMENT PRIMARY KEY,
    message_ts  VARCHAR(255) NOT NULL,
    channel_id  VARCHAR(255) NOT NULL,
    text        TEXT         NOT NULL,
    answers     TEXT         NOT NULL,
    attachments TEXT         NOT NULL,
    created_at  TIMESTAMP    NULL
) DEFAULT CHARSET = utf8 AUTO_INCREMENT = 1;

CREATE TABLE votes
(
    id          INT AUTO_INCREMENT PRIMARY KEY,
    channel_id   VARCHAR(255) NOT NULL,
    message_ts   VARCHAR(255) NOT NULL,
    user_name    VARCHAR(255) NOT NULL,
    user_id      VARCHAR(255) NOT NULL,
    action_name  VARCHAR(255) NOT NULL,
    action_value VARCHAR(255) NOT NULL,
    created_at   TIMESTAMP    NOT NULL
) DEFAULT CHARSET = utf8 AUTO_INCREMENT = 1;

貼り付けた任意のURLではなく、まずpublicの外にでも投稿用のファイルを作成します。 postの第一引数の連想配列が選択肢です。nameに表示する文字を入れて、 iconはslackのアイコンの名前を入力(::はとる)でOK。なくてもOK。

<?php

post([
    ['name' => '換気',    'icon' => 'wind_blowing_face'],
    ['name' => 'ゴミ出し',    'icon' => 'wastebasket'],
    ['name' => '拭き掃除',   'icon' => 'sparkles'],
    ['name' => 'コーヒー', 'icon' => 'coffee'],
] , '今日のTODO');


function post($questions, $description) {
    $attachments = [];
    $buttons = [];
    $text = $description . "\n";

    // answersが表示テキスト、buttonsが実施ボタンになる
    $answers = [];
    foreach ($questions as $key => $button) {
        $answers[] = ':' . $button['icon'] . ': ' . $button['name'];
        $buttons[] = [
            'name' => $button['icon'] ?? ($key + 1),
            'text' => ':' . ($button['icon'] ?? ($key + 1)) . ':',
            'type' => 'button',
            'value' => $button['name']
        ];
    }

    // ボタンは5つを超えるとattachmentがスマホで表示できなくなるためを分割しておく
    $block = array_chunk($buttons, 5);

    // タスクごとに改行する
    $text .= implode("\n", $answers);

    // ボタンのブロックごとにattachmentを作成する
    foreach ($block as $key => $action) {
        $attachments[] =
            [
                'fallback' => 'daily' . $key,
                "callback_id" => "daily",
                "color" => "#3AA3E3",
                "attachment_type" => "default",
                'actions' => $action
            ];
    }

    // Slackに投稿を行う
    $curl = curl_init();
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_URL => 'https://slack.com/api/chat.postMessage',
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json; charset=utf-8',
            'Authorization: Bearer ' . '【SLACKのトークン】'
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'channel' => '【投稿チャンネル】',
            'text'    => $text,
            'attachments' => $attachments
        ])
    ]);
    $response = curl_exec($curl);
    $response = json_decode($response, true) ?? null;

    if ( !$response ) {
        return false;
    }

    //投稿情報をMySQLに保存しておく
    $db = new PDO(
        'mysql:host=' . '【接続するホスト】' .';dbname=' . '【DB名】' . ';charset=utf8',
        '【DBユーザー名】', '【DBパスワード】', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );
    $query = $db->prepare('INSERT INTO polls (message_ts, channel_id, text, answers, attachments, created_at) VALUES (:message_ts, :channel_id, :text, :answers, :attachments, :created_at)');
    $query->execute([
        ':message_ts'  => $response['ts'],
        ':channel_id'  => $response['channel'],
        ':text'        => $description,
        ':answers'     => json_encode($answers),
        ':attachments' => json_encode($response['message']['attachments']),
        ':created_at'  => date('Y-m-d H:i:s')
    ]);

    return $response;
}

上記のPHPファイルを実行すると指定したチャンネルに下記のようなフォームみたいなものが投稿されます。

f:id:seeds-std:20190809175228p:plain

各ボタンをクリックすると、先程のURLのhoge.phpにPOSTが走るので、下記を配置しておきます。

<?php

if (!empty($_POST['payload'])) {
    $vote = json_decode($_POST['payload'], true);
    $message = $vote['message_ts'];
    $channel = $vote['channel']['id'];
    $user_id = $vote['user']['id'];
    $username = $vote['user']['name'];

    $db = new PDO(
        'mysql:host=' . '【接続するホスト】' .';dbname=' . '【DB名】' . ';charset=utf8',
        '【DBユーザー名】', '【DBパスワード】', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );
    $query = $db->prepare('SELECT * FROM polls WHERE message_ts = :message_ts AND channel_id = :channel_id');
    $query->execute([
        ':message_ts'   => $message,
        ':channel_id'   => $channel
    ]);
    $data = current($query->fetchAll());

    if ($data) {
        $attachments = json_decode($data['attachments'], true);

        //既に存在する実施TODO・実施者IDとボタン押した人の実施TODO・IDが一致したら存在フラグをたてる
        $votes = selectVotes($db, $message, $channel);
        $exist = false;
        foreach ($votes as $answer) {
            if ($answer['user_id'] === $user_id && $answer['action_value'] === $vote['actions'][0]['value']) {
                $exist = $answer['id'];
            }
        }

        //存在フラグが立ってる場合は削除・ない場合は新規挿入
        if ($exist) {
            $db->prepare('DELETE FROM votes WHERE id=:id')->execute([':id' => $exist]);
        } else {
            $db->prepare('
                INSERT INTO
                  votes
                   (channel_id, message_ts, user_name, user_id, action_name, action_value, created_at)
                VALUES
                   (:channel_id, :message_ts, :user_name, :user_id, :action_name, :action_value, :created_at)')
            ->execute([
                ':channel_id'   => $channel,
                ':message_ts'   => $message,
                ':user_name'    => $username,
                ':user_id'      => $user_id,
                ':action_name'  => $vote['actions'][0]['name'],
                ':action_value' => $vote['actions'][0]['value'],
                ':created_at'   => date('Y-m-d H:i:s')
            ]);
        }

        // 投稿したTODOの現在の実施者一覧を出す
        $votes = selectVotes($db, $message, $channel);
        $result = [];
        $answers = json_decode($data['answers']);
        foreach ($answers as $answer) {
            $result[$answer] = '';
            foreach ($votes as $vote) {
                if ($answer === ':' . $vote['action_name'] . ': ' . $vote['action_value']) {
                    $result[$answer] .= ' <@' . $vote['user_id'] . '>';
                }
            }
        }

        // 実施者一覧をメッセージに反映する
        $text = $data['text'];
        foreach ($result as $answer => $voters) {
            $text .= "\n" . $answer . "\n";
        $text .= empty($voters) ? '' : $voters . "\n";
        }

        $update = [
            'channel' => $data['channel_id'],
            'ts' => $data['message_ts'],
            'text' => $text,
            'attachments' => $attachments
        ];

        // Slackのメッセージを更新する
        $url = 'https://slack.com/api/chat.update';
        $curl = curl_init();
        curl_setopt_array($curl, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_URL => $url,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json; charset=utf-8',
                'Authorization: Bearer ' . '【SLACKのトークン】'
            ],
            CURLOPT_POSTFIELDS => json_encode($update)
        ]);
        return curl_exec($curl);
    }
}

function selectVotes(PDO $db, string $message_ts, string $channel_id)
{
    $query = $db->prepare('
          SELECT 
              *
          FROM
              votes
          WHERE
              message_ts = :message_ts AND
              channel_id = :channel_id
          ORDER BY
              action_value
          ');
    $query->execute([
        ':message_ts'   => $message_ts,
        ':channel_id'   => $channel_id
    ]);
    return $query->fetchAll();
}

送られてきたpostの中にユーザーID・ボタンを押したmessageの情報・押したボタンの情報があるので、それを照合し、あれば削除(MySQLから既存レコードをDELETE)・なければ作成(MySQLにINSERT)し、元のメッセージを状況に合わせて更新することで投票を実現しています。

所感

プログラムの部分で長くはなってしまいましたが、これでTODOアプリは完成です。シーズでは毎日の日次タスクと、月曜日に週次タスクを投稿する形で運用してますが、今のところ問題なく稼働しています。 こういったTODOリストの共有やリマインダ、GitHub/BitBucketの連携など、SlackAPIを応用することで、手軽に様々な機能を日々のコミュニケーションの中に自力で追加できるのはとても魅力的だと感じました。

アイデア次第ではもっと面白いことができそうなSlackAPI、もっと色々な活用方法を見出して社内に還元していきたいなあ…と目論んでおります!