トゥしたらツイする Mastodon→Twitterクロスポスト（2023年版，画像あり投稿対応）

# 本稿の趣旨

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/2cff3b35-25a2-8e4d-30c5-fa47c63dbc61.png" width="250px">

1. 4ヶ月放置。
1. にも関わらず「新APIに移行してね！」メールが届く。
1. ついに旧 API が Suspend された。😊えがお

## クロスポストとは

このような環境変化に耐えかねた人々が Twitter から種々の SNS へ避難したわけですが，SNS の本質は人とのつながりです。全員が一斉に移住しない限り，これまで自分をフォローしてくれていた人とはお別れとなります。

ある SNS への投稿があると，それを他 SNS へ自動転載する手法を，ここでは**クロスポスト**と呼びます。本稿では，Mastodon へ投稿がなされると，同内容を Twitter へ転載してくれる機能を IFTTT を用いて実装します。

他の類似記事との違いは，画像のあるなしに応じて適切なアクションを選ぶようにしていることと，画像つき投稿を可能とした点です。2017 年頃にこの手の記事が書かれてから現在までに Mastodon 仕様が変わっており，それに追従しました。

# 準備

* Mastodon アカウント
* Mastodon アカウントの \</> Development で Application を作成
    * Scopes は read を許可
    * `Your access token` が必要![2023-05-23_22-35-05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/3aee830c-2db0-7c55-ad6e-d24d8464c084.png)
* IFTTT Pro+ アカウント　いきなり壁が分厚く高くなりましたね……
* Mastodon.py モジュールを使用できる Web サーバ　さらに壁を強化するスタイル
* Twitter アカウント

# 実装

## 全体の流れ

最初に処理の流れを示します。

Mastodon は投稿があると RSS 形式で更新情報を配信します。この RSS Feed を IFTTT の `New feed item` トリガーに指定し，以後の処理を行います。IFTTT には Twitter 投稿アクションが整備されており，「テキストのみ版（`Post a tweet`）」と「画像つき版（`Post a tweet with image`）」があります。本来であれば，RSS Feed から得られた `EntryImageUrl` の値を参照して画像のありなしに応じ Filter code で処理を分ければすむ……としたかったところですが，IFTTT の RSS Feed トリガーでは Mastodon の画像 URL は取得できない仕様のため[^1]，画像 URL を Webhook を利用して取得します。
![全体の流れ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/5a79faa7-7d0c-1874-ad85-be950b97759e.png)
なぜ Webhook なんだ，もっとイケてるサービスがあるだろう，というご指摘はおありかと思いますが，Google Apps Script を使いこなすのには時間がかかりそうだなと思ったのと，Jupyter Lab やら GitLab やらをすでに稼働しており，Webhook 用の HTTP サーバを準備するのにたいした手間がかからなかったので……。あと Google はよく突然のディスコンを発表するので，GAS がそうなると厄介だなと思ったのも理由です。突ディスは Twitter API だけでもうお腹いっぱいです😊

## IFTTT の設定
### アプレット

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/24cf7dfc-33d0-ed78-4d33-1a45af7c127d.png" width="250px">

Applet を作成し，以下の図のように配置します。

**Trigger (If This)** : RSS Feed - New feed item
**Action (Then That)** : Twitter - Post a tweet と Twitter - Post a tweet with image[^2]

これらを選ぶと `If` と `Then` の間に `+` ボタンが現れるので，それを押して Query と Filter を配置[^3]。

**With (Query)** : Webhooks - Make a web request with JSON response
**When (Filter code)** : 後述のスクリプトを書く。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/bf668522-d56a-d371-0bfc-fa186167dc11.png" width="500px">

### Trigger : RSS Feed - New feed item

Feed URL には Mastodon の RSS Feed URL を指定します。`https://<インスタンスのホスト>/@<ユーザ名>.rss` で取得できると思います。例えば，私のアカウントがある mstdn.jp であれば `https://mstdn.jp/@rino.rss` といった感じです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/3c2ef58c-773e-515e-1593-42a768ea8670.png" width="250px">

### Action : Twitter - Post a tweet / with image

どっちを先にしても構いません。Twitter account には IFTTT と連携している投稿先 Twitter アカウントを設定します。Tweet text は Add ingredient から `EntryContent` を選んでください。Image URL は Filter code で上書きするので何でも構いません。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/ff334a21-a80e-0e67-4353-487c5d87cc83.png" width="250px">　<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/b3da7357-97e5-ac62-ded6-276e88e66955.png" width="250px">

### With : Webhooks - Make a web request with JSON response

URL には Mastodon の投稿からメディア URL を引っ張ってくるスクリプトを置いた URL を設定します。Method は POST，Content Type は application/x-www-form-urlencoded，Body は以下のように設定します。
```json
{
  "EntryUrl": "<<<EntryUrl>>>"
}
```
なお，私はこの Webhook で本稿に書いていない処理も行っているので，実際の運用では少し違う設定をしています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/c2ddbc0e-51bf-f91f-fdfc-257f707a84bf.png" width="305px"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/4d70aba8-9d78-1138-00f1-28b4c6861ef9.png" width="300px">

### When : Filter code

以下をコピペしてください。本文中に `nocrosspost`（case-insensitive）が書いてあると，その投稿はクロスポストしないようにしてあります。

```javascript
// Tweet when toot.
// This filter code skips tweeting if 'nocrosspost' is present
// in the given content.
// Use postNewTweet() if the content does not include an image.
// Use postNewTweetWithImage() if does include.
// Note that only the first image in the content is cross-posted.
let content = Feed.newFeedItem.EntryContent;
let payload = JSON.parse(MakerWebhooks.makeWebRequestQueryJson[0].ResponseBody);
let imageUrl = `${payload.EntryImageUrl}`;
let noImage = 'no_image_card.png';

// if nocrosspost is found in the content, skip actions
if (content.toLowerCase().indexOf('nocrosspost') !== -1) {
  Twitter.postNewTweet.skip('no cross post');
  Twitter.postNewTweetWithImage.skip('no cross post');
}
// then post
else {
  // no image -> use postNewTweet()
  if (imageUrl.indexOf(noImage) !== -1) {
    Twitter.postNewTweetWithImage.skip('no image found');
  }
  // with image -> use postNewTweetWithImage()
  else {
    Twitter.postNewTweet.skip('an image is given');
    Twitter.postNewTweetWithImage.setPhotoUrl(imageUrl)
  }
}
```

## Webhook で呼び出す CGI スクリプト
### CGI スクリプト本体
以下を Webhooks - Make a web request with JSON response で指定した URL からアクセスできる場所に配置します。`pip` で `mastodon.py` や `urllib3` などを入れておいてください。また，これらのモジュールを HTTPd のユーザー（`www-data` とか）が使えるよう，`PYTHONPATH` を設定するか，system-wide にインストールするなどしてください。

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# Webhook for IFTTT
# Receive EntryUrl, send EntryImageUrl

import cgi
import dotenv
import io
import json
import mastodon
import os
import sys
import urllib.parse


# read dotenv
dotenv.load_dotenv()

# use utf-8 for stdout
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')


def retrieve(url):
    toot_id = url.split('/')[4]

    media_files = []
    try:
        # create mastodon object
        mstdn = mastodon.Mastodon(
            api_base_url=os.getenv('EIU_API_BASE_URL'),
            access_token=os.getenv('EIU_ACCESS_TOKEN'),
        )

        # obtain the content of toot_id
        status = mstdn.status(toot_id)

        # add url to media_files if images are attached
        for media in status.media_attachments:
            if media.type == 'image':
                media_files.append(media.url)

    except Exception as e:
        status = e.args

    return status, media_files



# obtain the url of newly created post
if os.environ['REQUEST_METHOD'] == 'POST':
    # parse EntryUrl
    length, _ = cgi.parse_header(os.environ['CONTENT_LENGTH'])
    query_string = sys.stdin.buffer.read(int(length))
    data = json.loads(query_string.decode('utf-8'))
    url = urllib.parse.unquote_plus(data['EntryUrl'])

    # obtain EntryImageUrl
    status, media_files = retrieve(url)
    
    # return data to IFTTT
    if not type(status) is tuple:
        for i in range(4 - len(media_files)):
            media_files.append('no_image_card.png')

        print('Content-Type: text/json; charset=utf-8')
        print('Access-Control-Allow-Origin: *')
        print("\r\n\r\n")
        print('{"EntryImageUrl" : "%s", "EntryImageUrl2" : "%s", "EntryImageUrl3" : "%s", "EntryImageUrl4" : "%s"}' %
            (media_files[0], media_files[1], media_files[2], media_files[3]))
    else:
        print('Content-Type: text/json; charset=utf-8')
        print('Access-Control-Allow-Origin: *')
        print("\r\n\r\n")
        print('{"value1" : "%s"}' % ':'.join(map(str, status)))
```

### .env
また，この CGI スクリプトと同じ，または親ディレクトリのどこかに `.env` ファイルを以下の内容で作成してください。

```config
EIU_API_BASE_URL="https://mstdn.jp" # 自分のインスタンスに合わせて変更
EIU_ACCESS_TOKEN="                " # Mastoron API 用のアクセストークン
```


# まとめ

Mastodon の投稿を，画像あるなしを判定し，Twitter へクロスポストする仕組みを IFTTT Pro+ を使って実装しました。本稿を書いていて，Mastodon の RSS Feed（XML）を解析すればもっとスマートな実装になるな，ということに気づきましたが，それはまた別の話…


[^1]: [Mastodon → Twitter の連携（IFTTT + GAS）](https://nigauri.me/tech/others/mastodon/mastodon_twitter/3/)　RSS の仕様は変更されたが，画像取得はできないままだ。

[^2]: 複数のアクションを使うのに Pro プラン契約が必要。

[^3]: Query と Filter codeを使うのに Pro+ プラン契約が必要。Twitter は無課金ユーザーを貫くが，他サービスには気前よく支払う。