見出し画像

声優お仕事告知botについて GAS実装編

2022年4月から運用開始したお仕事告知bot。
それから1年、「TwitterAPI有料化」の情報。
IFTTTも無料プランではTwitter投稿不能。
月370円払って有料プランにするか?
関連リンク
声優お仕事告知botについて
構想編
IFTTT実装編
GAS実装編 ←イマココ


参考サイト

TwitterAPIの変更があったときに自力で対応しなくていいことが既存サービスを利用する利点だったんだけど、サービスのほうがつぶれるような事態になってきているので、自力でGASで何とかしてみるか。
プログラミングは学生時代にJAVAで「hello world」と表示したりブロック崩しのアプレット作ったことあるから、なんとかなるだろ。
まずはTwitter投稿を実現してみよう。


??
???

よくわからん。
よく分からんけど、ルールがあって書かれているものなのだから、パターン見てパズルのように考えれば何かわかるはずだ。

いくつかソースコードを読んでみると、
『NEW オブジェクトの定義名』でオブジェクトが作成されること、
オブジェクト名の後ろにピリオドを付けて下位のオブジェクトを指定でき、
属性値や関数を呼び出すことができるようだ。
とりあえず、上記サイトの通りに実装する。
無料のTwitterAPIの投稿は月1500回まで⇒1日50回程度。
botの運用には充分である。

続いて、Googleカレンダーの情報をツイートする機能を何とかしよう。

なんかLINEに出力するソースしか見つからなかったけど、上述のTwitter投稿の関数に渡してやればいいはず。

検索していたら「Googleカレンダーから予定を取得する」「Googleカレンダーに予定を登録する」というソースコードを見つけた。これを利用すれば『翌日までの予定』『来週末までの予定』が自動生成できそうだ。

あとは、手動でやっても大した手間ではないけど、「予定を新規追加したらツイート」を実現したい。ただこれがなかなか厄介で、「カレンダーが更新された」「前回読み込み時から変更のある予定を取得」まではできるんだけど、新規追加かどうかはリストを作って比較管理しなければ判定できないようだ。
じゃあ逆に、追加・変更があった場合に【告知用カレンダー】に予定を追加するという形で実装しよう。

ということで上記サイトを参考に実装する。

コード

ツイート機能

ほぼ参考サイトのまんま。
sendTweet関数には投稿したい内容を引数で渡すようにした。
初回実行時にはTwitter認証が発生。
140文字を超えるツイートはできない。Blueに加入してみたけどAPIが140文字以上だと弾いてしまうのでダメだった。

const CLIENT_ID = 'Twitter Developper情報から取得'
const CLIENT_SECRET = 'Twitter Developper情報から取得'

function getService() {
  pkceChallengeVerifier();
  const userProps = PropertiesService.getUserProperties();
  const scriptProps = PropertiesService.getScriptProperties();
  return OAuth2.createService('twitter')
    .setAuthorizationBaseUrl('https://twitter.com/i/oauth2/authorize')
    .setTokenUrl('https://api.twitter.com/2/oauth2/token?code_verifier=' + userProps.getProperty("code_verifier"))
    .setClientId(CLIENT_ID)
    .setClientSecret(CLIENT_SECRET)
    .setCallbackFunction('authCallback')
    .setPropertyStore(userProps)
    .setScope('users.read tweet.read tweet.write offline.access')
    .setParam('response_type', 'code')
    .setParam('code_challenge_method', 'S256')
    .setParam('code_challenge', userProps.getProperty("code_challenge"))
    .setTokenHeaders({
      'Authorization': 'Basic ' + Utilities.base64Encode(CLIENT_ID + ':' + CLIENT_SECRET),
      'Content-Type': 'application/x-www-form-urlencoded'
    })
}

function authCallback(request) {
  const service = getService();
  const authorized = service.handleCallback(request);
  if (authorized) {
    return HtmlService.createHtmlOutput('Success!');
  } else {
    return HtmlService.createHtmlOutput('Denied.');
  }
}

function pkceChallengeVerifier() {
  var userProps = PropertiesService.getUserProperties();
  if (!userProps.getProperty("code_verifier")) {
    var verifier = "";
    var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";

    for (var i = 0; i < 128; i++) {
      verifier += possible.charAt(Math.floor(Math.random() * possible.length));
    }

    var sha256Hash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, verifier)

    var challenge = Utilities.base64Encode(sha256Hash)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '')
    userProps.setProperty("code_verifier", verifier)
    userProps.setProperty("code_challenge", challenge)
  }
}

function logRedirectUri() {
  var service = getService();
  Logger.log(service.getRedirectUri());
}

function main() {
  const service = getService();
  if (service.hasAccess()) {
    Logger.log("Already authorized");
  } else {
    const authorizationUrl = service.getAuthorizationUrl();
    Logger.log('Open the following URL and re-run the script: %s', authorizationUrl);
  }
}

function sendTweet(tubuyaki) {
  var payload = {
    //    text: 'Test tweet from API OAuth2'
    text: tubuyaki
  }

  var service = getService();
  if (service.hasAccess()) {
    var url = `https://api.twitter.com/2/tweets`;
    var response = UrlFetchApp.fetch(url, {
      method: 'POST',
      'contentType': 'application/json',
      headers: {
        Authorization: 'Bearer ' + service.getAccessToken()
      },
      muteHttpExceptions: true,
      payload: JSON.stringify(payload)
    });
    var result = JSON.parse(response.getContentText());
    Logger.log(JSON.stringify(result, null, 2));
  } else {
    var authorizationUrl = service.getAuthorizationUrl();
    Logger.log('Open the following URL and re-run the script: %s',authorizationUrl);
  }
}

カレンダーイベント自動ツイート

この2つの関数は毎5分ごとに実行している。
秒単位で正確に5分ごとに動くことを確認できたので、
5分の幅を持たせ、お仕事の方は15分前、告知の方は開始時間でツイート。

function startEvents() {
  //カレンダーIDを指定して、対象のカレンダー情報を読み込み
  var cals = CalendarApp.getCalendarById("お仕事カレンダーのID");
  var d1 = new Date();
  var d2 = new Date();
  d2.setMinutes(d2.getMinutes() + 20);

  //現在~20分後の間で行われているイベントを取得
  let evts = cals.getEvents(d1, d2);

  d1.setMinutes(d1.getMinutes() + 15);
  //15分後~20分後の間で開始されるイベントのタイトルと内容をツイート
  for (var i in evts) {
    if ((evts[i].getStartTime() >= d1) & (evts[i].getStartTime() < d2)) {
      sendTweet("もうすぐ時間です\n" + evts[i].getTitle() + "\n" + evts[i].getDescription());
    }
  }
}

function startNotice() {
  //カレンダーIDを指定して、対象のカレンダー情報を読み込み
  var cals = CalendarApp.getCalendarById("告知用カレンダーのID");
  var d1 = new Date();
  var d2 = new Date();
  d2.setMinutes(d2.getMinutes() + 5);

  //現在~5分後の間で行われているイベントを取得
  let evts = cals.getEvents(d1, d2);

  //現在~5分後の間で開始されるイベントのタイトルと内容をツイート
  for (var i in evts) {
    if ((evts[i].getStartTime() >= d1) & (evts[i].getStartTime() < d2)) {
      sendTweet(evts[i].getTitle() + "\n" + evts[i].getDescion());
    }
  }
}

日次週次予定自動作成

日次・週次の予定をお仕事カレンダーから取得し、告知用カレンダーにイベント登録する部分。
日次は毎日1時に、週次は金曜1時に登録するよう起動時間を設定。
0:00~23:59で処理するのが正解なんだろうけど、8:00を基準にしている。
そのため、『終日』のイベントを前日にも検知してしまうので手動でカレンダーを修正している。要改善ポイント。
カレンダーのイベントは、開始時間・終了時間が定まっているもののほかに
開始時間だけ定まっているもの、
終了時間だけ定まっているもの(締め切り系)があるため、
StartTime ~ EndTimeを表示しているが手動でカレンダーを修正している。
週次予定では140文字を超えることが多々あり、結局時間を削ることが多い。

function createNichiji() {
  //8:00にイベント登録
  const startd = new Date();
  startd.setDate(startd.getDate() + 1)
  startd.setHours(08);
  startd.setMinutes(00);
  startd.setSeconds(00);
  const endd = new Date(Date.parse(startd) + (30 * 60 * 1000));
  //Googleカレンダーの情報を取得
  var calendar_ID = 'お仕事カレンダーのID';
  var calendar_get = CalendarApp.getCalendarById(calendar_ID);
  var titel = "明日までの予定";
  var options = {
    description: makeTommorowList(),
  }
  calendar_get.createEvent(titel, startd, endd, options);
}

function createShuji() {
  //7:30にイベント登録
  const startd = new Date();
  startd.setDate(startd.getDate() + 1)
  startd.setHours(07);
  startd.setMinutes(30);
  startd.setSeconds(00);
  const endd = new Date(Date.parse(startd) + (30 * 60 * 1000));
  //Googleカレンダーの情報を取得
  var calendar_ID = 'お仕事カレンダーのID';
  var calendar_get = CalendarApp.getCalendarById(calendar_ID);
  var titel = "来週末までの予定";
  var options = {
    description: makeEndweekList(),
  }
  calendar_get.createEvent(titel, startd, endd, options);
}

function makeTommorowList() {
  var calList = "";
  let array1 = [];
  const checkDay = new Date();
  for (let i = 0; i < 2; i++) {
    checkDay.setDate(checkDay.getDate() + 1);
    var dayList = createCalendarDayList(checkDay);
    if (dayList) {
      calList = calList + dayList + "\n";
    }
  }
  return calList;
}

function makeEndweekList() {
  var calList = "";
  let array1 = [];
  const checkDay = new Date();
  for (let i = 0; i < 9; i++) {
    checkDay.setDate(checkDay.getDate() + 1);
    var dayList = createCalendarDayList(checkDay);
    if (dayList) {
      calList = calList + dayList + "\n";
    }
  }
  return calList;
}


function createCalendarDayList(today) {
  //日付と時間を取得
  today.setHours(08);
  today.setMinutes(00);
  today.setSeconds(00);
  const tomorrow = new Date(Date.parse(today) + (24 * 60 * 60 * 1000));
  var returnMessage = "";

//Googleカレンダーの情報を取得
  var calendar_ID = 'お仕事カレンダーのID';
  var calendar_get = CalendarApp.getCalendarById(calendar_ID);
  let events = calendar_get.getEvents(today, tomorrow);
  if (events.length) {  //←空白判定
    var youbi = ["日", "月", "火", "水", "木", "金", "土"];
    var week_num = today.getDay();
    var week = "(" + youbi[week_num] + ")\n";
    const day = today.getDate();

    returnMessage = day + week;
  }

  //全ての予定を1つの文章につなげて出力
  for (var i in events) {

    //予定の開始時刻
    const startHours = "0" + events[i].getStartTime().getHours();
    const startMinutes = "0" + events[i].getStartTime().getMinutes();
    const startTime = startHours.slice(-2) + ":" + startMinutes.slice(-2); //データ型から文字列に変換

    //予定の終了時刻
    const endHours = "0" + events[i].getEndTime().getHours();
    const endMinutes = "0" + events[i].getEndTime().getMinutes();
    const endTime = endHours.slice(-2) + ":" + endMinutes.slice(-2); //データ型から文字列に変換

    //取得した情報を一文にし、配列に格納
    const time = startTime + " ~ " + endTime;
    const title = events[i].getTitle();
    const message = title + " " + time + "\n";
    //  messageArray.push(message);
    returnMessage = returnMessage + message;
  }

  return returnMessage;
}

新規予定追加告知

関数「onCalendarEdit」をトリガー「カレンダー編集時」で登録。
初回に実行するinitialSyncだが、1年運用していたのでカレンダーに登録したイベント数が多く、nextSyncTokenが1発では取れなかったため、ページ送りして取得させた。
お仕事カレンダーのイベントを追加・変更すると、現在日付に対する翌日9:30に告知用カレンダーのイベントとして登録する。必要ならその後日時を手動で移動する。

function createShinki(items) {
  //日付と時間を取得
  const startd = new Date();
  startd.setDate(startd.getDate() + 1)
  startd.setHours(09);
  startd.setMinutes(30);
  startd.setSeconds(00);
  const endd = new Date(Date.parse(startd) + (30 * 60 * 1000));
//Googleカレンダーの情報を取得
  var calendar_ID = 'お仕事カレンダーのID';
  var calendar_get = CalendarApp.getCalendarById(calendar_ID);
  var titel = "【新規予定追加】";
  var options = {
    description: items.summary + "\n" + items.description,
  }
  calendar_get.createEvent(titel, startd, endd, options);
}


//nextSyncTokenをスクリプトプロパティに保存する
function initialSync() {
  var calendarId = 'お仕事カレンダーのID';
  var items = Calendar.Events.list(calendarId);
  var nextSyncToken = items.nextSyncToken;
  var nextPageToken = items.nextPageToken;
  while (nextPageToken) {
    var options = {
      pageToken: nextPageToken
    };
    items = Calendar.Events.list(calendarId, options);
    nextSyncToken = items.nextSyncToken;
    nextPageToken = items.nextPageToken;
  }
  var properties = PropertiesService.getScriptProperties();
  properties.setProperty("syncToken", nextSyncToken);
}


//カレンダー編集時に自動的に起動するプログラム
function onCalendarEdit() {
  var calendarId = 'お仕事カレンダーのID';
  var properties = PropertiesService.getScriptProperties();
  var nextSyncToken = properties.getProperty("syncToken");
  var optionalArgs = {
    syncToken: nextSyncToken
  };
  var events = Calendar.Events.list(calendarId, optionalArgs);
  if (events.items.length > 0) {
    if (events.items[0].status == 'confirmed') {
        createShinki(events.items[0]);
    }
  }
  
  console.log(events);
  var nextSyncToken = events["nextSyncToken"];
  properties.setProperty("syncToken", nextSyncToken);
}

運用方法

前回「IFTTT実装編」を参照。
日次の予定、週次の予定を毎日コピペたくさんして仕込まなくても自動生成されるようになったのが、かなり楽だ。稼働させてから3か月。バグもなく安定運用できている。


終わりに

コーディングが汚い?動けばいいんだよ動けば。冗長な部分は多いと感じるけど、動かなくなるのが怖くて弄れない。
元々Googleカレンダーに視聴番組・参加イベントを登録していたが、日次と週次の予定告知により取りこぼしがなくなった。他のタレントでも軽率にやってくれこういうの。
ご本人が直接行うYouTube配信については取り扱わないことにした。告知したい場合もあれば、コッソリやりたい場合もあるようなので。
将来的には、いつTwitterあらためXの仕様が変わるかわからないので注視したい。

関連リンク
声優お仕事告知botについて
構想編
IFTTT実装編
GAS実装編


この記事が気に入ったらサポートをしてみませんか?