【改定版】Google Apps Scriptでデータを組織に流通させる：②Looker StudioのダッシュボードキャプチャをSlackに投稿する

# はじめに

Looker Studioをより効果的に活用する方法として、以前[「Google Apps Scriptでデータを組織に流通させる：②Looker StudioのダッシュボードキャプチャをSlackに投稿する」](https://qiita.com/SoySoySoyB/items/223e3cf14754e6e7fa31)という記事を書きました。

公開したのが2023年の9月末なのですが、その後2024年5月に、2025年3月11日に過去記事で利用していたfiles.uploadのAPIが使えなくなるという通知が来ていました。

いつかアップデート対応しないとな～と思っていましたが、ようやく対応できたので、改定版として記事にしておきます。


# こんな方におすすめ

- 整備したデータがより組織に流通するようにしたい
- Google Apps ScriptでBigQueryのデータを活用したい
- Google Apps ScriptでLooker Studioのダッシュボードを活用したい


# 変わったこと

仕様変更に伴い、以下のような対応が必要になります。

- 過去
    - files.uploadに投げればシンプルに投稿ができた
- 今後
    - 3つの工程を経なければいけない
        - files.getUploadURLExternalからアップロード用のURLを取得
        - 取得したURLにファイルをアップロード
        - files.completeUploadExternalでアップロードしたファイルをチャンネルにポスト

また、投稿したポストのURLがシンプルに取れないので、サンプルコードではURLを取得するための処理も追加しています。

:::note

基本的な処理は変わらないので、以前の記事をご確認ください！

:::


# サンプルコード

それでは、以下にサンプルコードを記載します。やや長くなっていますが、同じようなことを何回もやっているだけなので、処理としては単純です。

なお、headerやpayloadの書き方でやたらはまった記憶がありますが、もはやどこにはまったかは覚えていないですし、サンプルの通りに書けば問題なく処理できるようになっています。

特に追加で解説すべきところはないので詳細は割愛しますが、末尾の処理でポストのURLを取得する場合はfiles.readの権限が必要になるので注意してください。

```javascript
/**
 * Post subscribed Looker Studio dachboard image to Slack
 * @param {string} report_url - Looker Studio report url
 * @param {string} report_title - Looker Studio report title
 * @param {string} page_title - Looker Studio page title
 * @param {string} slack_token - Slack user or bot token
 * @param {string} slack_channel_id - Slack channnel id to post message
 * @param {string} custom_text - (optional) null or custom text to post
 * @param {string} reply_message_url - (optional) null or message url to reply; like 'https://sample.slack.com/archives/XXXXXXXXXXX/p****************'
 * @return {string} url of file post message
 */
function postLookerStudioDashboardImage(report_url, report_title, page_title, slack_token, slack_channel_id, custom_text, reply_message_url=null) {
  const report_id = report_url.split('/reporting/')[1].split('/page/')[0]
  const page_id = report_url.split('/page/')[1].split('/page/')[0]
  const file_name = `Report_${report_id}_page_${page_id}.jpg`
  const looker_studio_image = getLookerStudioImage_(report_title, file_name)
  let post_text = report_url
  if (custom_text){
    post_text = `${custom_text}\n${report_url}`
  }
  return postFileToSlack(slack_token, slack_channel_id, looker_studio_image, `${report_title} - ${page_title}`, post_text, reply_message_url)
}


/**
 * Get Looker Studio image from subscription mail (Gmail)
 * @param {string} report_title - Looker Studio report title
 * @param {string} file_name - file name of image
 * @return {blob} blob file of Looker Studio image 
 */
function getLookerStudioImage_(report_title, file_name) {
  const query = `subject:${report_title} from:looker-studio-noreply@google.com`
  const thread = GmailApp.search(query)[0]
  const messages = GmailApp.getMessagesForThread(thread)
  const last_message = messages[messages.length - 1]
  const attachments = last_message.getAttachments()
  for (attachment of attachments){
    if (attachment.getName() == file_name){
      return attachment
    }
  }
}


/**
 * Post file to Slack
 * @param {string} slack_token - Slack user or bot token
 * @param {string} slack_channel_id - Slack channnel id to post message
 * @param {blob} blob_file - blob file to post
 * @param {string} file_name - file name to post
 * @param {string} post_text - comment to post
 * @param {string} reply_message_url - (optional) null or message url to reply; like 'https://sample.slack.com/archives/XXXXXXXXXXX/p****************'
 * @return {string} url of file post message
 */
function postFileToSlack(slack_token, slack_channel_id, blob_file, file_name, post_text, reply_message_url=null){

  // Get URL to upload file / ref: https://api.slack.com/methods/files.getUploadURLExternal
  const file_size = blob_file.getBytes().length
  const url_to_get_url = `https://slack.com/api/files.getUploadURLExternal`
  const headers_to_get_url = {
    'contentType': 'application/x-www-form-urlencoded',
    'authorization': `Bearer ${slack_token}`
  }
  const payload_to_get_url = {
    'filename': file_name,
    'length': `${file_size}`
  }
  const options_to_get_url = {
    'method': 'post',
    'headers': headers_to_get_url,
    'payload': payload_to_get_url
  }
  const res_to_get_url = JSON.parse(UrlFetchApp.fetch(url_to_get_url, options_to_get_url).getContentText())
  if (!res_to_get_url.ok){
    throw new Error(`Failed to get upload URL: ${res_to_get_url.error}`)
  }
  Logger.log(res_to_get_url)


  // Upload file
  const url_to_upload_file = res_to_get_url.upload_url
  const file_id = res_to_get_url.file_id
  const payload_to_upload_file = {
    'file': blob_file,
    'filename': file_name
  }
  const options_to_upload_file = {
    'method': 'post',
    'headers': headers_to_get_url,
    'payload': payload_to_upload_file
  }
  const res_to_upload_file = UrlFetchApp.fetch(url_to_upload_file, options_to_upload_file)
  if (res_to_upload_file.getResponseCode() != 200){
    throw new Error(`Failed to upload file: ${res_to_upload_file.getContentText()}`)
  }
  Logger.log(res_to_upload_file.getContentText())


  // Post file to channel / ref: https://api.slack.com/methods/files.completeUploadExternal
  const url_to_post_file = `https://slack.com/api/files.completeUploadExternal`
  const headers_to_post_file = {
    'contentType': 'application/x-www-form-urlencoded',
    'authorization': `Bearer ${slack_token}`
  }
  let thread_ts = null
  if (reply_message_url){
    thread_ts = reply_message_url.split('/')[5].slice(1, -6) + '.' + reply_message_url.split('/')[5].slice(-6)
  }
  const payload_to_post_file = {
    'files': `[{"id": "${file_id}", "title": "${file_name}"}]`,
    'channel_id': slack_channel_id,
    'initial_comment': post_text
  }
  if (thread_ts){
    payload_to_post_file['thread_ts'] = thread_ts
  }
  const options_to_post_file = {
    'method': 'post',
    'headers': headers_to_post_file,
    'payload': payload_to_post_file
  }
  const res_to_post_file = JSON.parse(UrlFetchApp.fetch(url_to_post_file, options_to_post_file).getContentText())
  if (!res_to_post_file.ok){
    throw new Error(`Failed to post file: ${res_to_post_file.error}`)
  }
  Logger.log(res_to_post_file)


  // Get File Post URL / ref: https://api.slack.com/methods/files.info
  Utilities.sleep(3000)
  const url_to_get_file_post_url = 'https://slack.com/api/files.info'
  const headers_to_get_file_post_url = {
    'contentType': 'application/json',
    'authorization': `Bearer ${slack_token}`
  }
  const payload_to_get_file_post_url = {
    'file': `${res_to_post_file.files[0].id}`
  }
  const options_to_get_file_post_url = {
    'method': 'post',
    'headers': headers_to_get_file_post_url,
    'payload': payload_to_get_file_post_url
  }
  const res_to_get_file_post_url = JSON.parse(UrlFetchApp.fetch(url_to_get_file_post_url, options_to_get_file_post_url).getContentText())
  if (!res_to_get_file_post_url.ok){
    throw new Error(`Failed to post file: ${res_to_get_file_post_url.error}`)
  }
  Logger.log(res_to_get_file_post_url)

  const base_url = res_to_get_file_post_url.file.permalink.split('/files/')[0]
  const thread_ts_s_to_get_file_post_url = res_to_get_file_post_url.file.shares.public[slack_channel_id][0].ts
  let thread_base_ts = ''
  if (thread_ts){
    thread_base_ts = `?thread_ts=${thread_ts}`
  }
  const file_post_url = `${base_url}/archives/${slack_channel_id}/p${thread_ts_s_to_get_file_post_url.replace('.', '')}${thread_base_ts}`
  return file_post_url
}
```


# おわりに

別に大したものではないですが、これを使うとモニタリングが捗ることはもちろん、リプライ形式で断面を積み上げていくことで過去からの変化も簡単に振り返ることができるようになります。よろしければご利用くださいませ！