5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-12-04

#前置き
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"を選び実行:arrow_forward:
その後「表示」→「ログ」に出てくる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 を指定して実行:arrow_forward:、ログに出たURLにアクセスし承認を押した後、出てきたやつを authorization_code の中に入れてから authorize2 を指定して実行:arrow_forward:することで、ログに 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)

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?