GASで自動RT・自動ツイートするbotを作った覚え書き

こんにちは、[しゅがーはぁと応援bot](https://twitter.com/sugarheart26bot)の技術担当の人です。
"しゅがーはぁと"というのはアイドルマスターシンデレラガールズに登場するキャラクター、"佐藤心"の愛称です。
しゅがーはぁと応援botは、その佐藤心を応援するための非公式botです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621290/7c3a8dcb-f370-7226-37f4-f5d9ab125adb.png)
[JavaでTwitter4Jを用いて定期的に自動RTと自動ツイートを行うプログラムを組んでいました](https://qiita.com/sugarheart26bot/items/b0eab5da4efb26f45169 "Twitter4Jで自動RT・自動ツイートするbotを作った覚え書き")が、先日そのbotをGASへ移行しました。
（定期的にセルフRTする機能だけ増やしました）

# GAS移行の理由

主な理由は**トリガーにより定期的に実行してくれるため**です。
以前はbot起動してPCも起動しっぱなしにしていましたが、コストも手間もかかっていました。とはいえ簡単なbotのためにサーバ借りるのも……と思っていたところ、たまたまGASのことをを知り、こちらに移行することにしました。
また以下の利点もあるため、たとえ無料のレンタルサーバを見つけたとしてもGASにしていたと思います。

・メンテナンスが容易→設定を全てgoogleスプレッドに上げられるので、例えば出先で何かあってもスマホからどうにかできる
・画像フォルダとしてgoogleドライブを使用できる→今までbot運営担当から画像貰ってPCに取り込んでいたのが、「ここにぶち込んどいて」で済むように

ちなみに以下の欠点もありますが、運用等で回避できるので目をつぶりました。

・時間主導型のトリガーが、特定の時間に指定するか、繰り返しスクリプト実行するなら大まかな時間設定しかできない→スクリプトでトリガー設定できるので問題なし
・稼働時間などに制限あり（一回のスクリプト実行時間は6分まで、累計90分/日まで）→そんなに使わない（後述）
・トリガーの「特定の時刻」でも秒単位の指定はできない（ランダムな秒で実行される）→そこまで細かいことbot運営から要求されていないので問題なし

# 構成

以下のようなスプレッドシートで設定値の管理のほか、bot稼働の制御も行っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621290/a2304191-9f56-9cd2-783b-dd1ec7ed8523.png)
トリガー自体は一定時間ごとに実行・登録されていますが、設定値のチェックによってスクリプトの実行を制御しています。なので不具合があったらチェックを外せば自動RT・自動ツイートが止まるようになっています。
停止が容易なことと、bot運営担当がスクリプトエディタを開くことなく制御できることから、このような形にしています。
ブラックリストのシートには除外アカウント・除外ワードを設定しています。

スクリプトは上記のスプレッドシートに紐づけて作成しています。
権限まわりのこととかを考えると、独立したスクリプトのファイルで作った方が良かったかも……
（参考：[Google Apps Script で Spreadsheet にアクセスする方法まとめ](https://qiita.com/negito6/items/c64a7a8589faaffcfdcf)）


# 事前準備

GASでTwitter APIを叩くには以下の2つの作業が必要です。
1.Twitter Developer申請
2.GASで連携認証を行う

Twitter Developer申請は前回やったので移行に関しては行っていません。
前の記事でも書いていませんでしたが割愛します。

GASでの連携認証についてですが、Twitter Developer側にコールバックURLを入力し、GASから認証のスクリプトを実行する必要があります。

## コールバックURLの設定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621290/5fb27d4e-c016-a175-18af-bff622d479b7.png)

ここに以下のURLを入力したのち、画面下部のsaveを押下して保存します。
https://script.google.com/macros/d/[スクリプトID]/usercallback

[スクリプトID]の部分には、GASのIDを入れてください。
IDはGASの画面左側の歯車アイコン「プロジェクトの設定」から確認できます。なんならURLにも以下のような感じで入ってるので、そこからコピってきてもいいです。
https://script.google.com/home/projects/[スクリプトID]

## TwitterWebServiceライブラリの登録

認証やbot本体のコードには[TwitterWebServiceライブラリ](https://gist.github.com/M-Igashi/750ab08718687d11bff6322b8d6f5d90)を使用します。これ使うと認証まわりのを書くのがめちゃくちゃ楽なので。
ということでライブラリの登録を行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621290/2f0020eb-59ba-70a0-83c1-5cf051e70f6b.png)

画面左側「ライブラリ」の右側の＋ボタンを押すとプロジェクトキーの入力を求められますので、以下のIDを入力してください。（IDは先ほど貼ったライブラリのリンク先、githubのページのコメントに記載のものです）
1rgo8rXsxi1DxI_5Xgo_t3irTw1Y5cxl2mGSkbozKsSXf2E_KBBPC3xTF

設定等はデフォルトで大丈夫ですが、バージョンが現行バージョンの2であることを確認してください。

## 連携認証の実行

まず以下のコードを丸コピします。3,4行目のAPIキーはTwitter Developerから生成したものを書いてください。

```javascript 
//認証用インスタンスの生成
var twitter = TwitterWebService.getInstance(
  'xxxxxx',//API Key
  'xxxxxxxxxxxx'//API secret key
);

//アプリを連携認証する
function authorize() {
  twitter.authorize();
}
 
//認証を解除する
function reset() {
  twitter.reset();
}
 
//認証後のコールバック
function authCallback(request) {
  return twitter.authCallback(request);
}
```

丸コピして保存したらauthorizeを実行します。
成功するとログになんかURLが表示されますので、そちらを開いて認証し、白画面に「Success」とだけ書かれた画面が表示されればOKです。
（失敗する場合はkeyの文字列やコールバックURLを再確認してみてください）

# 本体のスクリプトの作成

あとは前回Javaで書いたようなものをスクリプトで書いていきます。
JavaScript書きなれてないのでループの書き方とかが石器時代のものだったりしますが、佐藤心のbotなので許してください（？）

## Twitter APIを呼ぶ関数の作成

スクリプトで呼んでいるTwitter APIは、ツイート検索、ツイートのステータス取得、RT、いいね、RT解除、画像投稿、ツイート投稿の7つです。
いずれもTwitterWebServiceを介してAPIを呼んでいます。

要求や応答のパラメータや構造については公式ドキュメントを見たほうが早いかも
[Twitter API v1.1 公式ドキュメント（英語）](https://developer.twitter.com/en/docs/twitter-api/v1) 
[↑の日本語訳版](http://westplain.sakuraweb.com/translate/twitter/Documentation/REST-APIs/Public-API/REST-APIs.cgi)


### ツイート検索

```javascript 
//ツイートを検索する
function searchTweets(searchWord, rtNum, sinceId) {
  var service = twitter.getService();
  var json = service.fetch("https://api.twitter.com/1.1/search/tweets.json?"
    +"q="+encodeURIComponent(searchWord)+"&count="+rtNum+"&result_type=recent&since_id="+sinceId);
  var result = JSON.parse(json);
  return result.statuses;　//データがstatusesの中に入っているので取り出しておく
}
```

検索ワードや取得件数、sinceIdはスプレッドシートから取得してきた値をそのまま入れています。
検索ワードは`encodeURIComponent(URL)`でエンコードしないと半角スペースや記号などが含まれている場合にエラーになるので入れています。（余談ですがこの時出てくるエラーがなぜか401（認証系エラー）だったのでパニクりました）
sinceIdは「このツイートIDより新しいものしか拾いませんよ」というものです。


### ツイートのステータス取得

```javascript 
// ツイートのステータスを取得する
function getTweetStatus (id) {
  var service  = twitter.getService();
  var response = service.fetch('https://api.twitter.com/1.1/statuses/lookup.json?id=' + id);
  var result = JSON.parse(response)
  return result[0];
}
```

引数のIDはツイート検索で取得したツイートのtweet.id_strです。
戻り値のステータスはツイートが他の人へのリプライでないか、RT・いいね済でないか等、様々なチェックに使用します。

### RT、RT解除、いいね

どれも似通ったAPIなのでまとめて紹介。

```javascript 
// RTする
function postRetweet (id) {
  var service  = twitter.getService();
  var response = service.fetch('https://api.twitter.com/1.1/statuses/retweet/' + id +'.json', {
    method: 'post'
  });
}

// いいねする
function postFavorite (id) {
  var service  = twitter.getService();
  var response = service.fetch('https://api.twitter.com/1.1/favorites/create.json', {
    method: 'post',
    payload: { id: id }
  });
}

// RTを解除する
function postUnRetweet (id) {
  var service  = twitter.getService();
  var response = service.fetch('https://api.twitter.com/1.1/statuses/unretweet/' + id +'.json', {
    method: 'post'
  });
}
```

引数のIDはいずれもツイート検索で取得したツイートのtweet.id_strです。
RTはURLにID埋め込む必要がありますが、いいねはIDをbodyのパラメータとして渡します。~~なんで違うのかは知らん~~


いずれもRT・いいね先のツイートが帰ってくるらしいですが、既にツイート検索やパラメータ取得やってて不要なので戻してないです。

### 画像付きツイート投稿

画像付きツイートを投稿する際は、一旦こちらのAPIを投げます。応答内のメディアIDが必要になります。

```javascript
// 画像を投稿する
function postMedia(fileBase64) {
  var service = twitter.getService();
  var json = service.fetch('https://upload.twitter.com/1.1/media/upload.json', {
    method: 'post',
    payload: {
      media_data : fileBase64
    }
  });
  
  return JSON.parse(json).media_id_string;
}
```

引数はメディアのデータで、`Utilities.base64Encode(data)`で変換したバイトデータです。（どうやって変換・取得してるかとかは後述します）

んで戻り値のIDを配列（mediaIds）にぶち込んで……


```javascript
// 画像付きツイートする。
function postTweetWithMedia(text, mediaIds) {
  var service = twitter.getService();
  var json = service.fetch('https://api.twitter.com/1.1/statuses/update.json', {
    method: 'post',
    payload: {
      status: text,
      media_ids : mediaIds.join(',')
    }
  });

  console.log("次の内容でツイートしました:" + text + "(画像ID:" + mediaIds);
}
```

画像付きツイートしかしない予定なのでそんな前提で作っています。試してないので分からないんですが、media_idsが空だったら画像無しツイートになるんですかね？

## トリガーの削除・登録

先述した通り、トリガーは特定の時間に一回起動するものか、一定時間ごとに起動するものしかありません。
時間ごとに起動するものも、例えば分刻みだと1,5,10,15,30分しかなく、それ以外の数値にはできません。
あとは特定の時間帯のみ動く、みたいなのも作ることができません。

というわけでスクリプトで毎回トリガー削除・登録を行ってしまいます。

### トリガーの削除

```javascript
// 指定した関数名のトリガーを全削除する
function deleteTriggers(funcName) {
  var triggers = ScriptApp.getProjectTriggers();
  for(var i = 0, len = triggers.length; i < len; i++){
    if(triggers[i].getHandlerFunction() == funcName){
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
}
```

### トリガーの登録

単純に指定した時刻のトリガーを登録するものならこれでいいです。
（手動で停止・起動したときのため、無条件で翌日の日付に設定する、とはしていないです）

```javascript
// 指定した関数名・時間のトリガーを作成する。
function createTrigger(funcName, h, m) {
  var today = new Date();
  var nextDate = new Date();
  nextDate.setHours(h);
  nextDate.setMinutes(m);
  if (nextDate <= today) { //次の設定時刻が過去だったら1日後の日付に設定する
    nextDate.setDate(nextDate.getDate() + 1);
  }
  ScriptApp.newTrigger(funcName).timeBased().at(nextDate).create();
  console.log("トリガーの時間を変更しました。次回起動時刻:" + nextDate);

  return nextDate;
}
```

ちなみに「決められた稼働時間内でn分後に設定する、稼働時間外なら翌日の稼働開始時間に設定する」みたいな複雑な条件になるとこうなります。

```javascript
// 稼働間隔後のトリガーを作成する
function createTriggerWithInterval (funcName, intarvalM, startH, startM, finH, finM) {
  var nextDate = getNextDate(intarvalM, startH, startM, finH, finM);
  ScriptApp.newTrigger(funcName).timeBased().at(nextDate).create();
  console.log("トリガーの時間を変更しました。次回起動時刻:" + nextDate);
}

// 稼働間隔後の時刻を算出する。終了時刻を過ぎている場合は開始時刻を返す
function getNextDate(intarvalM, startH, startM, finH, finM) {
  //同じ日付になるように現在時刻+稼働間隔、開始時刻、終了時刻を算出する
  var date = new Date();
  date.setMinutes(date.getMinutes() + intarvalM);

  startDate = new Date();
  startDate.setMinutes(startDate.getMinutes() + intarvalM);
  startDate.setHours(startH);
  startDate.setMinutes(startM);

  finDate = new Date();
  finDate.setMinutes(finDate.getMinutes() + intarvalM);
  finDate.setHours(finH);
  finDate.setMinutes(finM);

  // 開始時刻 = 終了時刻の場合は無条件で稼働間隔後の時刻を返す
  if (startDate == finDate) {
    return date;
  }

  // 開始時間 < 停止時間
  if (startDate < finDate) {
    // 開始時間～停止時間の場合は稼働間隔後の時刻を返す
    if (startDate < date && date < finDate) {
      return date;
    }
    // 日付が変わっていなければ翌日の開始時間を返す
    if (startDate < date) {
      startDate.setDate(startDate.getDate() + 1);
    }
    return startDate;
  }

  // 停止時間 < 開始時間（RT停止時間が日付変わった後）
  // ～停止時間、開始時間～の場合は稼働間隔後の時刻を返す
  if (date < finDate || startDate < date) {
    return date;
  }
  return startDate;
}
```

日付・時刻計算のライブラリあったらそれ使った方が早そうでしたが、Javaで原型のコードが手元にあったので……

## bot作成

トリガーで呼び出す先の関数を作成します。
とはいっても、今までに作成した関数を組み合わせているだけです。

自動RTは前回のロジックほぼそのままなので割愛します。
セルフRTは、やっていることがツイートID持ってきてRT解除→再RTしているだけなので、こちらも割愛します。

画像ツイートだけマイドライブから持ってくるようになってそこそこ変わったので、そちらだけ解説すると、

```javascript
// 画像投稿・画像付きツイートを行った後、フォルダ移動を行う
// 画像が取得できない場合は何もせずreturn
function mediaTweetAndFileMove(tweetText, fromFolderId, toFolderId) {
  var folderFrom = DriveApp.getFolderById(fromFolderId);
  var files = folderFrom.getFiles();

  // 古い画像から使うためイテレータを無駄に回す（）
  var file;
  while(files.hasNext()) {
    file = files.next();
  };
  if (file == null) {
    return;
  }

  var fileBase64 = Utilities.base64Encode(file.getBlob().getBytes());//Blobを経由してBase64に変換
  var mediaIds = [];
  mediaIds[0] = postMedia(fileBase64);
  postTweetWithMedia(tweetText, mediaIds);

  //使用したファイルを移動する
  var toFolder = DriveApp.getFolderById(toFolderId);
  file.moveTo(toFolder);
}
```

どうやらフォルダ内のファイルを複数取得した時は新しい順に並ぶようなので、無駄にイテレータを回しています。イテレータ回すのが`files.next()`しかないし並び順変える方法も無いので苦肉の策です。
持ってきたファイルをなんやかんやしてBase64変換したバイナリデータにして、画像投稿・ツイート投稿を行っています。
その後、ファイルを使用済画像フォルダに移動しています。

# 終わりに
今回作成したコードは全てこちらに上げています。
https://github.com/sugarheart26bot/sugarheartbot/blob/main/code.gs
スプレッドシートはフォルダURLやブラックリストの関係で公開はできませんが、最初の方に挙げた画像をサンプルにコードを追ってみてください。

他アイドルのPのみなさんも、ぜひこれらを参考にしてbotを作って、担当アイドルの界隈を盛り上げてみてください。

もし総選挙かなにかイベントがあった際は、佐藤心と三好紗南をよろしくお願いします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621290/e083ac63-8062-964a-5aea-8d27a9b755a4.png)