TwitterOAuth で Twitter API v2 に対応してみた

地元サッカークラブのサポーターをしているのですが、マイナーなクラブだけにクラブ HP のリリースやニュースなどが Twitter 公式アカウントでツイートされないことが多く、それなら bot を作って情報発信のサポートもしようということで、非公式ながら運用開始してはや７年目のシーズンになります。
クラブ公式 HP のリリース更新をトリガーにタイトルとページリンク、画像があれば画像も含めて bot ツイートするだけです。

先日 Twitter API v1.1 のサポートが終了し、とうとう bot ツイートできなくなってしまいました。2分毎に動く自動起動バッチから大量のエラーメールが...

#### エラー履歴
|日付|経緯|対応|
|:--:|:--|:--|
|2023/05/23|Twitter API が SUSPENDED になり bot ツイートでエラーとなる|「プランが未選択」のため「Free」を選択したところ解決 ※[参考](https://sk13g.com/twitter-api-suspended/)|
|2023/07/19|TwitterAPI v1.1 サポート終了につき再び bot ツイートでエラーとなる|Twitter API  サイトで v2 用プロジェクトの作成とアプリ追加 ※[参考](https://pictblog.com/twitter-newapi)<br>TwitterOAuth を使用し API v2 対応|

#### エンドポイントサポート状況
Twitter API v1.1 と v2 のエンドポイントのサポート状況は以下の通りです(2023/07/31現在)。
|Twitter API Endopoint|　v1.1　　|　　v2　　|
|:--:|:--:|:--:|
|upload|〇|-|
|tweets|×|〇|

今まで [dg/twitter-api](https://github.com/dg/twitter-php) というライブラリを使用していましたが、すでに更新が止まっているため、Twitter API v2 対応のメジャーな [Abraham/TwitterOAuth](https://github.com/abraham/twitteroauth) に乗り換えることにしました。


## Twitter API v2 対応
### 初期化
```php
<?php
require "vendor/autoload.php";
use Abraham/TwitterOAuth/TwitterOAuth;

$consumer_key       = "your consumer key";
$consumer_secret    = "your consumer secret";
$access_token       = "your access token";
$access_token_secret = "your access token secret";

$twitter = new TwitterOAuth($consumer_key, $consumer_secret, $access_token, $access_token_secret);
```
### アップロード
```php
// media/upload v1.1
foreach ($media_url_list as $media_url) {
    $stream = file_get_contents($media_url, false, $context);
    $media_data = base64_encode($stream);

    $parameters = array('media' => $media_data);
    $twitter->setApiVersion('1.1');
    $response = $twitter->upload('media/upload', $parameters); // エラーになる

    if (isset($response->media_id_string)) {
        $media_id = $response->media_id_string;
        array_push($media_ids, $media_id);
    }
}
```
Twitter API のアップロードのエンドポイントは未だ v1.1 で v2 を指定するとエラーになるため、明示的に 1.1 を指定しています。 setApiVersion() の 1.1 と 2 の違いは URLの末尾 に .json が付くか付かないかだけです。

ただこのコードでは残念ながら TwitterOAuth::upload() メソッドでエラーとなります。
今までクラブ HP のリリースページの画像URLを file_get_contents() で読み込んで一旦ファイルには落とさずそのままアップロードしていましたが、TwitterOAuth::upload() メソッドは Base64 エンコード文字列のアップロードをサポートしておらず、ファイルパス文字列のみサポートしていますのでこのままでは動作しません。Twitter API はサポートしているのですが、 TwitterOAuth が未サポートとなります。普通にメディアのパス文字列を指定するのであれば TwitterOAuth::upload() で問題ありません。

```php
OK : $parameters = array('media' => '/path/to/media.jpg');
NG : $parameters = array('media' => $media_data);
```
またメモリを意識しないのかというご指摘には富豪的プログラミングということでご容赦ください。まあ NotChunked() を利用するのでしたらあまり変わらないかと。

2019 年あたりでは post() メソッドでサポートするような履歴 [Issues#792](https://github.com/abraham/twitteroauth/issues/792), [Support POST requests to the UPLOAD](https://github.com/abraham/twitteroauth/pull/791/commits/e275fb957cf08cd33f0e6d4d7ac981625380a8a3) があったのですが、今も実装されていないです。

一旦ファイルには落としたくないので、イレギュラーですが、 private な http() メソッドを Closure::bind() で叩く方法で逃げました。隠蔽が無駄じゃんとかはなしで笑。まあ protected でもよいのではないかと。

```php
$closure_http = function ($parameters) {
        return $this->http('POST', 'https://upload.twitter.com', 'media/upload', $parameters, false);
};

foreach ($media_url_list as $media_url) {
    $stream = file_get_contents($media_url, false, $context);
    $media_data = base64_encode($stream);

    $parameters = array('media' => $media_data);
    $twitter->setApiVersion('1.1');
    $closure_bind = Closure::bind($closure_http, $twitter, 'Abraham\TwitterOAuth\TwitterOAuth');
    $response = $closure_bind($parameters);

    if (isset($response->media_id_string)) {
        $media_id = $response->media_id_string;
        array_push($media_ids, $media_id);
    }
}
```

### ツイート
```php
// tweets v2
$parameters = array('text' => $title);
if (!empty($media_ids)) {
    $parameters['media'] = array('media_ids' => $media_ids);
}
$twitter->setApiVersion('2');
$response = $twitter->post('tweets', $parameters, true);
```
アップロード画像があれば media_ids 配列を parameters にセットして v2 を指定して POST します。 v1.1 の時は media_ids に media_id をカンマ区切りで指定しましたが、 v2 では media.media_ids に  media_id 配列を指定します。また v1.1 では media_ids 値があってもなくてもツイートできていましたが、 v2 では media_ids 値が null や空の場合はエラーとなるようです。
|Twitter API|v1.1|v2|
|:--:|:--:|:--:|
|Tweets Endopoint|[POST statuses/update](https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-update)|[POST /2/tweets](https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets)|

```php
v1.1 : $parameters = array('media_ids' => implode(',', $media_ids));
v2   : $parameters['media'] = array('media_ids' => $media_ids);
```

## 番外編 ～ TwitterOAuth の変更 ～
オリジナルなソースコードに手を加えるのはどうかとは思うのですが、当初は TwitterOAuth::uploadMediaNotChunked() メソッドを変更しようと思っていましたのでその破片も残しておきます。
##### オリジナル : TwitterOAuth.php.org
```php
    337     private function uploadMediaNotChunked(string $path, array $parameters)
    338     {
    339         if (
    340             !is_readable($parameters['media']) ||
    341             ($file = file_get_contents($parameters['media'])) === false
    342         ) {
    343             throw new \InvalidArgumentException(
    344                 'You must supply a readable file',
    345             );
    346         }
    347         $parameters['media'] = base64_encode($file);
    348         return $this->http(
    349             'POST',
    350             self::UPLOAD_HOST,
    351             $path,
    352             $parameters,
    353             false,
    354         );
    355     }
```
以下のような感じで base64 エンコード文字列のアップロードもサポートしてくれるとよいのですが。。。
##### 変更後 : TwitterOAuth.php 
```php
    337     private function uploadMediaNotChunked(string $path, array $parameters)
    338     {
    339         if (
    340             is_readable($parameters['media']) &&
    341             ($file = file_get_contents($parameters['media'])) !== false
    342         ) {
    343             $parameters['media'] = base64_encode($file);
    344         } elseif (preg_match('%^[a-zA-Z0-9/+]*={0,2}$%', $parameters['media']) &&
    345             base64_encode(base64_decode($parameters['media'], true)) === $parameters['media']) {
    346             // already base64 encoded
    347         } else {
    348             throw new \InvalidArgumentException(
    349                 'You must supply a readable file or base64 data',
    350             );
    351         }
    352         return $this->http(
    353             'POST',
    354             self::UPLOAD_HOST,
    355             $path,
    356             $parameters,
    357             false,
    358         );
    359     }
```

##### # diff -u TwitterOAuth.php.org TwitterOAuth.php
```diff_php
--- TwitterOAuth.php.org        2023-07-20 11:43:02.000000000 +0900
+++ TwitterOAuth.php    2023-07-25 12:29:53.000000000 +0900
@@ -337,14 +337,18 @@
     private function uploadMediaNotChunked(string $path, array $parameters)
     {
         if (
-            !is_readable($parameters['media']) ||
-            ($file = file_get_contents($parameters['media'])) === false
+            is_readable($parameters['media']) &&
+            ($file = file_get_contents($parameters['media'])) !== false
         ) {
+            $parameters['media'] = base64_encode($file);
+        } elseif (preg_match('%^[a-zA-Z0-9/+]*={0,2}$%', $parameters['media']) &&
+            base64_encode(base64_decode($parameters['media'], true)) === $parameters['media']) {
+            // already base64 encoded.
+        } else {
             throw new \InvalidArgumentException(
-                'You must supply a readable file',
+                'You must supply a readable file or base64 data',
             );
         }
-        $parameters['media'] = base64_encode($file);
         return $this->http(
             'POST',
             self::UPLOAD_HOST,
```

## おわりに
イーロン・マスクよ、勘弁してよ怒