【GAS】Twitterのbotを作ってみました。サクッとできます!

GAS投稿2発目です!
次はTwitterのbotを作ってみたので、作り方をご紹介したいと思います。

作製したbot

「アバローのプリンセス エレナ」のbotを作ってみました。

アバローのプリンセス エレナは毎週日曜、朝7:30からテレビ東京系で放送しています。新ディズニープリンセスです。明るく元気なラテン系プリンセスです。かわいいです。ファンです。
Twitterで探してみたらまだbot無かったので、自分で作ってしまいました。

実装した機能は以下の通りです。

  • 1時間に1回セリフを適当につぶやく
  • メンションが来たらそれに応える
    • 「フォローして」とメンションが来たら、そのユーザーをフォローし、リプライを返す
    • 適当にメンションが来たら、適当にセリフをリプライする

自動投稿したい文章のリスト化

まずはスプレッドシートを用意し、つぶやかせたいセリフを大量に書き込みましょう。
(この例では、1行目をヘッダにしています)

GASのプロジェクトキーの確認

先ほどのスプレッドシートにて、スクリプトエディタを開き、「ファイル」→「プロジェクトのプロパティ」を開くと表示されます。この値を控えましょう。

Twitterでのアプリケーション登録

Twitterのアプリケーションマネージャーにアクセスし、アプリケーションを登録します。以下の図ではもう登録済みですが、「Create New App」からアプリケーションを新規登録できます。

新規登録

新規作成画面は以下となります。必要事項を入力しましょう。

今回の例だと、

  • Name(アプリ名):アバローのプリンセス エレナ bot
  • Description(説明文):アバローのプリンセス エレナのbotです。非公式です。アニメ中のエレナの名言を定期的につぶやきます。
  • Website(ウェブサイト):Webサイトは持ち合わせていないので、適当にhttps://script.google.com/macrosと入力
  • Callback URL(コールバックURL):https://script.google.com/macros/d/[プロジェクトキー]/usercallback

Callback URLにて、先ほど控えたプロジェクトキーを使います。

パーミッションの設定

作成したアプリケーションのパーミッションを設定します。以下図のように、PermissionsタブにてRead and Writeになっているかどうかを確認してください。もしなっていない場合は変更しましょう。

コード実装

いよいよGASにてbotのコーディングを行います。ここからの作業は先ほど開いたGASエディタにて行います。

ライブラリのインポート

TwitterのAPIをGASで扱うときに、先人様の作ったありがたいライブラリが提供されております。ありがたく使わせていただきましょう。
「リソース」→「ライブラリ」と選択すると、以下図のようなダイアログが出現するので、テキストボックスにMb2Vpd5nfD3Pz-_a-39Q4VfxhMjh3Sh48と入力し、追加を押すとライブラリをインポートできます。(バージョンは7がよさそう)

先人様ありがとうございます。

Twitter.gsの作成

ユーティリティコードを記述しておきます。参考サイトほぼそのままです。
途中で出てくるprojectKeyconsumerKeyconsumerSecretにはそれぞれ環境に合わせた値を入力する必要があります。projectKeyには、先ほど控えたプロジェクトキーを、consumerKeyconsumerSecretにはアプリケーションマネージャーにて表示されている値を入力します。

Twitter.gs

// 最初にこの関数を実行し、ログに出力されたURLにアクセスしてOAuth認証する
function twitterAuthorizeUrl() {
  Twitter.oauth.showUrl();
}

// OAuth認証成功後のコールバック関数
function twitterAuthorizeCallback(request) {
  return Twitter.oauth.callback(request);
}

// OAuth認証のキャッシュをを削除する場合はこれを実行(実行後は再度認証が必要)
function twitterAuthorizeClear() {
  Twitter.oauth.clear();
}


var Twitter = {
  projectKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxx",

  consumerKey: "xxxxxxxxxxxxxxxxxxxxxxxxx",
  consumerSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",

  apiUrl: "https://api.twitter.com/1.1/",

  oauth: {
    name: "twitter",

    service: function(screen_name) {
      // 参照元:https://github.com/googlesamples/apps-script-oauth2

      return OAuth1.createService(this.name)
      // Set the endpoint URLs.
      .setAccessTokenUrl('https://api.twitter.com/oauth/access_token')
      .setRequestTokenUrl('https://api.twitter.com/oauth/request_token')
      .setAuthorizationUrl('https://api.twitter.com/oauth/authorize')

      // Set the consumer key and secret.
      .setConsumerKey(this.parent.consumerKey)
      .setConsumerSecret(this.parent.consumerSecret)

      // Set the project key of the script using this library.
      .setProjectKey(this.parent.projectKey)


      // Set the name of the callback function in the script referenced
      // above that should be invoked to complete the OAuth flow.
      .setCallbackFunction('twitterAuthorizeCallback')

      // Set the property store where authorized tokens should be persisted.
      .setPropertyStore(PropertiesService.getUserProperties());
    },

    showUrl: function() {
      var service = this.service();
      if (!service.hasAccess()) {
        Logger.log(service.authorize());
      } else {
        Logger.log("認証済みです");
      }
    },

    callback: function (request) {
      var service = this.service();
      var isAuthorized = service.handleCallback(request);
      if (isAuthorized) {
        return HtmlService.createHtmlOutput("認証に成功しました!このタブは閉じてかまいません。");
      } else {
        return HtmlService.createHtmlOutput("認証に失敗しました・・・");
      }
    },

    clear: function(){
      OAuth1.createService(this.name)
      .setPropertyStore(PropertiesService.getUserProperties())
      .reset();
    }
  },

  api: function(path, data) {
    var that = this, service = this.oauth.service();
    if (!service.hasAccess()) {
      Logger.log("先にOAuth認証してください");
      return false;
    }

    path = path.toLowerCase().replace(/^\//, '').replace(/\.json$/, '');

    var method = (
         /^statuses\/(destroy\/\d+|update|retweet\/\d+)/.test(path)
      || /^media\/upload/.test(path)
      || /^direct_messages\/(destroy|new)/.test(path)
      || /^friendships\/(create|destroy|update)/.test(path)
      || /^account\/(settings|update|remove)/.test(path)
      || /^blocks\/(create|destroy)/.test(path)
      || /^mutes\/users\/(create|destroy)/.test(path)
      || /^favorites\/(destroy|create)/.test(path)
      || /^lists\/[^\/]+\/(destroy|create|update)/.test(path)
      || /^saved_searches\/(create|destroy)/.test(path)
      || /^geo\/place/.test(path)
      || /^users\/report_spam/.test(path)
      ) ? "post" : "get";

    var url = this.apiUrl + path + ".json";
    var options = {
      method: method,
      muteHttpExceptions: true
    };

    if ("get" === method) {
      if (!this.isEmpty(data)) {
        // 2015/07/07 再度修正
        // 旧コード)
        // var queries = [];
        // for (var key in data) {
        //   // 2015/05/28 以下の部分を修正
        //   // 旧コード) queries.push(key + "=" + encodeURIComponent(data[key]));
        //   
        //   
        //   var encoded = encodeURIComponent(data[key]).replace(/[!'()*]/g, function(c) {
        //     return "%" + c.charCodeAt(0).toString(16);
        //   });
        //   queries.push(key + "=" + encoded);
        // }
        // url += '?' + queries.join("&");
        url += '?' + Object.keys(data).map(function(key) {
          return that.encodeRfc3986(key) + '=' + that.encodeRfc3986(data[key]);
        }).join('&');
      }
    } else if ("post" == method) {
      if (!this.isEmpty(data)) {
        // 2015/07/07 修正
        // 旧コード)options.payload = data;
        options.payload = Object.keys(data).map(function(key) {
          return that.encodeRfc3986(key) + '=' + that.encodeRfc3986(data[key]);
        }).join('&');

        if (data.media) {
          options.contentType = "multipart/form-data;charset=UTF-8";
        }
      }
    }

    try {
      var result = service.fetch(url, options);
      var json = JSON.parse(result.getContentText());
      if (json) {
        if (json.error) {
          throw new Error(json.error + " (" + json.request + ")");
        } else if (json.errors) {
          var err = [];
          for (var i = 0, l = json.errors.length; i < l; i++) {
            var error = json.errors[i];
            err.push(error.message + " (code: " + error.code + ")");
          }
          throw new Error(err.join("\n"));
        } else {
          return json;
        }
      }
    } catch(e) {
      this.error(e);
    }

    return false;
  },

  error: function(error) {
    var message = null;
    if ('object' === typeof error && error.message) {
      message = error.message + " ('" + error.fileName + '.gs:' + error.lineNumber +")";
    } else {
      message = error;
    }

    Logger.log(message);
  },

  isEmpty: function(obj) {
    if (obj == null) return true;
    if (obj.length > 0)    return false;
    if (obj.length === 0)  return true;
    for (var key in obj) {
        if (hasOwnProperty.call(obj, key)) return false;
    }
    return true;
  },

  encodeRfc3986: function(str) {
    return encodeURIComponent(str).replace(/[!'()]/g, function(char) {
      return escape(char);
    }).replace(/\*/g, "%2A");
  },

  init: function() {
    this.oauth.parent = this;
    return this;
  }
}.init();


/********************************************************************
以下はサポート関数
*/

// ツイート検索
Twitter.search = function(data) {
  if ("string" === typeof data) {
    data = {q: data};
  }

  return this.api("search/tweets", data);
};

// 自分のタイムライン取得
Twitter.tl = function(since_id) {
  var data = null;

  if ("number" === typeof since_id || /^\d+$/.test(''+since_id)) {
    data = {since_id: since_id};
  } else if("object" === typeof since_id) {
    data = since_id;
  }

  return this.api("statuses/home_timeline", data);
};

// ユーザーのタイムライン取得
Twitter.usertl = function(user, since_id) {
  var path = "statuses/user_timeline";
  var data = {};

  if (user) {
    if (/^\d+$/.test(user)) {
      data.user_id = user;
    } else {
      data.screen_name = user;
    }
  } else {
    var path = "statuses/home_timeline";
  }

  if (since_id) {
    data.since_id = since_id;
  }

  return this.api(path, data);
};

// フォロワー取得
Twitter.followers = function(user) {
  var path = "followers/ids";
  var data = {}
  if (user) {
    if (/^\d+$/.test(user)) {
      data.user_id = user;
    } else {
      data.screen_name = user;
    }
  } else {
    data.screen_name = "elena_bot161127";
  }

  return this.api(path, data);
}

// フォロー取得
Twitter.friends = function(user) {
  var path = "friends/ids";
  var data = {}
  if (user) {
    if (/^\d+$/.test(user)) {
      data.user_id = user;
    } else {
      data.screen_name = user;
    }
  } else {
    data.screen_name = "elena_bot161127";
  }

  return this.api(path, data);
}


// ツイートする
Twitter.tweet = function(data, reply) {
  var path = "statuses/update";
  if ("string" === typeof data) {
    data = {status: data};
  } else if(data.media) {
    path = "statuses/update_with_media ";
  }

  if (reply) {
    if("string" === typeof reply) {
      data.in_reply_to_status_id_str = reply;
    } else {
      data.in_reply_to_status_id = reply;
    }
  }

  return this.api(path, data);
};

// トレンド取得(日本)
Twitter.trends = function(woeid) {
  data = {id: woeid || 1118108};
  var res = this.api("trends/place", data);
  return (res && res[0] && res[0].trends && res[0].trends.length) ? res[0].trends : null;
};

// トレンドのワードのみ取得
Twitter.trendWords = function(woeid) {
  data = {id: woeid || 1118108};
  var res = this.api("trends/place", data);
  if (res && res[0] && res[0].trends && res[0].trends.length) {
    var trends = res[0].trends;
    var words = [];
    for(var i = 0, l = trends.length; i < l; i++) {
      words.push(trends[i].name);
    }
    return words;
  }
};


Twitter.replies = function(last_id){
  if(last_id) {
    return Twitter.api('statuses/mentions_timeline',{'since_id':last_id});
  } else {
    return Twitter.api('statuses/mentions_timeline');
  }
}

OAuth認証

ここで、TwitterにOAuth認証します。

認証用の関数の中から、twitterAuthorizeUrlというのを選択し、実行します。

関数が終了しても特に画面は変化しませんが、上部メニュー「表示」→「ログ」を開くと承認用のURLが出力されています。 これをコピーしてブラウザでアクセスすると、Twitterのアプリ承認画面に行くので承認してください。

承認後は画面が切り替わって「認証に成功しました」という文字が出現します。

軽く動作テスト

APIがちゃんと叩けるかどうかテストしてみましょう。

以下の関数を定義し、実行してみてください。

function tweet_test() {
  // ツイートする
  var res = Twitter.tweet("ツイート文");
}

これで、認証を通したアカウントにツイート文が表示されれば、APIを正常に叩けていることになります。
Twitter.tweet("")とするだけでツイートできるとか楽すぎます。

botの動作をコーディング

実際のbotのコードですが、今回は以下のようにコーディングしました。

// 定期つぶやき
function elena_bot() {
  elena_tweet();
}

// タイムライン処理
function elena_mentions() {
  var last_id = PropertiesService.getScriptProperties().getProperty("last_id");
  if(last_id) {
    var mentions = Twitter.replies(last_id);
  } else {
    var mentions = Twitter.replies();
  }
  if(mentions.length>0&&'id_str' in mentions[0]){
    PropertiesService.getScriptProperties().setProperty("last_id", mentions[0]['id_str']);  //最新のIDを保存しておく
  } else {
    return;
  }

  for(var i = 0, l = mentions.length; i < l; i++) {
    if(mentions[i]["text"].match(/フォローして/)) {
      // フォローしてと言われたら、フォローする
      Twitter.api("friendships/create", {user_id: mentions[i]["user"]["id_str"]});
      Twitter.tweet("@" + mentions[i]["user"]["screen_name"] + " フォローしたわよ!あなたも一緒にアバロー王国の平和を守りましょう!", mentions[i]['id_str']);
    } else {
      // その他のリプは適当に返す
      elena_tweet(mentions[i]['id_str'], mentions[i]["user"]["screen_name"]);
    }
  }
}

// フォローを解除されたらこちらも解除する
function unfollow() {
  var follows = Twitter.friends();
  var followers = Twitter.followers();

  var comp = {};
  for(var i = 0, l = followers["ids"].length; i < l; i++) {
    comp[followers["ids"][i]] = 1;
  }

  for(var i = 0, l = follows["ids"].length; i < l; i++) {
    if(!comp[follows["ids"][i]]) Twitter.api("friendships/destroy", {user_id: follows["ids"][i].toString()});
  }
}

// ツイート処理
function elena_tweet(tweet_id, screen_name) {
  var ash = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ash.getActiveSheet();
  var lastRow = sheet.getLastRow();
  var random = Math.floor( Math.random() * (lastRow-2))+2;
  if(tweet_id && screen_name) {
    var res = Twitter.tweet("@" + screen_name + " " + sheet.getRange(random, 1).getValue(), tweet_id);
  } else {
    var res = Twitter.tweet(sheet.getRange(random, 1).getValue());
  }
}

トリガーの設定

エディタ上の時計マークのボタンからトリガーを設定します。時間は適当です。お好みで。

動作確認

1時間に1回ツイートするようにしました。

トラブルシュート

いざコードを実装し、何の問題もなくスムーズに動作したかというと、そういう訳ではありませんでした。レスポンスをLogger.log(res);してみると、以下のようなエラーに遭遇しました。その解決方法についても書き記しておこうと思います。

Read-only application cannot POST

まず、Twitterにアカウントを作成した場合、Emailでの本人確認を行う必要がありますが、それをやってなかったので、アプリケーションにWriteの権限が付与されておりませんでした。(アホかと。。。)
本人確認をして、前述したパーミッションの設定でRead and Writeの権限を付与してあげれば、こちらは解決しました。

Invalid or expired token (code:89)

次にこのエラーがでました。本人確認をする前に認証を通してしまったので、本人確認することでアクセストークンが期限切れになってしまったものと思っております。
これについては、twitterAuthorizeClear()を実行し、再度twitterAuthorizeUrl()の手順を行えば解決しました。

まとめ

GASでTwitterのbotを作るのはライブラリもあるので想像以上に敷居が低かったです。スプレッドシートとの組み合わせで、簡単な分析なんかもできそうですね。是非皆さんもトライしてみてください。

参考にしたページ

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です