RubyでTwitter APIを利用してダイレクトメッセージを送信する

[sferik/twitter](https://github.com/sferik/twitter) (6.2.0)で自分のアカウントからダイレクトメッセージ送信を試してみようとしたものの、 `Twitter::Error::NotFound (Sorry, that page does not exist.)` などと怒られて進めなかったため、自分でAPIを直に叩く実装をした。

## 環境
- Ruby 2.5.1
- Rails 5.2.0

## 成果物
- [midwhite/twitter_service.rb - gist](https://gist.github.com/midwhite/c5b6f4d4b6982d95d67630ea0c992a72)

## 事前準備
- [Twitter](https://twitter.com)でTwitterアカウントを取得する。
- [Twitter Apps](https://apps.twitter.com/)でアプリを作成してトークン類を取得する。

**注意: トークンのAccess Levelを「Read, write, and direct messages」に設定した後にAccess Tokenを再発行しないとダイレクトメッセージ操作の権限がトークンに付与されない。**

![MatchLab___Twitter_Application_Management.png](https://qiita-image-store.s3.amazonaws.com/0/131942/894da536-33c8-4581-4073-9e25f6236d63.png)

## コード
```ruby:app/services/twitter_service.rb
class TwitterService
  TWITTER_API_DOMAIN          = "https://api.twitter.com"
  TWITTER_CREATE_DM_ENDPOINT  = "#{TWITTER_API_DOMAIN}/1.1/direct_messages/events/new.json"
  TWITTER_SIGNATURE_METHOD    = "HMAC-SHA1"
  TWITTER_OAUTH_VERSION       = "1.0"
  TWITTER_CONSUMER_KEY        = ENV.fetch("TWITTER_CONSUMER_KEY")
  TWITTER_CONSUMER_SECRET     = ENV.fetch("TWITTER_CONSUMER_SECRET")
  TWITTER_ACCESS_TOKEN        = ENV.fetch("TWITTER_ACCESS_TOKEN")
  TWITTER_ACCESS_TOKEN_SECRET = ENV.fetch("TWITTER_ACCESS_TOKEN_SECRET")

  def initialize(user_id, text)
    @user_id = user_id
    @text = text
  end

  def call
    uri = URI.parse(TWITTER_CREATE_DM_ENDPOINT)

    request = Net::HTTP::Post.new(uri)
    request.content_type = "application/json"
    request.body = JSON.generate(request_body_hash)

    request["Authorization"] = authorization_value

    options = { use_ssl: true }

    response = Net::HTTP.start(uri.hostname, uri.port, options) do |http|
      http.request(request)
    end
  end

  private
  def request_body_hash
    {
      event: {
        type: "message_create",
        message_create: {
          target: { recipient_id: @user_id },
          message_data: { text: @text }
        }
      }
    }
  end

  def authorization_value
    authorization_params = signature_params.merge(
      oauth_signature: generate_signature("POST", TWITTER_CREATE_DM_ENDPOINT)
    )
    return "OAuth " + authorization_params.sort.to_h.map{|k, v| "#{k}=\"#{v}\"" }.join(",")
  end

  def signature_params
    @signature_params ||= {
      oauth_consumer_key: TWITTER_CONSUMER_KEY,
      oauth_nonce: SecureRandom.uuid,
      oauth_signature_method: TWITTER_SIGNATURE_METHOD,
      oauth_timestamp: Time.zone.now.to_i,
      oauth_token: TWITTER_ACCESS_TOKEN,
      oauth_version: TWITTER_OAUTH_VERSION
    }
  end

  def oauth_values
    values = signature_params.sort.to_h.map {|k, v| "#{k}=#{v}" }.join("&")
    ERB::Util.url_encode(values)
  end

  def generate_signature(method, url)
    signature_data = [method, ERB::Util.url_encode(url), oauth_values].join("&")
    signature_key = "#{ERB::Util.url_encode(TWITTER_CONSUMER_SECRET)}&#{ERB::Util.url_encode(TWITTER_ACCESS_TOKEN_SECRET)}"
    signature_binary = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, signature_key, signature_data)
    ERB::Util.url_encode(Base64.strict_encode64(signature_binary))
  end
end
```

## 要点
### リクエストボディ
Twitter API経由でDirect Messageを送信するためには、Twitter社が `Event Object` と呼ぶ形式のJSONを送信する必要がある。それがこの部分。
**注意: recipient_id にはメンション等に使うアカウント名 (screen_name) ではなく、Twitter側で管理されている user_id を用いる必要がある。** お手軽に調べるなら [Twitter IDチェッカー](https://idtwi.com/) が便利。

```ruby
  def request_body_hash
    {
      event: {
        type: "message_create",
        message_create: {
          target: { recipient_id: @user_id },
          message_data: { text: @text }
        }
      }
    }
  end
```

| key | value |
|:--:|:--|
| `event.type` | `"message_create"` 固定 |
| `target.recipient_id` | メッセージの宛先ユーザーID |
| `message_data.text` | メッセージ内容 |

何やら別APIで画像や動画をアップロードしてから `media: { attachment: { type: "media", media: { id: @media_id } } }` のようなプロパティを追加すると、ファイルを添付してメッセージを送信できるようです。 [（参考）](https://developer.twitter.com/en/docs/direct-messages/message-attachments/guides/attaching-media)

### Authorizationヘッダ
Authorizationヘッダには `OAuth xxx=aaa,yyy=bbb,zzz=ccc` みたいな文字列を入れる必要がある。各パラメータについては下記の通り。

| key | value |
|:---:|:------|
| oauth_consumer_key | [Twitter Apps](https://apps.twitter.com/)で取得したConsumer Key (API Key) |
| oauth_nonce | APIリクエストごとにユニークな文字列。 |
| oauth_signature_method | 署名の形式。 "HMAC-SHA1" 固定。 |
| oauth_timestamp | タイムスタンプ（数字）。 |
| oauth_token | [Twitter Apps](https://apps.twitter.com/)で取得したAccess Token。 |
| oauth_version | OAuth認証形式のバージョン。 "1.0" 固定。 |
| oauth_signature | 認証用の署名（詳細は後述）。Base64エンコードされた文字列。 |

### 署名(signature)
この **署名** が最大の難所だったので、[公式](https://developer.twitter.com/en/docs/basics/authentication/guides/creating-a-signature.html)に則って順を追って説明していく。

#### 必要な手順
##### 認証用情報を生成
Authorizationヘッダに含める情報のうち、 `oauth_signature` を除く6つのパラメータをキーの名前順に並べて `"&"` で結合し、さらにURLエンコードした文字列を用意する。それがこの部分。

```ruby
def oauth_values
  values = signature_params.sort.to_h.map {|k, v| "#{k}=#{v}" }.join("&")
  ERB::Util.url_encode(values)
end
```

##### 署名用の文字列を生成
リクエストメソッド（今回は `"POST"` ）、リクエストURL（クエリパラメータ無し、1回エンコード）、そして先ほど生成した認証用情報の3つを `"&"` で結合する。それが `def generate_signature` 内のこの部分。

```ruby
signature_data = [method, ERB::Util.url_encode(url), oauth_values].join("&")
```

##### Signing key計算用の鍵を生成
[Twitter Apps](https://apps.twitter.com/)で取得したAccess Token SecretとConsumer Secret (API Secret)をそれぞれURLエンコードしてから `"&"` で結合する。それがこの部分。

```ruby
signature_key = "#{ERB::Util.url_encode(TWITTER_CONSUMER_SECRET)}&#{ERB::Util.url_encode(TWITTER_ACCESS_TOKEN_SECRET)}"
```

##### Signing keyを生成
```ruby
signature_binary = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, signature_key, signature_data)
```

##### signatureを生成
最後にSigning keyをBase64エンコードした上でもう一度URLエンコードすると signature が完成です。それがこの部分。

```ruby
ERB::Util.url_encode(Base64.strict_encode64(signature_binary))
```

#### ポイント
- URLエンコード時は `ERB::Util.url_encode` を用いる。 [（参考）](https://riocampos-tech.hatenablog.com/entry/20131025/p1)
- 今回は関係ないが、 GETリクエスト時にクエリパラメータを含む場合は `def oauth_values` 内で `signature_params` に加えてクエリパラメータも一緒にソート・結合・エンコードの処理を行う必要がある。
- `Content-Type: application/x-www-form-urlencoded` の場合も、リクエストパラメータを上記のように追加してソート・結合・エンコードを行う必要がある。

## 実行
こういう風に書けば実行できる。

```ruby
TwitterService.new(user_id, text).call
```

`Could not authenticate you` みたいなエラーが返ってきたら、アクセストークン類の再発行とか試してみてください。

## 余談（背景）
なぜこんな実装が必要になったのかというと、Twitterの自分のアカウントからダイレクトメッセージを送信する機能を実装しようとして、[sferik/twitter](https://github.com/sferik/twitter) (6.2.0) を使って実装してみたところエラーになったから。

```ruby
client = Twitter::REST::Client.new do |config|
  config.consumer_key        = TWITTER_CONSUMER_KEY
  config.consumer_secret     = TWITTER_CONSUMER_SECRET
  config.access_token        = TWITTER_ACCESS_TOKEN
  config.access_token_secret = TWITTER_ACCESS_TOKEN_SECRET
end

client.create_direct_message(user_id, message)
```

```bash
Twitter::Error::NotFound (Sorry, that page does not exist.)
```

`page does not exist` って何だと思ってgemの中のコードを読んでみると、こんなコードを見つけた。

```ruby:ruby/2.5.0/gems/twitter-6.2.0/lib/twitter/rest/direct_messages.rb
def create_direct_message(user, text, options = {})
  options = options.dup
  merge_user!(options, user)
  options[:text] = text
  perform_post_with_object('/1.1/direct_messages/new.json', options, Twitter::DirectMessage)
end
```

[Twitter公式](https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event.html)によると、正しいエンドポイントは `direct_messages/events/new` らしいので、試しに上記コードのパス部分を変更してみても、 `Twitter::Error::Forbidden ()` とか言われる。
実装したかったのはダイレクトメッセージ送信機能だけだったので、ライブラリなしでTwitter APIを直に叩こうと思い立ったことが今回の実装の背景になります。

## 余談
Authorizationヘッダに入れる文字列を簡単に生成できる [laserlemon/simple_oauth](https://github.com/laserlemon/simple_oauth) というgemを発見してしまったので、こっちを使った方が楽だったかもしれない。
（[sferik/twitter](https://github.com/sferik/twitter)の中でAuthorizationヘッダ生成用に使われていた）