Go言語で画像付きのツイートを投稿する

# 初めに
個人で和歌をテーマにした以下のスマホ向けのおみくじアプリを開発・運用しています。

<img width="130" alt="Screenshot_home.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/d4a6102b-53cd-eb2c-f333-5e23392e7f29.png">
<img width="130" alt="Screenshot_fortune_1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/ccc35e8e-f92e-308a-3fb8-8d194e2edc16.png">
<img width="130" alt="Screenshot_fortune_2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/c6959d28-9900-9789-84b1-d909607acc40.png">
<img width="130" alt="Screenshot_emperor_list.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/34b47a38-04e2-1ac9-baf8-6da0e8848086.png">
<img width="130" alt="Screenshot_emperor_detail.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/d68400a0-2926-1b1c-764f-76efaa7729e3.png">

[iOS]
https://apps.apple.com/us/app/%E5%A4%A7%E5%BE%A1%E5%BF%83%E3%82%A2%E3%83%97%E3%83%AA/id1627544916

[Android]
https://play.google.com/store/apps/details?id=jp.sikisimanomiti.oomigokoro

今回、アプリで扱っている御製（歴代天皇が詠んだ和歌）をアプリを利用していない人々にも届けるために、定期的にX（旧Twitter）に自動で投稿する機能を実装しました。

# 構成
実装後の構成としては下図のようになりました（赤枠で囲んだ部分が今回追加した箇所になります）。

![バックエンド構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/bc8187e3-4ef3-b695-f848-6aef6335ef58.png)

上図を実現するために以下の機能を追加しました。

- バックエンドAPI
  - DBからランダムに和歌を1首取得して歴代天皇の画像付きツイートをXへポストする機能
- インフラ
  - Cloud Schedulerで毎日AM06:00に上記追加したAPIをコールする機能

バックエンドAPIはGo言語で実装していて、画像付きでXへポストする機能を既存のクライアントライブラリでは実現できなかったので自分でXのAPIを呼んで実装しました。備忘として実装したコードを残して

# 実装
### ①画像付きツイートを投稿する
Xのツイートを投稿するAPIのドキュメント（V1.1とV2.0）を確認すると、画像付きのツイートを投稿することはできません。また、[X API platform resources](https://developer.x.com/en/docs/x-api/getting-started/about-x-api#item2)によると、画像をアップロードするAPIはV2.0ではまだ存在しないようでV1.1のAPIを使うしかありませんが、ツイートを投稿するAPIはV2.0を利用することにします（いつ廃止になるか分からないので）。

現状、Xに画像付きのツイートを投稿するなら以下の手順で行うことになると思います。

1. V1.1のメディアアップロードのAPIで画像を投稿
1. 1のAPI呼び出しで取得したmedia_idを付与してツイートを投稿

上記手順で実行するGo言語のコードを以下に掲載します。

```golang:golang
import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"github.com/caarlos0/env"
	"github.com/dghubble/oauth1"
	"io"
	"log"
	"mime/multipart"
	"net/http"
	"os"
)

type config struct {
	ConsumerKey       string `env:"CONSUMER_KEY,required"`
	ConsumerSecret    string `env:"CONSUMER_SECRET,required"`
	AccessToken       string `env:"ACCESS_TOKEN,required"`
	AccessTokenSecret string `env:"ACCESS_TOKEN_SECRET,required"`
}

const (
	// 画像をアップロードするAPIのエンドポイント
	UploadMediaEndpoint = "https://upload.twitter.com/1.1/media/upload.json"
	// ツイート投稿用のAPIのエンドポイント
	ManageTweetEndpoint = "https://api.twitter.com/2/tweets"
)

func PostPoemToX(ctx context.Context) error {
	// 環境変数の読み込み
	var cfg config
	if err := env.Parse(&cfg); err != nil {
		log.Fatal(err)
	}

	// アップロードする画像のファイルパスを指定
	filePath := "images/sample.jpg"

	// OAuth1のクライアントを作成
	config := oauth1.NewConfig(cfg.ConsumerKey, cfg.ConsumerSecret)
	token := oauth1.NewToken(cfg.AccessToken, cfg.AccessTokenSecret)
	httpClient := config.Client(oauth1.NoContext, token)

	// ファイルを開く
	file, err := os.Open(filePath)
	if err != nil {
		log.Fatal("Error opening file:", err)
		return err
	}
	defer file.Close()

	// マルチパートフォームデータのバッファを作成
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)

	// ファイルパートを作成
	part, err := writer.CreateFormFile("media", filePath)
	if err != nil {
		log.Fatal("Error creating form file:", err)
		return err
	}

	// ファイルの内容をコピー
	_, err = io.Copy(part, file)
	if err != nil {
		log.Fatal("Error copying file content:", err)
		return err
	}

	// 書き込みを完了
	err = writer.Close()
	if err != nil {
		log.Fatal("Error closing writer:", err)
		return err
	}

	// 画像をアップロードするリクエストを作成
	req, err := http.NewRequest("POST", UploadMediaEndpoint, body)
	if err != nil {
		log.Fatal("Error creating request:", err)
		return err
	}

	// ヘッダーを設定
	req.Header.Set("Content-Type", writer.FormDataContentType())

	// リクエストを送信
	resp, err := httpClient.Do(req)
	if err != nil {
		log.Fatal("Error sending request:", err)
		return err
	}
	defer resp.Body.Close()

	// レスポンスの内容を読み取る
	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal("Error reading response:", err)
		return err
	}

	// レスポンスからmedia_idを取得
	mediaID, err := extractMediaID(string(respBody))
	if err != nil {
		log.Fatal("Failed to extract media ID:", err)
		return err
	}

	// JSONにエンコード
	tweetBody, err := json.Marshal(map[string]interface{}{
		"text": "Hello, X!",
		"media": map[string]interface{}{
			"media_ids": []string{mediaID},
		},
	})
	if err != nil {
		log.Fatal("Error marshaling tweet data:", err)
		return err
	}

	// ツイートを送信するリクエストを作成
	req, err = http.NewRequest("POST", ManageTweetEndpoint, bytes.NewBuffer(tweetBody))
	if err != nil {
		log.Fatal("Error creating tweet request:", err)
		return err
	}

	// ヘッダーを設定
	req.Header.Set("Content-Type", "application/json")

	// リクエストを送信
	resp, err = httpClient.Do(req)
	if err != nil {
		log.Fatal("Error sending tweet request:", err)
		return err
	}
	defer resp.Body.Close()

	// レスポンスの内容を読み取る
	respBody, err = io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal("Error reading tweet response:", err)
		return err
	}

	return nil
}

type UploadMediaResponse struct {
	MediaIDString string `json:"media_id_string"`
}

// JSONレスポンスからmedia_idを抽出
func extractMediaID(respBody string) (string, error) {
	var uploadResponse UploadMediaResponse

	// JSONレスポンスを構造体にデコード
	err := json.Unmarshal([]byte(respBody), &uploadResponse)
	if err != nil {
		return "", fmt.Errorf("failed to parse JSON response: %w", err)
	}

	// media_id_stringを返す
	return uploadResponse.MediaIDString, nil
}

```

### ②Cloud Schedulerで毎日AM06:00にAPIをコール
インフラはTerraformを使って構築しているので、毎日AM06:00にAPIをコールするCloud Schedulerを以下のTerraformのコードで構築しました。

```terraform:terraform
resource "google_cloud_scheduler_job" "post-tweet-job" {
  name             = "post-tweet-job"
  description      = "post tweet job"
  schedule         = "0 6 * * *"
  time_zone        = "Asia/Tokyo"
  attempt_deadline = "320s"

  retry_config {
    retry_count = 1
  }

  http_target {
    http_method = "POST"
    uri         = "https://example.jp/api/poems"
  }
}
```

# まとめ
APIがV1.1とV2.0とあって、できることが微妙に違うので、ちょっと迷いましたが、最終的に画像付きツイートを投稿できるようになったので良かった。

# 参考
- [Twitter API v2 では画像付きツイート、というか画像(メディア)のアップロードが出来ないらしい件](https://zenn.dev/amay077/scraps/c4623c216a90cf)
- [Goでライブラリを使用せずTwitterAPIを実装する](https://qiita.com/ppco/items/8bf22a7bde9be13c22f1)