映画を見る会用Slack botの作成

今回作ったもの

ラボで映画を定期的に見る会が立ったので、各人が見たい映画を各々追加し、またその映画リストの中からランダムな映画を推薦してくれるslack botを作成しました。

画像

こんな感じで動きます。

手順については大まかにしか記していませんが、GASのコードを残しておきますので、誰かの助けになればと思います。

機能:

  • cinema add <title>で映画を追加
  • cinema remove <title>で削除
  • cinema list で現在のリストを確認
  • cinema next で次に見る映画をランダムにレコメンド

構成:

Google スプレッドシート + GAS (Google Apps Script) + slack api 

 

参考:

GASでSlackのinteractive messageの作成 ① Webhookアプリ編 #Slack - Qiita

SlackBotへのメンションをトリガーにメール送信する方法 #Slack - Qiita

Slack Appの作り方を丁寧に残す【BotとEvent APIの設定編】

 

作成手順:

  1. https://api.slack.com/ からYour Appsを作成
    1. Create New App→From Scratch

    2. 適当に名前とアプリを入れたいワークスペースを追加します
    3. Basic InformationのAdd featurs and functionalityからIncoming webhhooksを追加



    4. 一番下のAdd New Webhook to Workspaceから、ワークスペースの中の投稿したいチャンネルを選択
      (ここまでで、メッセージをチャンネルに投稿できるようになる。Sample curl requestをターミナルで実行すれば、"Hello, world!"のメッセージがチャンネルに投稿されるはず。)

      アプリの名前とアイコンはBasic Informationの下の方から追加できます。
  2. Google スプレッドシートを開いて、webアプリを作成する
    1. スプレッドシートを作成
      今回は3列目をデータとして用いる。テスト用に適当なタイトルを入力しておく。
    2. 拡張機能のタブからApps Scriptを追加
    3. 以下の関数を作成
      function doPost(e) {
        // testMessage();
        var params = JSON.parse(e.postData.getDataAsString());
        var response = {};
        if (params.type === 'url_verification') {
          return ContentService.createTextOutput(params.challenge);
        } else if (params.type === 'event_callback') {
          if (params.event.type == 'message') {
            response = eventHandler(params.event);
          }
        }
        return {};
        // return ContentService.createTextOutput(response).setMimeType(ContentService.MimeType.JSON);
      }
       
      function eventHandler(event) {
        return {};
      }
      slackのEvent Subscriptionのチェックを通すために、送られてきたメッセージがurl_verificationだった時にchallengeを返すようにしておく。後でeventHandler内を実装し、メッセージに合わせて処理を行うようにする。
    4. アプリをデプロイする。アクセスできるユーザーの範囲を全員に広げておく(本当はもっと狭めるべきかもしれない)。
    5. デプロイしたアプリのurlをコピーしておく。
  3. スプシとslack apiを連携させる
    1. slackのアプリを作るwebサイトの方に戻って、Event Subscriptionを開く
    2. URLのところに先ほどのGASのwebアプリのurlを貼り、Verifiedされたら成功。失敗した場合はエラーメッセージをググる
    3. Subscribe to events on behalf of usersの所から、message.channelsに対するサブスクライブを行うように設定する。


      これでslack botがチャンネル内のメッセージを読み、GASに送信できるようになる。読むたびにdoPost()関数が実行される。
      function testMessage() {
        //Webhook URLを以下に入力
        const postUrl = "<your app url>";
        const sendMessage = "test";
        const jsonData = {
          "text": sendMessage
        };
        const payload = JSON.stringify(jsonData);
        const options = {
          "method": "post",
          "contentType": "application/json",
          "payload": payload
        };
        UrlFetchApp.fetch(postUrl, options);
      }
      こんな感じの関数をGASに追加してテストしてみる。この関数を実行すると、slackに"test"というメッセージが飛ぶ。

      GASの実行ボタンから関数を実行しても良いし、上のdoPost()関数のどこかに追加して実行されているか試してもいい。
  4. GASのスクリプトに処理を追加する。
    1. 最後に、読んだメッセージに合わせて映画リストを編集したりslackに投稿したりする機能を作成する。スクリプトは以下。
      function sendMessage(text) {
        //Webhook URLを以下に入力
        const postUrl = "<>";
       
        // メッセージを加工
        const formattedMessage = text + "\n ```" + createMessage()+ "```"; // 引用枠で囲まれたテキストとcreateMessage()の内容を結合

        const jsonData = {
          "text": formattedMessage
        };
        const payload = JSON.stringify(jsonData);
        const options = {
          "method": "post",
          "contentType": "application/json",
          "payload": payload
        };
        UrlFetchApp.fetch(postUrl, options);
      }


      function testMessage() {
        //Webhook URLを以下に入力
        const postUrl = "<>";
        const sendMessage = "test";
        const jsonData = {
          "text": sendMessage
        };
        const payload = JSON.stringify(jsonData);
        const options = {
          "method": "post",
          "contentType": "application/json",
          "payload": payload
        };
        UrlFetchApp.fetch(postUrl, options);
      }

      function chosenMessage(text) {
        //Webhook URLを以下に入力
        const postUrl = "<>";
        // メッセージを加工
        //最後の文字を幾つかのパターンからランダムに変える "お楽しみに!"など
        const lastMessages = ["お楽しみに!", "ポップコーンをお忘れなく!","いってらっしゃい!","研究ネタが見つかるかも!?","コーラをお忘れなく!","ピザでも頼む?","初めて見る?","アメージング!!","予定は空いてる?","CHILL OUT!","見たことあるかな?","これは教養だね!","これは必修だよね!"];
        const random = Math.floor(Math.random() * lastMessages.length);
        const lastMessage = lastMessages[random];
        const sendMessage = "次回の上映は *" + text + "* ですよ!\n" + lastMessage;
        const jsonData = {
          "text": sendMessage
        };
        const payload = JSON.stringify(jsonData);
        const options = {
          "method": "post",
          "contentType": "application/json",
          "payload": payload
        };
        UrlFetchApp.fetch(postUrl, options);
      }

      function createMessage() {
        const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
        const sheet = spreadsheet.getSheetByName("schedule");

        const data = sheet.getRange(2, 3, sheet.getLastRow() - 1, 1).getValues();
        const messages = ;

        for (const row of data) {
          if (row[0]) { // 空でないセルのみを処理
            messages.push(row[0]);
          }
        }

        return messages.join('\n');
      }

      function doPost(e) {
        // testMessage();
        var params = JSON.parse(e.postData.getDataAsString());
        var response = {};
        if (params.type === 'url_verification') {
          return ContentService.createTextOutput(params.challenge);
        } else if (params.type === 'event_callback') {
          if (params.event.type == 'message') {
            response = eventHandler(params.event);
          }
        }
        return {};
        // return ContentService.createTextOutput(response).setMimeType(ContentService.MimeType.JSON);
      }

      function eventHandler(event) {
        if (event.text.startsWith("cinema add")) {
          const valueToAdd = event.text.replace("cinema add", "").trim(); // メンション部分を削除
          // スプレッドシートにデータを追加
          addValueToSpreadsheet(valueToAdd);
          return sendMessage("追加したよ!");
        }

        if (event.text.startsWith("cinema list")) {
          return sendMessage("今後の上映予定は以下の通りだよ!\n" + createMessage());
        }

        if (event.text.startsWith("cinema remove")) {
          const valueToRemove = event.text.replace("cinema remove", "").trim(); // メンション部分を削除
          // スプレッドシートからデータを削除
          removeValueFromSpreadsheet(valueToRemove);
          return sendMessage("削除したよ!");
        }

        if (event.text.startsWith("cinema next")) { //ある映画リストから一つだけランダムに抜き出してそれをslackに返す、抜き出した後は削除
          return nextTitleFromSpreadsheet();
        }

        return {};
      }

      function nextTitleFromSpreadsheet(){
          const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
          const sheet = spreadsheet.getSheetByName("schedule");
          const data = sheet.getRange(2, 3, sheet.getLastRow() - 1, 1).getValues();
          const messages = ;
          for (const row of data) {
            if (row[0]) { // 空でないセルのみを処理
              messages.push(row[0]);
            }
          }
          const random = Math.floor(Math.random() * messages.length);
          const randomMessage = messages[random];
          removeValueFromSpreadsheet(randomMessage);
          chosenMessage(randomMessage);
      }

      function removeValueFromSpreadsheet(value){
          const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
          const sheet = spreadsheet.getSheetByName("schedule");
         
          // スプレッドシートから既存のデータを取得
          const data = sheet.getRange(2, 3, sheet.getLastRow() - 1, 1).getValues();
          // ↑ getRange 関数によって、3列目(C列)のセルからデータを取得し、
          // 2行目から始めて、最終行までのデータを取得します。
         
          // 値を削除
          for (let i = 0; i < data.length; i++) {
              if (data[i][0] === value) {
              // 値が一致した場合、そのセルを空白に設定
              sheet.getRange(i + 2, 3).setValue("");
              // ↑ i + 2 はスプレッドシートの行インデックス。i は配列のインデックスなので、+2 して行インデックスに変換します。
              break;
              }
          }
      }

      function addValueToSpreadsheet(value) {
        const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
        const sheet = spreadsheet.getSheetByName("schedule");

        // スプレッドシートから既存のデータを取得
        const data = sheet.getRange(2, 3, sheet.getLastRow() - 1, 1).getValues();
        // ↑ getRange 関数によって、3列目(C列)のセルからデータを取得し、
        // 2行目から始めて、最終行までのデータを取得します。

        let foundEmptyCell = false;

        // 最初の空白のセルに値を追加
        for (let i = 0; i < data.length; i++) {
          if (!data[i][0]) {
            // 空白のセルが見つかった場合、そのセルに値を設定
            sheet.getRange(i + 2, 3).setValue(value);
            // ↑ i + 2 はスプレッドシートの行インデックス。i は配列のインデックスなので、+2 して行インデックスに変換します。
            foundEmptyCell = true;
            break;
          }
        }

        if (!foundEmptyCell) {
          // 空白のセルが見つからなかった場合、新しい行に値を追加
          sheet.appendRow(["", "", value]);
        }
      }
      eventHandler内で、メッセージの最初の数文字に合わせて何らかの処理を行うようにしておく。これで完成。

 

最後に

今回は特定の文字列に対して反応するようにしたが、メンションに対して反応する方が正しい気がする。まあ最低限動いたので、一旦はこれで良しとしよう。