Ruby (x gem) を使ったTwitter(X) APIへの画像付き投稿:知られざる落とし穴と完全解決ガイド

2026/04/11

Ruby
API
Twitter

こんにちは。

Twitter (現X) API v2を使って画像付きのツイートを自動投稿しようとした際、Rubyの x gem (v0.19.0) を利用して実装を進めました。しかし、公式ドキュメント通りに進めても数々の不可解なエラーやサイレント(通知なしの)失敗に遭遇してしまいました。

この記事では、今回直面した**「5つの絶望的なハマりポイント」と、それをすべて乗り越えた「最終的な正解コード」**をご紹介したいと思います。これから実装する方の参考になれば幸いです。


TL;DR

  • x gem (v0.19.0) を使う場合、画像アップロードもデフォルトクライアントに任せるのが正解です。
  • multipart/form-data の手動構築や署名計算を自前でやろうとすると沼にハマります。
  • x gemの仕様で、メディアIDのレスポンスキーが media_id_string から id に書き換えられる点に要注意です。

開発環境

  • Ruby 3.x / Ruby on Rails
  • x gem (v0.19.0)
  • Twitter API v2 (ツイート用) / v1.1 (メディアアップロード用)

遭遇したエラーと真の原因

開発中に遭遇した5つのハマりポイントとその原因を順番に見ていきましょう。

1. 410 Gone エラー(廃止されたエンドポイントの踏み抜き)

画像アップロードには upload.twitter.com/1.1/ のエンドポイントが必要です。ツイート用の標準クライアントとは異なるベースURLであるため、アップロード専用の別クライアント(@upload_client)を生成して X::MediaUploader.upload_binary に渡しました。しかし結果は 410 Gone(既に廃止されています)となってしまいました。

【原因】
実は x gem (v0.19.0) は非常に優秀で、デフォルトのクライアント(@client)のまま MediaUploader に渡せば、内部で自動的に画像サーバーの正しいURLにルーティングしてリクエストを送ってくれる仕様になっていました。自前でベースURLを書き換えたクライアントを渡すと、Gem内部のURL構築ロジックと衝突し、存在しない(または古い)URLを叩いてしまっていたのです。

2. 401 Unauthorized エラー(OAuth 1.0a署名の不一致)

Gemの挙動がブラックボックス化されているのを避けようと、自前でBase64エンコードし、x-www-form-urlencoded 形式で直接APIを叩こうとしました。しかしこれも認証エラーになります。

【原因】
OAuth 1.0aの仕様上、リクエストボディを x-www-form-urlencoded で送信すると、「送信する全データ(莫大な画像データを含む)」が署名(Signature)の計算パラメータに含まれてしまいます。プログラム側とTwitter側での署名計算にズレが生じ、問答無用で弾かれていました(multipart/form-data であれば署名計算から除外される仕様です)。

3. media type unrecognized (画像として認識してくれない)

multipart/form-data を手動構築して生のバイナリデータ(File.binread)を送信した際に出たエラーです。

【原因】
ここには2つの絶望的なトラップがありました。

  1. Rubyの文字列表現へのバイナリ結合によるデータ破損
    "#{File.binread(path)}" のように、UTF-8などの文字列の中に生のバイナリデータを展開すると、Ruby側で対応できないバイト列が自動的に代替文字(文字化け)に書き換えられてしまいます。結果として画像ファイルのヘッダー情報(マジックナンバーなど)が破壊され、Twitterから「画像じゃない」と判定されていました。

4. ターミナルで undefined method 'bytesize' for an instance of Hash

ツイートをポストする際、@client.post('tweets', tweet_body) とHashのまま渡すと落ちる現象です。

【原因】
x gem は薄いラッパーであるため、リクエストボディは自分でJSON文字列に変換して(.to_json を使って)渡す必要がありました。

5. 【最恐】エラーなしで成功するのに、画像がツイートに付かない

ようやく全てのエラーを突破し、APIからの戻り値も {"data" => {"id" => "...", "text" => "..."}} と成功ステータスに。しかし、実際のTwitterタイムラインを見ると文章だけで画像が添付されていませんでした。

【原因】
公式のTwitter API v1.1 ドキュメントには「画像アップロードの戻り値には media_id_string というキーでIDが入っている」と書かれています。
しかし、x gem の内部仕様により、取得したデータが抽象化され、id という名前に自動的に書き換えられて返却されていました!

そのため、response['media_id_string'] でIDを取得しようとすると nil (空)になり、プログラムは「画像IDはないのか」と判断してサイレントに(ひっそりと)テキストだけでツイートを成功させていたのです。


最終的な正解コード

これらすべての落とし穴を回避し、ローカルファイルパスとリモートURL(CDNなどの画像)の両方に対応した、究極の Notifier::X 実装がこちらです。

# app/models/notifier/x.rb
# frozen_string_literal: true

require 'x/media_uploader'
require 'open-uri'

class Notifier::X
  attr_accessor :client

  def initialize
    # ツイート用、アップロード用問わず、この1つのクライアントで全て完結します
    @client = ::X::Client.new(
      api_key:             Rails.application.credentials.dig(:twitter, :api_key),
      api_key_secret:      Rails.application.credentials.dig(:twitter, :api_key_secret),
      access_token:        Rails.application.credentials.dig(:twitter, :access_token),
      access_token_secret: Rails.application.credentials.dig(:twitter, :access_token_secret)
    )
  end

  def send(message, image_path: nil)
    media_id = upload_media(image_path) if image_path.present?

    tweet_body = { text: message }
    tweet_body[:media] = { media_ids: [media_id] } if media_id

    # POSTデータのボディは文字列(JSON)を要求されるため、.to_jsonで変換
    @client.post('tweets', tweet_body.to_json)
  end

  private

  def upload_media(image_source)
    # URL指定(http...)と、ローカルパス指定の両対応
    file_content = if image_source.match?(/\Ahttps?:\/\//)
                     URI.open(image_source).read
                   else
                     File.binread(image_source)
                   end

    # MediaUploaderにデフォルトクライアントをそのまま渡すのが正解
    response = ::X::MediaUploader.upload_binary(
      client: @client,
      content: file_content,
      media_category: 'tweet_image'
    )
    
    # 【超重要】 x gem (v0.19.0) では 'media_id_string' ではなく 'id' で受け取る
    media_id = if response.is_a?(Hash)
                 response['id'] || response[:id]
               elsif response.respond_to?(:id)
                 response.id
               elsif response.respond_to?(:[])
                 response['id'] || response[:id]
               end

    media_id.to_s
  end
end

まとめ

APIラッパーのGemを利用する場合、公式APIドキュメントのレスポンス構造と、Gemが独自に抽象化したレスポンス構造に乖離があることに注意が必要ですね。
今回のような深いデバッグが必要になったとしても、ログ出力を用いて生のレスポンスデータを追うことの重要性を改めて実感しました。

この記事が、同じように x gem で画像付きツイートを実装しようとしてハマっている方の助けになれば嬉しいです。

Related Posts

ChatGPT APIで遊んでみた 〜環境構築〜

ChatGPT APIで遊んでみた 〜環境構築〜

.envを導入してみた

.envを導入してみた