😺

【GAS(Google Apps Script)× Slack】アイデア投稿Slackアプリ実装 - 後編|Offers Tech Blog

2022/07/28に公開

概要

こんにちは、Offers を運営している株式会社 overflow のバックエンドエンジニアの shun です。今回は、前回の 【GAS(Google Apps Script)× Slack】アイデア投稿Slackアプリ実装 - 前編 で作成した Slack アプリを育て、アイデア投稿 Slack アプリを全て実装していきます。早速やっていきましょう。

本格的にアイデア投稿Slackアプリを実装

前回の記事にて Slack アプリの作成ができたので、あとは GAS で機能を実装 -> 検証 -> 修正を繰り返していくだけです。残りのやることとしては以下になります。

  • スラッシュコマンド投稿したらアイデア入力フォームを立ち上げる
  • 投稿内容をスプレッドシートへ出力
  • 投稿内容を Slack へ通知

ではサクサク残りやってしまいましょう!

Slackアプリが行うScopeを設定する

Slack アプリで行うこととしては以下なので、それぞれに必要となる Scope を付与していきます。
Slack アプリのページのサイドメニューから、OAuth & Permissions を選択し、Scopes セクションから以下の 2 つを追加します。

行うこと Scope
Slackへの投稿 chat:write
投稿者の情報取得 users:read

Scope の設定が完了したら、ページ上部の Reinstall to Workspace を実行し、再度インストールします。
インストールが完了すると、Bot User OAuth Token なるものが出現します。この値を GAS で利用していきます。

スラッシュコマンド投稿したらアイデア入力フォームを立ち上げる

GAS の doPost メソッドは、デプロイしたエンドポイント URL に対して POST リクエストした時に発火します。なのでスラッシュコマンドを打ち込んだ時にもこの POST リクエスト送られるため、doPost 内部にあれこれ書く必要があります。

App/Main.gs を以下のように書き換えます。

App/Main.gs
const doPost = (e) => {
  const parameter = e.parameter

  try {
    // 自分が作成したSlackアプリからのリクエストでない場合はエラー
    if (SLACK_TOKEN != parameter.token) throw new Error(parameter.token)

    // モーダルを開くようにSlackへリクエスト
    return openModal(parameter)
  } catch(error) {
    return ContentService.createTextOutput(403)
  }
}

続いて、新規で App/ModalView.gs を作成します。

App/ModalView.gs

/**
 * モーダルOpen
 */
const openModal = payload => {
  const modalView = generateModalView()
  const viewData = {
    token: SLACK_ACCESS_TOKEN,
    trigger_id: payload.trigger_id,
    view: JSON.stringify(modalView)
  }
  const postUrl = 'https://slack.com/api/views.open'
  const viewDataPayload = JSON.stringify(viewData)
  const options = {
    method: "post",
    contentType: "application/json",
    headers: { "Authorization": `Bearer ${SLACK_ACCESS_TOKEN}` },
    payload: viewDataPayload
  }

  UrlFetchApp.fetch(postUrl, options)
  return ContentService.createTextOutput()
}

/**
 * モーダルBlocks
 */
const generateModalView = () => {
  return {
    "type": "modal",
    "title": {
      "type": "plain_text",
      "text": "アイデア投稿App",
      "emoji": true
    },
    "submit": {
      "type": "plain_text",
      "text": "投稿する",
      "emoji": true
    },
    "close": {
      "type": "plain_text",
      "text": "キャンセル",
      "emoji": true
    },
    "blocks": [
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": "*アイデア募集中!*"
        }
      },
      {
        "type": "divider"
      },
      {
        "block_id": "idea_title",
        "type": "input",
        "element": {
          "type": "plain_text_input",
          "multiline": true,
          "action_id": "idea_title_id"
        },
        "label": {
          "type": "plain_text",
          "text": "アイデア",
          "emoji": true
        }
      },
      {
        "block_id": "idea_detail",
        "type": "input",
        "element": {
          "type": "plain_text_input",
          "multiline": true,
          "action_id": "idea_detail_id"
        },
        "label": {
          "type": "plain_text",
          "text": "背景・詳細",
          "emoji": true
        }
      }
    ]
  }
}

続いて、Settings.gs を新規作成し、以下のように設定データを格納します。

Settings.gs
const SLACK_SIGNING_SECRET = 'SlackアプリのTop画面にある Signing Secret'
const SLACK_ACCESS_TOKEN = 'Bot User OAuth Tokenに記述されている値'
const SLACK_TOKEN = 'SlackアプリのTop画面にある Verification Token'
const SLACK_CHANNEL = '回答内容を通知したいチャネル名(例)#general'

ここで、再度デプロイをしましょう。

再度Slackからスラッシュコマンド打ってみる

Slack の任意のチャンネルにて /ideapost を打ち込んでみます。

スラッシュコマンドによって表示されるSlack上のモーダルの画像

ペンギン GoodJob

現状はこのフォームに値を入力して投稿するボタンを押してもローディングが続いてエラーになります。

続いて 回答 をできるようにしましょう。

投稿内容をスプレッドシートへ出力

App/Main.gs を以下のように書き換えます。

スラッシュコマンドでもこの doPost は呼ばれ、投稿時にも doPost は呼ばれます。
以下の特徴があるので、プログラムに分岐として適応します。

Action 特徴
スラッシュコマンド実行 e.parameter.payloadが存在しない
モーダルから回答実行 e.parameter.payloadが存在する
App/Main.gs
const doPost = (e) => {
  const parameter = e.parameter

  try {
    if(parameter.payload) {
      const payload = JSON.parse(decodeURIComponent(parameter.payload))
      if (SLACK_TOKEN != payload.token) throw new Error(payload.token)

      return appendIdeaAnswer(payload) // 回答時はスプレッドシートへ出力
    } else {
      if (SLACK_TOKEN != parameter.token) throw new Error(parameter.token)

      return openModal(parameter) // スラッシュコマンドの時はモーダルをOpen
    }
  } catch(error) {
    return ContentService.createTextOutput(403)
  }
}

App/ModalView.gs に以下のメソッドを追加します。

App/ModalView.gs
/**
 * モーダル入力サブミットした時の処理
 * ログシートへ出力のみ
 */
const appendIdeaAnswer = payload => {
  const book = SpreadsheetApp.getActiveSpreadsheet()
  const sheet = book.getSheetByName('シート1') // 投稿内容を出力するシート名

  const formValues = payload.view.state.values
  const ideaTitle = formValues.idea_title.idea_title_id.value
  const ideaDetail = formValues.idea_detail.idea_detail_id.value
  const userId = payload.user.id
  const today = Utilities.formatDate(new Date(), 'Asia/Tokyo', "yyyy/MM/dd")
  const appendData = [ideaTitle, ideaDetail, userId, today]
  const lastRow = sheet.getLastRow()+1
  sheet.getRange(lastRow, 1).setFormula('=row()-1')
  sheet.getRange(lastRow, 2, 1, appendData.length).setValues([appendData])
  return ContentService.createTextOutput()
}

再度、デプロイ -> スラッシュコマンド・Interactivity 設定更新を行いましょう。
その後、再度検証してみます。

Slackアプリで立ち上げたアイデア投稿モーダルフォームの画像

投稿する ボタンクリックします。

ローディングが走り、モーダルが閉じます。
ここで、出力先のスプレッドシートを確認してみましょう。

アイデアの出力内容を表示したスプレッドシートの画像

無事に出力が確認できました!!ペンギン GoodJob!

投稿内容をSlackへ通知

いよいよ最後のステップです。
スプレッドシートに出力された投稿内容を、定期ジョブにて Slack へ通知しましょう。
重複で通知が行かないように、スプレッドシートの isNotified? 列にてステータスを随時更新するようにします。スプレッドシートの最終行に isNotified? を追加しましょう。

isNotified?列を追加したスプレッドシートの画像

次に、新規で Jobs/NotifyPostedIdea.gs ファイルを作成します。

Jobs/NotifyPostedIdea.gs
/**
 * 投稿内容を匿名でSlackへPOST
 * 5分おきに実行
 */
const notifyPostedIdeas = () => {
  const book = SpreadsheetApp.getActiveSpreadsheet()
  const sheet = book.getSheetByName('シート1') // 投稿内容を出力するシート名
  const sheetData = sheet.getDataRange().getValues()
  const header = sheetData[0]

  sheetData.map((data, index) => {
    if(index > 0 && data[header.indexOf("isNotified?")] === '') {
      return {
        data: data,
        rowNum: index+1
      }
    }
  }).filter(a => a).forEach(target => { // map内にif分使用すると、elseに入った場合はundifinedになるため、filterで潰します
    postToSlack(target, sheet, header)
    updateSheetFlag(target, sheet, header)
  })
}

/**
 * Slackへ通知
 */ 
const postToSlack = (data, sheet, header) => {
  // 重複投稿防ぎ
  sheet.getRange(data.rowNum, header.indexOf("isNotified?")+1).setValue("投稿中...")
  const postRequestUrl = "https://slack.com/api/chat.postMessage" // see: https://api.slack.com/methods/chat.postMessage
  const blocks = generateNotifyBlockMessage(data, header)
  const message = {
    token: SLACK_ACCESS_TOKEN,
    blocks: blocks['blocks'],
    channel: SLACK_CHANNEL
  }

  const messagePayload = JSON.stringify(message)
  const messageOptions =
  {
    method: "post",
    contentType: "application/json",
    payload: messagePayload,
    headers: { "Authorization": `Bearer ${SLACK_ACCESS_TOKEN}` }
  }

  UrlFetchApp.fetch(postRequestUrl, messageOptions)
}

/**
 * 通知完了した行の isNotified? 列をYESに更新
 * これをすることで、再度 notifyPostedIdeas が走った時に重複通知がされないようになります
 */ 
const updateSheetFlag = (data, sheet, header)  => {
  sheet.getRange(data.rowNum, header.indexOf('isNotified?')+1).setValue('YES')
  return true
}

/**
 * Slack通知用のメッセージ作成
 */ 
const generateNotifyBlockMessage = (data, header) => {
  return {
    "blocks": [
      {
        "type": "divider"
      },
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": ":sparkles:*新しいアイデア発掘!*:sparkles:"
        }
      },
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": ":bulb:*タイトル*"
        }
      },
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": `${data.data[header.indexOf('Title')]}`
        }
      },
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": ":bulb:*詳細・背景*"
        }
      },
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": `${data.data[header.indexOf('Description')]}`
        }
      },
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": "これ賛同!というものだったら:+1:(+1)スタンプを押してください!"
        }
      }
    ]
  }
}

そして、こちらを参考 に、notifyPostedIdeas を 5 分おきに動くように設定すれば、以下のような通知が Slack へ飛ぶようになります。

アイデアの投稿内容がSlackに通知されたことを示す画像

ペンギンお疲れ様でした。

実際に社内に導入してみてどうだったか

アイデア投稿は導入後、いろんな視点でのアイデアがありました。
以前ご紹介した BreakingTime という施策もこのアイデア投稿アプリ経由で実現され、そろそろ 1 年記念日を迎えます。
プロダクトに対するアイデアや、社内制度に対するアイデアなど、多くのアイデアが実行されていきました。 「あ、アイデア出したらみんな吟味してくれるじゃん」 という感覚を得られるのは結構嬉しいものです。
余談ですが、私が良かれと思って毎週月曜日にペンギン経由で「アイデアください!!」的なメッセージを投稿していたら多少ウザがられたので、やり過ぎには注意ですね笑
使いたい時、そこにあることが重要だと思います。

まとめ

今回は Slack・GAS(Google Apps Script)・スプレッドシートを用いて、アイデア投稿 Slack アプリの実装方法についてまとめました。

前編含めだいぶ長くなりましたが、最後まで読んで頂き、ありがとうございました。「いいね」していただけると記事執筆の励みになりますので、参考になったと思った方は是非よろしくお願いします!

次回は、社内でみんなが気持ちよく利用できるような GAS 利用時の注意点などを書こうと思います。

関連記事

https://zenn.dev/offers/articles/20220616-google-app-script-technique
https://zenn.dev/offers/articles/20220630-google-app-script-slack
https://zenn.dev/offers/articles/20220328-promote-communication-in-remote-team

Offers Tech Blog

Discussion