GASでGoogleCalendarに登録した予定を取得してX APIでツイートする

自分用メモ書き。適宜追記していきます。指摘等あればコメントください。

# やりたいこと
* GASでGoogleCalendarに登録した予定を取得してX APIでツイートする
    * [カレンダー編集時に予定の「説明」をツイートする](#カレンダー編集時に予定の説明をツイートする)
    * [毎日決まった時間帯に次の日の予定一覧を取得し、予定の開始時刻にツイートする](#毎日決まった時間帯に次の日の予定一覧を取得し予定の開始時刻にツイートする)

# 準備

## GASプロジェクトの準備
1. GASのプロジェクトを作成する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/3710f166-bffb-ad48-f938-069ef36d57b5.png)
1. プロジェクトの設定を開き、スクリプトIDをメモする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/f30eaad0-4ec8-7614-0970-bf0e333d2e9e.png)

## X(旧Twitter)開発者アカウントの準備

1. [Developer Platform](https://developer.x.com/en)へアクセス
Freeの`Get Start`をクリック
1. 使用したいTwitterアカウントでログイン
1. 以下のような画面が出るので`Sign up for Free Account`をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/86fed4b8-e322-6017-e21b-589549ff83d2.png)
1. アプリケーションの名前、説明、ウェブサイトなどを設定し、`Submit`をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/7c23d53f-9f65-25e3-ff0e-154b40116775.png)

## ClientID/ClientSecretの取得

1. Project&Appsから作成したプロジェクトを選択し、画面上部の`Settings`をクリック
`User authentication settings`の`Set up`をクリック
(※添付の画像は設定済みのため`Edit`になっています)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/658b8bd3-4f0e-3b1a-4a3b-6cf0fee018fd.png)
1. 以下のような画面が出るので次のように入力
    * App permissionsを`Read and write`に設定
    * Type of Appを`Web App, Automated App or Bot`に設定
    * App infoのCallback URI/Redirect URLとWebsite URLを下記のように設定
      `MY_SCRIPT_ID`は[GASプロジェクトの準備](#gasプロジェクトの準備)でメモしたスクリプトIDに置き換えてください
        ```:Callback URI/Redirect URL
        https://script.google.com/macros/d/MY_SCRIPT_ID/usercallback
        ```
        ```:Website URL
        https://script.google.com/macros/d/MY_SCRIPT_ID/
        ```
        ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/4abab58b-0d7f-3f97-f8ed-fd1772b66c5d.png)
1. `Save`をクリックするとClient IDとClient Secretが表示されるのでメモしておく
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/bb0fd949-e75d-f8ec-c2bd-cbaed068a39b.png)

<!--
1. APIキーとアクセストークンの取得
画面上部のKeys and tokensをクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/4231befc-3525-af41-b703-80c7280e45f7.png)
    * APIキー
API Key and SecretのRegenerateをクリックし、発行されたAPIキーとAPIキーシークレットをメモしておく
    * アクセストークン
Access Token and SecretのGenerateをクリックし、発行されたアクセストークンとアクセストークンシークレットをメモしておく
-->

## GASプロジェクトのスクリプトプロパティの設定

1. GASのプロジェクトの`設定`を開き、`スクリプトプロパティを編集`→`スクリプトプロパティを追加`をクリック
1. プロパティに`MY_CLIENT_ID`を追加し、値に[ClientID/ClientSecretの取得](#clientidclientsecretの取得)で取得したX(旧Twitter)APIのClient IDをペーストする
1. プロパティに`MY_CLIENT_SECRET`を追加し、値に[ClientID/ClientSecretの取得](#clientidclientsecretの取得)で取得したX(旧Twitter)APIのClient Secretをペーストする
1. `スクリプトプロパティを保存`をクリックして保存する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/22030df3-7996-d511-a782-84971691785d.png)

## カレンダーIDの取得

こちらの記事を参考に使用したいカレンダーのカレンダーIDを取得する

https://qiita.com/mikeneko_t98/items/60e264941492d0b44fe5

## 設定ファイルの作成

基本変更することがない値を別ファイルに分けて定義します。
スクリプトプロパティに保存するでも良いかも。スプレッドシートでも良いかも。
どこに保存するのが良いかは要検討。
文言はスプレッドシート、URL等はスクリプトプロパティなのかなぁ…。
定型文はお好きな文言に変更してください。
`MY_CALENDAR_ID`は[カレンダーIDの取得](#カレンダーidの取得)で取得したIDに置き換えてください。
```typescript:Const.gs
/**
 * 定型文
 * 改行を入れたい場合は\nと入力してください
 */
const remindTommorowEvent = "明日の予定\n"
const startEventAnnounce = "まもなく開始\n"

/**
 * カレンダーID
 * eventCalendarID:カレンダー登録時と前日と当日開始時刻にツイート。
 */
const eventCalendarID = "MY_CALENDAR_ID"

// 以下は変更不要 ////////////////////////////////////////////////////////////////////////////////////////////////

/**
 * OAuth2.0用ライブラリgithub
 * https://github.com/googleworkspace/apps-script-oauth2
 * サンプル
 * https://github.com/googleworkspace/apps-script-oauth2/blob/main/samples/Twitter.gs
 */
const authorizationBaseUrl = 'https://twitter.com/i/oauth2/authorize';
const tokenUrl = 'https://api.twitter.com/2/oauth2/token';

/**
 * ツイート投稿用エンドポイント
 */
const postUrl = "https://api.twitter.com/2/tweets";

/**
 * 日時計算用
 */
const one_minute = 60 * 1000
const one_hour = 60 * one_minute
const one_day = 24 * one_hour
```

# 認証処理の実装

## 使用ライブラリ

OAuth2.0で認証を行う。
apps-script-oauth2を使用する。

https://github.com/googleworkspace/apps-script-oauth2

## 手順

1. ライブラリを導入する
GASの画面で`エディタ`→`ライブラリ`→`ライブラリを追加`をクリック
`スクリプトID`に以下を入力して`検索`をクリック
    ``` :スクリプトID
    1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF
    ```
    最新のバージョンを選択して`追加`をクリック
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/dd76975b-497b-7902-e317-e27fcfd77f3b.png)
1. [使用ライブラリ](#使用ライブラリ)に記載のリンクのGitHubのサンプルを参考に以下のコードを作成
    ```javascript:Auth.gs
    /**
     * アクセスが許可されている場合はアクセストークンを返却
     * アクセスが未許可の場合は認証用URLを表示
     */
    const getXAPIToken = () => {
      const service = getOAuthService();
      if (!service.hasAccess()) {
        const authorizationUrl = service.getAuthorizationUrl();
        Logger.log('次のURLを開き、再度スクリプトを実行してください: %s', authorizationUrl);
      } else {
        const accessToken = service.getAccessToken();
        return accessToken;
      }
    }
    /**
     * OAuth2Serviceオブジェクトの作成
     */
    const getOAuthService = () => {
      const serviceName = "xapi";
      const properties = PropertiesService.getScriptProperties();
      const clientId = properties.getProperty("MY_CLIENT_ID");
      const clientSecret = properties.getProperty("MY_CLIENT_SECRET");
      const scope = "tweet.read tweet.write users.read offline.access"
      return OAuth2.createService(serviceName)
        .setAuthorizationBaseUrl(authorizationBaseUrl)
        .setTokenUrl(tokenUrl)
        .setClientId(clientId)
        .setClientSecret(clientSecret)
        .setCallbackFunction('authCallback')
        .setPropertyStore(PropertiesService.getUserProperties())
        .setScope(scope)
        .generateCodeVerifier()
        .setTokenHeaders({
          'Authorization': 'Basic ' + Utilities.base64Encode(clientId + ':' + clientSecret),
          'Content-Type': 'application/x-www-form-urlencoded'
        })
    }
    
    /** 
     * 認証終了時に呼び出される関数
    */
    const authCallback = (request) => {
      var service = getOAuthService();
      var authorized = service.handleCallback(request);
      if (authorized) {
        return HtmlService.createHtmlOutput('認証が完了しました。このウィンドウを閉じてください。');
      } else {
        return HtmlService.createHtmlOutput('認証に失敗しました。');
      }
    }
    ```
    <font color="lightgray">ここでscopeにoffline.accessを入れ忘れてリフレッシュトークンが発行されず、アクセストークンが期限切れになっては認証用URLにアクセスして許可して…を繰り返していました。
    なんで再取得してくれないの～？！と思ったら抜けてた。(アホすぎる)
    同じ悩みを持つ人に届け…。</font>
1. getXAPITokenを実行すると「次のURLを開き、再度スクリプトを実行してください:…」と出るので、表示されたURLにアクセス
1. 認証を求められるので`Authorize.app`をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/3f47c3f8-f246-68dd-722b-57d1ac69d217.png)
1. `認証が完了しました。このウィンドウを閉じてください。`と表示されたらOAuth2を使った認証は完了です◎

# ツイート投稿処理の実装

## ツイートの文章整形

ツイートする文章からHTMLタグを除去する処理を実装します。

```typescript:TextUtil.gs
/**
 * 文章からHTMLタグを除去します
 * 
 * @param {string} text - 対象の文章
 * @return {string} - HTMLタグを除去した文章
 */
const removeHTMLTag = (text) => {
  try {
    text = text.replace(/<br>/g, "\n");
    return text.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, '');
  } catch (e) {
    Logger.log("Error at removeHTMLTag:")
    Logger.log(e)
  }
}
```

## X(旧Twitter)APIでツイートを投稿

ツイート内容を引数に受け取って、[認証処理の実装](#認証処理の実装)で取得したアクセストークンを使用してツイートを行う処理を実装します。

```typescript:Tweet.gs
/**
 * postリクエストを行う関数
 * @param {string} url - リクエストを行うURL
 * @param header - リクエストヘッダ
 * @param message - ポストを行うメッセージ
 */
const fetchPost = (url, headers, message) => {
  const options = {
    method: "post",
    headers,
    muteHttpExceptions: true,
    payload: JSON.stringify(message),
    contentType: "application/json"
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    const result = JSON.parse(response.getContentText());
    Logger.log(JSON.stringify(result, null, 2));
  } catch (e) {
    Logger.log("Error:")
    Logger.log(e)
  }
}

/**
 * ツイートをポストする
 * @param {string} tweetText - ツイート内容
 */
const postTweet = (tweetText) => {
  try {
    const message = {
      text: removeHTMLTag(tweetText)
    }
    const headers = {
      Authorization: 'Bearer ' + getXAPIToken()
    }
    fetchPost(postUrl, headers, message);
  } catch (e) {
    Logger.log("Error at postTweet:")
    Logger.log(e)
  }
}
```

# GoogleカレンダーAPIの有効化

Google Apps ScriptからGoogleカレンダーを読み書きするためにGoogle Calendar APIを有効化します。
1. `サービスを追加`をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/ab8e9572-0b13-4dc2-1a07-55bb7bc552a8.png)
1. `Google Calendar API`を選択し、`追加`をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/612fa92e-4d69-d16c-f9ee-4d69e5234a9b.png)


# カレンダー編集時に予定の「説明」をツイートする

## 予定編集時に実行する関数の用意

カレンダーの変更を取得のあったイベントを取得し、非公開の予定でなければ説明欄に記入されている内容を`postTweet`でツイートします

```typescript:Calendar.gs
/**
 * カレンダー新規登録・編集時に非公開の予定でなければポストする
 * トリガー「カレンダーから」で設定
 */
const onCalendarEventAdded = (e) => {
  try {
    const calendarId = e.calendarId
    Logger.log("onCalendarEventAdded calendarId:%s", calendarId)
    const properties = PropertiesService.getScriptProperties();
    // スクリプトプロパティにSYNC_TOKEN_CALENDAR_ID=XXXXというカレンダーIDに紐づいた名前でnextSyncTokenが保存される
    const syncTokenProperty = "SYNC_TOKEN_CALENDAR_ID=" + calendarId
    const nextSyncToken = properties.getProperty(syncTokenProperty);
    const optionalArgs = (!nextSyncToken) ? {} : { syncToken: nextSyncToken }
    // 変更のあったイベントを取得
    const events = Calendar.Events.list(calendarId, optionalArgs);
    Logger.log("onCalendarEventAdded events.items.length:%d", events.items.length)
    for (event of events.items) {
      Logger.log("onCalendarEventAdded event:%s", JSON.stringify(event, null, 2))
      // カレンダー新規登録/編集時にstatus=confirmedになる
      if (event.status == 'confirmed') {
        // 非公開の予定の場合はツイートしない
        if (event.visibility == "private") continue;
        // カレンダーに登録されている予定の「説明」をツイートする
        if (event.description) postTweet(event.description);
      }
    }
    // スクリプトプロパティのnextSyncTokenを更新する
    properties.setProperty(syncTokenProperty, events["nextSyncToken"]);
  } catch (e) {
    Logger.log("Error at onCalendarEventAdded:")
    Logger.log(e)
  }
}
```
    
## トリガーの作成

1. GASで`トリガー`→`トリガーを作成`をクリック
1. イベントのソースを`カレンダーから`に設定する

    :::note info
    イベントのソースを`カレンダーから`に設定すると、指定したカレンダーが編集された際に自動で指定した関数を実行してくれる。

1. 実行する関数に[予定編集時に実行する関数の用意](#予定編集時に実行する関数の用意)で作成した関数`onCalendarEventAdded`を指定する。
1. カレンダーのオーナーのメールアドレスに予定の変更を検知したいカレンダーのカレンダーIDを入力する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/afebd5d8-404a-215c-d8cc-794b7bb38222.png)

# 毎日決まった時間帯に次の日の予定一覧を取得し、予定の開始時刻にツイートする

:::note info
トリガーでイベントのソースを`時間主導型`にすると、日付ベースや時間ベースのトリガーをセットすることができる。
:::

## 処理の流れ

1. 日付ベースのトリガーで、毎日0～1時の間に「当日の20:00に指定の関数を実行する」トリガーをセットする
1. 20:00になったら実行される関数で、翌日のイベントを取得する
1. 取得したイベントの「説明」をツイートする
1. 取得したイベントの「開始時刻」を取得し、「開始時刻」に「説明」をツイートするトリガーをセットする

## トリガーの削除処理の実装

実行済みのトリガーを削除するため、指定されたトリガーIDのトリガーを削除する関数を用意する

```typescript:Trigger.gs
const deleteTrigger = (triggerId) => {
  try {
    const triggers = ScriptApp.getProjectTriggers();
    for (trigger of triggers) {
      if (trigger.getUniqueId() === triggerId) {
        ScriptApp.deleteTrigger(trigger);
      }
    }
  } catch (e) {
    Logger.log("Error at deleteTrigger:")
    Logger.log(e)
  }
}
```

## 指定された時刻に指定された内容を呟くための実装

以下の2つの関数を実装する
 - postByTrigger
     - セットされた時間になったら実行される
     - スクリプトプロパティにトリガーIDに紐づいて保存されているツイート内容を取得する
     - 取得したツイート内容を投稿する
     - スクリプトプロパティからツイート内容を削除する
     - 実行済みのトリガーを削除する
 - setTriggerPostDesignatedTime
     - 時間とツイート内容を受け取り、指定された時間にpostByTrigger関数を実行するトリガーをセットする
     - セットしたトリガーのIDを取得し、IDに紐づけてスクリプトプロパティにツイート内容を保存する

```typescript:Tweet.gs
/**
 * セットされたトリガーの時刻になったらトリガーにセットされたツイート内容をポストする関数
 */
const postByTrigger = (e) => {
  try {
    const triggerId = e.triggerUid;
    const property = PropertiesService.getScriptProperties();
    const body = property.getProperty(triggerId);
    postTweet(body);
    // 後処理
    property.deleteProperty(triggerId);
    const triggers = ScriptApp.getProjectTriggers();
    for (let i = 0; i < triggers.length; i++) {
      if (triggers[i].getUniqueId() === triggerId) {
        ScriptApp.deleteTrigger(triggers[i]);
      }
    }
  } catch (e) {
    Logger.log("Error at postByTrigger:")
    Logger.log(e)
  }
}
/**
 * 指定された時刻に指定された内容を呟くためのトリガーをセットする
 */
const setTriggerPostDesignatedTime = (designatedTime, tweetDescription) => {
  try {
    const trigger = ScriptApp.newTrigger('postByTrigger').timeBased().at(designatedTime).create();
    const triggerId = trigger.getUniqueId();
    const properties = PropertiesService.getScriptProperties();
    properties.setProperty(triggerId, tweetDescription);
  } catch (e) {
    Logger.log("Error at setTriggerPostDesignatedTime:")
    Logger.log(e)
  }
}
```

## 任意のカレンダーから任意の期間の予定を取得する関数の実装

指定されたカレンダーの指定された区間の予定を取得して返す関数を実装する。

```typescript:Calendar.gs
/**
 * 任意のカレンダーIDのカレンダーから指定区間の予定を取得して返す
 * @param {string} calendarId - カレンダーID(空文字が渡された場合はデフォルトのカレンダー)
 * @param {date} startTime - 検索する期間の開始日時
 * @param {date} endTime - 検索する期間の終了日時
 * @return {event[]} - イベントのリスト
 */
const getCalendarEvents = (calendarId, startTime, endTime) => {
  try {
    if (!calendarId) {
      calendarId = 'primary';
    }
    const calendar = CalendarApp.getCalendarById(calendarId);
    const events = calendar.getEvents(startTime, endTime);
    return events
  } catch (e) {
    Logger.log("Error at getCalendarEvents:")
    Logger.log(e)
  }
}
```

## 翌日の予定の取得＆ツイートと予定開始時刻に呟くトリガーをセットする関数の実装

以下のような処理を行う
1. 実行済みトリガーの削除(本関数がトリガーによって実行されるため)
1. [任意のカレンダーから任意の期間の予定を取得する関数の実装](#任意のカレンダーから任意の期間の予定を取得する関数の実装)で作成した関数を用いて次の日の予定を取得
1. 取得した予定の「説明」に文言を付加してツイート
1. イベント開始時刻を取得
1. 取得した予定の「説明」に文言を付加してイベント開始時刻にツイートしたい内容を作成
1. [指定された時刻に指定された内容を呟くための実装](#指定された時刻に指定された内容を呟くための実装)で作成した関数を用いてイベント開始時刻にツイートを行うトリガーをセット

```typescript:Tweet.gs
/**
 * イベントカレンダーに登録された明日の予定をsendTweetに渡し、予定開始時刻に走るトリガーを作成する
 */
const postTommorowEvent = (e) => {
  try {
    const triggerId = e.triggerUid;
    deleteTrigger(triggerId);
    // 現在の日時を取得
    const now = new Date();
    // 現在の日付を基に翌日の 0:00 を計算
    const nextDayStart = new Date(now);
    nextDayStart.setDate(now.getDate() + 1);
    nextDayStart.setHours(0, 0, 0, 0); // 時間、分、秒、ミリ秒を 0 に設定
    // 翌日の 23:59 を計算
    const nextDayEnd = new Date(nextDayStart);
    nextDayEnd.setHours(23, 59, 59, 999); // 時間、分、秒、ミリ秒を 23:59:59.999 に設定
    const events = getCalendarEvents(eventCalendarID, nextDayStart, nextDayEnd)
    if (!events.length) return
    for (event of events) {
      const description = event.getDescription();
      if (!description) continue;
      const tommorowEventPost = remindTommorowEvent + `\n` + description;
      postTweet(tommorowEventPost);
      // イベント開始時刻に呟くトリガーをセット
      const eventStartTime = event.getStartTime();
      const startEventPost = startEventAnnounce + `\n` + description;
      setTriggerPostDesignatedTime(eventStartTime, startEventPost);
    }
  } catch (e) {
    Logger.log("Error at postTommorowEvent:")
    Logger.log(e)
  }
}
```

## 日付ベースのトリガーにより実行される関数の実装

以下の2つの関数を実装する。
- setTodaysTrigger
    - 日付ベースのトリガーにより、本関数を毎日0~1時の時間帯で実行する。
    - 20:00になったら`postTommorowEvent`を実行するトリガーをセットする。
- setTrigger
    - 指定された日時に指定された関数を実行するトリガーを作成する。

```typescript:Trigger.gs
/**
 * 本日実行するトリガーを作成する
 * この関数を毎日0-1時の間に呼ぶようトリガーの設定画面から設定する
 */
const setTodaysTrigger = () => {
  try {
    setTrigger('postTommorowEvent', 0, 20, 00);
  } catch (e) {
    Logger.log("Error at setTodaysTrigger:")
    Logger.log(e)
  }
}

const setTrigger = (func, day, hour, min) => {
  try {
    //Dateオブジェクトで実行した時間を取得
    const time = new Date();
    time.setHours(hour);
    time.setMinutes(min);
    ScriptApp.newTrigger(func).timeBased().at(time).create();
  } catch (e) {
    Logger.log("Error at setTrigger:")
    Logger.log(e)
  }
}
```

## 日付ベースのトリガーをセットする

[日付ベースのトリガーにより実行される関数の実装](#日付ベースのトリガーにより実行される関数の実装)で作成した関数を毎日0～1時の間で実行されるようトリガーを以下のようにセットする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325529/cac7211d-1088-e094-17ed-d20eabfad372.png)

# まとめ

Googleカレンダーに登録された予定から自動でツイートを行えるようにしました。
具体的には行ったことは以下です。
- カレンダー編集時に予定の「説明」を呟く
- 毎日20:00に翌日0:00-23:59の予定一覧を取得し、予定の「説明」に文言を付加して呟く
- 毎日20:00に翌日0:00-23:59の予定一覧を取得し、予定の開始時刻に予定の「説明」に文言を付加して呟くトリガーをセットする

# 参考にさせていただいたリンク

https://qiita.com/yuzinet/items/ae4b9ca2b5cd989de435

https://qiita.com/kakakaori830/items/19c7c8fb4a9f6abc2ac5