TwitterからMastodonへ転載するbotを作る


前置き

TwitterのツイートをMastodonに転載してトゥートするbotをGoogle Apps Script(以下GAS)で動かすという内容です。
GASやJavaScriptには不慣れなので拙いコードにはなりますが備忘録として残しておきます。
そして何よりQiitaへの投稿は初めてなので、見づらい点多く見受けられると思いますがご容赦ください。

GASを一切使ったことがない前提で進めていきます。
また、このサンプルコードは https://imastodon.net/@imascg_stage_bot で実際に使用しているものです。

下準備

Googleドライブを開き、「新規」→「その他」→「アプリの追加」と進み、「Google Apps Script」を追加したら、新規作成。
プロジェクトに名前をつけた後、「ファイル」から「プロジェクトのプロパティ」を開いておく。

Twitter側

https://apps.twitter.com よりTwitterのAppsを作成。その際Callback URLには
https://script.google.com/macros/d/[スクリプトID]/usercallback
と入力する。スクリプトIDは「プロジェクトのプロパティ」に記載されているものを入力。

KeyとSecretを表示させたらGASに戻り、スクリプトプロパティに「Key, Secret, Mastodonのインスタンス」をそれぞれ入力する。
Mastodonのインスタンスの例:https://mstdn.jp

さらに、「リソース」→「ライブラリ」→「ライブラリを追加」に
MFE2ytR_vQqYfZ9VodecRE0qO0XQ_ydfb
を入力して、バージョン1を選択して完了。

その後以下のコードをそのまま入力し、「関数を選択」から"authorize"を選び実行
その後「表示」→「ログ」に出てくるURLに進みTwitter認証をする。(この時ログインしているアカウントでアクセスすることになる。)

Twitter.gs
var twitter = TwitterWebService.getInstance(ckey,csecret);

function authorize() {
  twitter.authorize();
}

function authCallback(request) {
  return twitter.authCallback(request);
}

最後に下のコードを追加してTwitter側は終了。使用例は後ほど。

Twitter.gs
var scrprop = PropertiesService.getScriptProperties();
var userprop = PropertiesService.getUserProperties();
var ckey = scrprop.getProperty("consumer_key");
var csecret = scrprop.getProperty("consumer_secret");
var twitter = TwitterWebService.getInstance(ckey,csecret);
var request = "https://api.twitter.com/1.1/statuses/user_timeline.json?tweet_mode=extended&screen_name=" // extendedは必須

function getUserTimeline(targetname) { // targetnameに対象のスクリーンネームを入れる
  var res = twitter.getService().fetch(request+targetname+"&since_id="+
                                       userprop.getProperty(targetname)); //user_timelineへリクエスト
  var json = JSON.parse(res.getContentText());

  if (json.length>0) {
    userprop.setProperty(targetname, json[0].id_str); // 次の参照用

    var statuses = [];
    json.forEach(function(status,i){
      var e = status.entities;
      if (e.user_mentions.length==0) {
        var text = status.full_text;
        var medias = [];
        if (e.urls != undefined && e.urls.length>0) { // URLを置き換える
          for (var j=0;j<e.urls.length;j++) {
            text = text.replace(/https:\/\/t.co\/[a-zA-Z0-9]+/,e.urls[j].expanded_url);
          }
        }
        if (e.media != undefined && e.media.length>0) { // 画像DL
          for (var j=0;j<e.media.length;j++) {
            text = text.replace(/https:\/\/t.co\/[a-zA-Z0-9]+/,"");
            medias[j] = UrlFetchApp.fetch(e.media[j].media_url_https).getBlob();
          }
        }
        text = text+"#official_bot\nhttps://twitter.com/"+targetname+"/status/"+status.id_str+"/"; // ハッシュタグやURLを付ける
        statuses[i] = {
          "text" : text,
          "media" : medias
        };
      }
    });
    return statuses.reverse();
  }
}

function initialize(target) { // プロパティのセット
  if (userprop.getProperty(target)==null) {
    var res = twitter.getService().fetch(request+target);
    userprop.setProperty(target, JSON.parse(res.getContentText())[0].id_str);
  }
}

Mastodon側

初めにAppsの取得、Tokenの取得をしなければならない。
これといったライブラリもなさそうなので、直接APIを叩いていく。
クライアント名を入力した後、authorize1 を指定して実行、ログに出たURLにアクセスし承認を押した後、出てきたやつを authorization_code の中に入れてから authorize2 を指定して実行することで、ログに Bearer ではじまるトークンが出てくるので控えておく。

Mastodon.gs
var scrprop = PropertiesService.getScriptProperties();
var instance = scrprop.getProperty("instance");

function authorize1() {
  if (scrprop.getProperty("client_id")==null) { // 1度もAppsを作成したことがない場合に作成する
    var client_name = ""; // ここにクライアント名入力してから実行すること

    var payload = {
      "client_name" : client_name,
      "redirect_uris" : "urn:ietf:wg:oauth:2.0:oob",
      "scopes" : "write"
    };
    var params = {
      "method" : "post",
      "contentType" : "application/json",
      "payload" : JSON.stringify(payload)
    };
    var res = UrlFetchApp.fetch(instance+"/api/v1/apps", params);
    var json = JSON.parse(res.getContentText());
    scrprop.setProperties({"client_id":json.client_id,"client_secret":json.client_secret});
  }

  Logger.log(instance+
             "/oauth/authorize?response_type=code&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=write&client_id="+
             scrprop.getProperty("client_id"));
}

function authorize2() {
  var authorization_code = ""; // ↑で入手したコードを入力してから実行すること

  var payload = {
    "grant_type" : "authorization_code",
    "redirect_uri" : "urn:ietf:wg:oauth:2.0:oob",
    "code" : authorization_code,
    "client_id" : scrprop.getProperty("client_id"),
    "client_secret" : scrprop.getProperty("client_secret")
  };
  var params = {
    "method" : "post",
    "contentType" : "application/json",
    "payload" : JSON.stringify(payload)
  };
  var res = UrlFetchApp.fetch(instance+"/oauth/token", params);
  Logger.log("Bearer "+JSON.parse(res.getContentText()).access_token); // トークンの前に必ずBearerを付けないと失敗する
}

最後に下のコードを追加して準備が完了。

Mastodon.gs
var scrprop = PropertiesService.getScriptProperties();
var instance = scrprop.getProperty("instance");

function postStatuses(statuses,authorize) {
  return postStatuses(statuses,authorize,"unlisted");
}

function postStatuses(statuses,authorize,visibility) {
  if (statuses!=null&&authorize!=null) {
    statuses.forEach(function(status){
      var payload = {
        "status" : status.text,
        "media_ids" : postMedia(status.media,authorize),
        "visibility" : visibility
      };
      var params = {
        "method" : "post",
        "headers" : {"authorization":authorize},
        "contentType" : "application/json", // これをつけないと500を返される
        "payload" : JSON.stringify(payload) // JSONに直さないと画像投稿に失敗する
      };
      UrlFetchApp.fetch(instance+"/api/v1/statuses",params);
    });
  }
}

function postMedia(medias,authorize) {
  var media_ids = [];
  if (medias.length>0) {
    medias.forEach(function(media,i){
      var params = {
        "method" : "post",
        "headers" : {"authorization":authorize},
        "payload" : {"file":media}
      };
      var res = UrlFetchApp.fetch(instance+"/api/v1/media",params);
      media_ids[i] = JSON.parse(res.getContentText()).id; // 画像URLは必要ない
    });
  }
  return media_ids;
}

トリガーの準備

以下のようなコードを追加した後、「編集」→「現在のプロジェクトのトリガー」と進み、
実行したい関数(ここではimascg_stage) 、どのくらいの時間で確認/投稿させたいかを入力して保存。(5分以上でないと失敗する可能性がある。)

Trigger.gs
function imascg_stage() {
  var target = "imascg_stage"; // 転載したいユーザーのID ここではデレステ公式
  var authorize = ""; // 上で控えた Bearer で始まるトークンを入力
  initialize(target);
  var statuses = getUserTimeline(target);
  postStatuses(statuses,authorize,"unlisted"); // 省略した場合はunlisted(未収載)、他にも"public","private","direct"に指定できる。
  // サーバーごとにbot運用のルールが異なるので必ず確認すること。
}

以上

参考文献

MastodonのAPI全集 https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#apps
MastodonのAccess Token取得について https://qiita.com/m13o/items/7798f09f16523d5693d5

あとがき

  • 画像は?
    • 画像の投稿は最後まで成功しませんでした。助言お待ちしております。
    • 成功しました。GASのURLFetchApp.fetch()のparamsはデフォルトで "application/x-www-form-urlencoded" で送信されるらしいです。知らないでドツボにはまってました。
  • 汚ねぇコードだな
    • すいません許してください、Advent Calendarの次の方がなんでもしますから。
  • なんで作ろうと思ったの?
    • 1つは単にMastodon見てるのにTwitterに見に行くのが不便だったからです。TwitterがActivityPubに対応してくれたら良いんですけどね。
    • もう1つは、Mastodonのあり方を自分なりに模索してみたかったからです。これについてはnullkalさんの記事に触発されました。 http://blog.nil.nu/entry/2017/12/01/211959
  • 最後に一言
    • botも記事も2日間の突貫工事でごめんなさい。多分しばらく経った頃に修正すると思います。

こちらは アイマストドン内非公式「ジョンベベベント・カレンダー」 Advent Calendar 2017 12月5日分の記事になります。
prev: わかりやすいプレゼン・ダイマ資料を作るために試行錯誤した話 author: ぽよすけ(@sand)
next: 姉の日、音の日だって author: 周平P(@syuheiP)