Chapter 10

【コード解説:GAS】スケジュール一覧取得/スケジュールの作成API、LINE Messaging API によるスケジュール通知

tokku5552
tokku5552
2022.11.24に更新

このチャプターについて

このチャプターでは、GAS を用いて、サンプルのスケジュール管理アプリの、

  • スケジュール一覧取得 API
  • 新規スケジュール作成 API
  • 締め切り日が近づいたスケジュールの LINE Messaging API による通知の実装

について説明します。

本書は Flutter Web による LIFF アプリの開発に関するものですので、GAS に関する説明は簡略化している部分があります。

GAS 実装のソースコードの全体や clasp による開発環境構築については、サンプルリポジトリgas ディレクトリ以下でご覧いただけます。

前提

ご自身の Google Drive にスプレッドシートを作成し、最初の行に次の URL と同様の順序で列名を入力してください。

また、シート名を忘れずに "schedules" に変更してください。

https://docs.google.com/spreadsheets/d/1n_Dem-FpKK36tJ-G5VYilWDuN0VBoDaRkvR6B2ziC7M/edit?usp=sharing

その後、上部メニューの拡張機能から App Script を作成します。その時開かれる GAS のエディタおよび管理画面上で GAS の実装やデプロイやトリガーの設定などを行います。

spreadsheet

スケジュール一覧の取得 API

まずは GAS によるスケジュール一覧の取得 API を実装します。

まだスケジュールの作成 API を実装していないので、上述の「前提」で入力した列名に沿って、適当なデータを用意してください。

sample data

上記のサンプルのスプレッドシートの内容をコピーして使っても構いません。

userId 列の内容が対応する LIFF アプリの LINE のユーザー ID と一致していると、後述の Messaging API による締め切り日の近づいたスケジュールの通知機能によって、実際にその userId の LINE ユーザーに対して Bot からメッセージが届くようになりますが、この段階では適当な文字列を入力していて問題ありません。

GAS で HTTP の GET リクエストを行うための API を作成するためには、doGet(e) という関数を実装します。

doGet(e) 関数の完成形は次の通りです。

doGet
/**
 * GET API。
 * スケジュール一覧を取得する。
 */
function doGet(e) {
  const userId = e.parameter.userId
  if (userId === undefined) {
    console.error('userId が指定されていません。')
    throw 'userId を指定してください。'
  }
  const schedules = fetchSchedulesByUserId(userId)
  const body = schedules.map((row) => {
    return {
      userId: row[SCHEMA.userId.columnIndex],
      title: row[SCHEMA.title.columnIndex],
      dueDateTime: row[SCHEMA.dueDateTime.columnIndex].getTime(),
      isNotified: row[SCHEMA.isNotified.columnIndex],
    }
  })
  const response = ContentService.createTextOutput(JSON.stringify(body))
  response.setMimeType(ContentService.MimeType.JSON)
  return response
}

doGet(e) 関数の引数である e に対して、e.parameter.キー名 とすることで、クエリパラメータの指定したキー名の値を取得することができます。ここでは ?userId=<LIFF アプリのユーザー ID 名> がリクエストに含まれることを必須としています。

その後 fetchSchedulesByUserId(userId) 関数(実装の詳細はサンプルリポジトリからご確認ください)で、スプレッドシートの各行からクエリパラメータに指定された userId に対応する行(スケジュール)一覧を取得します。

それらをレスポンスボディとして整形した後に、MIME Type を JSON としてレスポンスを返して完成です。

スケジュールの作成 API

次に GAS スケジュール作成 API を実装します。

今度は GAS で HTTP の POST リクエストを行うための API を作成することになるので、doPost(e) という関数を実装します。

doPost(e) 関数の完成形は次の通りです。

doPost
/**
 * POST API。
 * スケジュールを登録する。
 */
function doPost(e) {
  const rowNumber = '= ROW()'
  const userId = e.parameter.userId
  const title = e.parameter.title
  const dueDateTime = new Date(e.parameter.dueDateTime)
  const isNotified = false
  const values = { rowNumber, userId, title, dueDateTime, isNotified }

  sheet = getSchedulesSheet()
  sheet.appendRow(Object.values(values))
  const response = ContentService.createTextOutput(JSON.stringify(values))
  response.setMimeType(ContentService.MimeType.JSON)
  return response
}

リクエストボディは e.parameter.キー名 で取得することができるので、それらを用いて定義した変数 values に対して sheet.appendRow(Object.values(values)) のようにすることで、そのデータをスプレッドシートの最下行に新しいデータとして追加することができます。

この例において rowNumber 列はスプレッドシートの ROW() 関数によって行番号を与えることにしているので、単に文字列で = ROW() を指定すれば OK です。

最後に doGet(e) と同様の方法で、作成したスケジュールリソースを MIME Type: JSON でレスポンスして完了です。

スケジュール一覧の取得、スケジュール作成 API のデプロイ

GAS エディタ画面の「デプロイ」ボタンを押下して、「新しいデプロイ」ボタンを選択し、デプロイの種類を「ウェブアプリ」として、アクセスできるユーザーは「全員」を選択してデプロイします。

gas api deploy

デプロイが完了すると、そのウェブアプリの URL が表示されます。

それが GET, POST リクエストのエンドポイント URL であり、Flutter の .envGAS_URL=<あなたの GAS URL> として設定するべきものです。

gas deployed url

締め切り日時の近づいたスケジュールの LINE Messaging API による通知

GAS 実装の最後として、LINE Messaging API による、締め切り日時が近づいたスケジュールの通知メッセージの送信機能を実装します。

次のような pushMessageToUser(userId, messageTexts) 関数を実装し、指定した ID のユーザーに、指定した複数のメッセージを Messaging API で送信します。

pushMessageToUser
/**
 * 指定した ID のユーザーに、LINE の Messaging API でメッセージを送信する。
 * @param {string} userId ユーザー ID
 * @param {string[]} messageTexts 送信するメッセージ本文一覧
 */
function pushMessageToUser(userId, messageTexts) {
  UrlFetchApp.fetch('https://api.line.me/v2/bot/message/push', {
    headers: {
      'Content-Type': 'application/json; charset=UTF-8',
      Authorization: `Bearer ${PropertiesService.getScriptProperties().getProperty(
        'CHANNEL_ACCESS_TOKEN'
      )}`,
    },
    method: 'post',
    payload: JSON.stringify({
      to: userId,
      messages: [
        { type: 'text', text: '近づいているスケジュールのお知らせ' },
        ...messageTexts.map((text) => {
          return { type: 'text', text }
        }),
      ],
    }),
  })
}

GAS の UrlFetchApp.fetch() を用いて、LINE API の /v2/bot/message/push に対して POST リクエストを行っています。

Authorization ヘッダには、ベアラートークンとして Messaging API のチャネルアクセストークンを指定します。

この例では、環境変数のように使用できる GAS のスクリプトプロパティに CHANNEL_ACCESS_TOKEN というキー名で設定している前提です。

script properties

あとはスプレッドシート一覧から、スケジュールの締め切り日時が近づいていて通知すべき該当行をユーザー ID ごとに集計して、前述の pushMessageToUser(userId, messageTexts) をユーザーごとに実行すれば良いでしょう。

通知メッセージの送信後には各行の isNotifiedtrue に更新する実装も含んでいます。

notifySchedules
/**
 * 毎時実行して、次の 1 時間の間のスケジュールを LINE Messaging API でお知らせする。
 * お知らせが済んだものは isNotified = true にする。
 *
 * 例:
 * この関数が、2022-11-01 09:20:00 に発火した場合、
 * dueDateTime が 2022-11-01 10:00:00 〜 2022-11-01 10:59:59 の間である
 * スケジュールを含むメッセージが送信される。
 * */
function notifySchedules() {
  const notificationTargetSchedules = fetchNotificationTargetSchedules()
  if (notificationTargetSchedules.length === 0) {
    console.log('近づいているスケジュールはありません。')
    return
  }

  const rowNumbers = []
  const messagesByUserId = {}
  for (const row of notificationTargetSchedules) {
    const userId = row[SCHEMA.userId.columnIndex]
    const message = `${row[SCHEMA.title.columnIndex]} (${row[
      SCHEMA.dueDateTime.columnIndex
    ].toLocaleString('ja-JP')})`
    if (userId in messagesByUserId) {
      messagesByUserId[userId].push(message)
    } else {
      messagesByUserId[userId] = [message]
    }
    rowNumbers.push(row[SCHEMA.rowNumber.columnIndex])
  }

  for (const userId in messagesByUserId) {
    console.log(`userId: ${userId}, messages: ${messagesByUserId[userId]}`)
    pushMessageToUser(userId, messagesByUserId[userId])
  }

  setIsNotified(rowNumbers)

bot messages

コメントに書いたように、たとえばこの関数が、2022-11-01 09:20:00 に発火した場合、dueDateTime が 2022-11-01 10:00:00 〜 2022-11-01 10:59:59 の間であるスケジュールを対象に LINE Messaging API を用いて通知が送信されるので、このような処理が 1 時間に 1 回走るよう GAS のトリガーを設定します。

GAS のメニューの「トリガー」の「トリガーを追加」から実行する関数に notifySchedules を、イベントのソースを「時間主導型」にして「時間ベースのタイマー」と「1 時間おき」を選択します。

gas time driven trigger

まとめ

最後に、GAS を用いて、ハンズオンで作成するスケジュール管理アプリの、スケジュール一覧取得 API、新規スケジュール作成 API、締め切り日が近づいたスケジュールの LINE Messaging API による通知の実装に関するコードをまとめて再掲します。

その他のファイルを含む実装全体は、flutter_liff_scheduler のリポジトリの gas 以下のディレクトリ・ファイルをご覧ください。

main.js
/** スケジュール一覧を保存するシート名。 */
const SCHEDULES_SHEET_NAME = 'schedules'

/** スプレッドシートのスキーマ。 */
const SCHEMA = {
  rowNumber: {
    columnName: 'rowNumber',
    columnIndex: 0,
  },
  userId: {
    columnName: 'userId',
    columnIndex: 1,
  },
  title: {
    columnName: 'title',
    columnIndex: 2,
  },
  dueDateTime: {
    columnName: 'dueDateTime',
    columnIndex: 3,
  },
  isNotified: {
    columnName: 'isNotified',
    columnIndex: 4,
  },
}

/** ヘッダ行を除く最初の有効なデータセル。 */
const firstCell = { row: 2, column: 1 }

/** スケジュール一覧が記録されているシート。 */
function getSchedulesSheet() {
  return SpreadsheetApp.getActiveSpreadsheet().getSheetByName(
    SCHEDULES_SHEET_NAME
  )
}

/**
 * 毎時実行して、次の 1 時間の間のスケジュールを LINE Messaging API でお知らせする。
 * お知らせが済んだものは isNotified = true にする。
 *
 * 例:
 * この関数が、2022-11-01 09:20:00 に発火した場合、
 * dueDateTime が 2022-11-01 10:00:00 〜 2022-11-01 10:59:59 の間である
 * スケジュールを含むメッセージが送信される。
 * */
function notifySchedules() {
  const notificationTargetSchedules = fetchNotificationTargetSchedules()
  if (notificationTargetSchedules.length === 0) {
    console.log('近づいているスケジュールはありません。')
    return
  }

  const rowNumbers = []
  const messagesByUserId = {}
  for (const row of notificationTargetSchedules) {
    const userId = row[SCHEMA.userId.columnIndex]
    const message = `${row[SCHEMA.title.columnIndex]} (${row[
      SCHEMA.dueDateTime.columnIndex
    ].toLocaleString('ja-JP')})`
    if (userId in messagesByUserId) {
      messagesByUserId[userId].push(message)
    } else {
      messagesByUserId[userId] = [message]
    }
    rowNumbers.push(row[SCHEMA.rowNumber.columnIndex])
  }

  for (const userId in messagesByUserId) {
    console.log(`userId: ${userId}, messages: ${messagesByUserId[userId]}`)
    pushMessageToUser(userId, messagesByUserId[userId])
  }

  setIsNotified(rowNumbers)
}

/** スプレッドシートからスケジュールを全件取得して、dueDateTime の降順にして返す。 */
function fetchAllSchedulesFromSpreadSheet() {
  sheet = getSchedulesSheet()
  const range = sheet.getRange(
    firstCell.row,
    firstCell.column,
    sheet.getLastRow() - (firstCell.row - 1), // ヘッダ行を除くため
    sheet.getLastColumn()
  )
  return range
    .getValues()
    .sort((a, b) =>
      a[SCHEMA.dueDateTime.columnIndex] > b[SCHEMA.dueDateTime.columnIndex]
        ? -1
        : 1
    )
}

/**
 * スケジュール一覧から指定したユーザー ID のものだけをフィルタして返す。
 * @param {string} userId
 * @return {Schedule[]} 指定したユーザー ID のスケジュール一覧
 */
function fetchSchedulesByUserId(userId) {
  const schedules = fetchAllSchedulesFromSpreadSheet()
  return schedules.filter((row) => row[SCHEMA.userId.columnIndex] === userId)
}

/**
 * スケジュール一覧から通知のターゲットとなるものだけをフィルタして返す。
 * @param {Date} minDueDateTime スケジュールの対象を絞り込む dueDateTime の下限値。
 * @param {Date} maxDueDateTime スケジュールの対象を絞り込む dueDateTime の下限値。
 * @return {Schedule[]} 通知の対象となるスケジュール一覧。
 */
function fetchNotificationTargetSchedules() {
  const now = new Date()
  const schedules = fetchAllSchedulesFromSpreadSheet()
  const minDueDateTime = new Date(
    now.getFullYear(),
    now.getMonth(),
    now.getDate(),
    now.getHours() + 1
  )
  const maxDueDateTime = new Date(
    now.getFullYear(),
    now.getMonth(),
    now.getDate(),
    now.getHours() + 2
  )
  return schedules.filter(
    (row) =>
      row[SCHEMA.dueDateTime.columnIndex] >= minDueDateTime &&
      row[SCHEMA.dueDateTime.columnIndex] < maxDueDateTime &&
      !row[SCHEMA.isNotified.columnIndex]
  )
}

/**
 * 指定した ID のユーザーに、LINE の Messaging API でメッセージを送信する。
 * @param {string} userId ユーザー ID
 * @param {string[]} messageTexts 送信するメッセージ本文一覧
 */
function pushMessageToUser(userId, messageTexts) {
  UrlFetchApp.fetch('https://api.line.me/v2/bot/message/push', {
    headers: {
      'Content-Type': 'application/json; charset=UTF-8',
      Authorization: `Bearer ${PropertiesService.getScriptProperties().getProperty(
        'CHANNEL_ACCESS_TOKEN'
      )}`,
    },
    method: 'post',
    payload: JSON.stringify({
      to: userId,
      messages: [
        { type: 'text', text: '近づいているスケジュールのお知らせ' },
        ...messageTexts.map((text) => {
          return { type: 'text', text }
        }),
      ],
    }),
  })
}

/**
 * 指定した行番号の isNotified フラグを true に更新する。
 * @param {number[]} rowNumbers
 */
function setIsNotified(rowNumbers) {
  for (const rowNumber of rowNumbers) {
    sheet = getSchedulesSheet()
    sheet.getRange(rowNumber, SCHEMA.isNotified.columnIndex + 1).setValue(true)
  }
}

/**
 * GET API。
 * スケジュール一覧を取得する。
 */
function doGet(e) {
  const userId = e.parameter.userId
  if (userId === undefined) {
    console.error('userId が指定されていません。')
    throw 'userId を指定してください。'
  }
  const schedules = fetchSchedulesByUserId(userId)
  const body = schedules.map((row) => {
    return {
      userId: row[SCHEMA.userId.columnIndex],
      title: row[SCHEMA.title.columnIndex],
      dueDateTime: row[SCHEMA.dueDateTime.columnIndex].getTime(),
      isNotified: row[SCHEMA.isNotified.columnIndex],
    }
  })
  const response = ContentService.createTextOutput(JSON.stringify(body))
  response.setMimeType(ContentService.MimeType.JSON)
  return response
}

/**
 * POST API。
 * スケジュールを登録する。
 */
function doPost(e) {
  const rowNumber = '= ROW()'
  const userId = e.parameter.userId
  const title = e.parameter.title
  const dueDateTime = new Date(e.parameter.dueDateTime)
  const isNotified = false
  const values = { rowNumber, userId, title, dueDateTime, isNotified }

  sheet = getSchedulesSheet()
  sheet.appendRow(Object.values(values))
  const response = ContentService.createTextOutput(JSON.stringify(values))
  response.setMimeType(ContentService.MimeType.JSON)
  return response
}