積雲が映像制作したMV『RANGEFINDER』公開中
専門88IO

Rustでツイート(Twitter API v2とOAuth 1.0a)

専門
スポンサーリンク

スライド

部活内の発表に使ったスライドです。本記事はこのスライドを文章に起こしたものになります。

スポンサーリンク

参考記事

経緯

2021年8月、Discord APIラッパーであるdiscord.pyの開発終了による激震が走った。

以前開発していたツイッター投稿Botや通話強制切断Botはdiscord.pyを使用している。

【Discord】VC強制切断ボット制作 ー導入ー
前年にオンライン飲み会が開催された話を聞いた。実地開催と異なり門限も終電も閉店もないので、結局1日に渡って飲み食い談笑したらしい。 この話をきっかけにボイスチャットから強制的に切断してくれるボットを作ろうと考えていた。今更動き出した訳だけど...

フォークされたライブラリとしてpycordやdysnakeといったライブラリがあるが、スラッシュコマンドの実装方法等に思想の違いがみえる。安定するまで保留したいところ。

Discord APIラッパーとして他の主要ライブラリとして挙げられるのはdiscord.js。スラッシュコマンドも対応しておりドキュメントや参考記事が豊富。

しかし最近興味のあるRustにもserenityというラッパーがあったため、書き換えるなら入門を兼ねてRustに決定。

通話強制切断Botは非同期処理や切断予定の保持、時間計算など調べることが多く、まずはツイッター投稿BotからRustで書き換えることにした。

そこでTwitter APIから手を付けた。Rustにはtweepyみたいに簡単にTwitter APIを叩けるライブラリ(クレート)がない。自分で認証を実装する必要がある。

プロジェクト

プロジェクトはGitHubレポジトリを参照。

GitHub - 88IO/tweers: An easy-to-use Rust library for accessing the Twitter API.
An easy-to-use Rust library for accessing the Twitter API. - 88IO/tweers

雛形は以下の通り。機能追加によって変更される可能性あり。

  •  /
    • src/
      • lib.rs(Twitter APIラッパー)
    • examples/
      • tweet-demo.rs
    • Cargo.toml
    • Cargo.lock
    • .gitignore

Twitter API

Twitter APIにはv1.1v2があり、HTTPメソッドやエンドポイント、パラメータの指定方法が異なる。

Google検索で見つかる情報の多くはv1.1。混ぜるな危険。

ツイートやツイート削除のリクエストは以下の通り。v2はツイート削除にPOSTメソッドではなくDELETEメソッドを送るようになり分かりやすくなった。また、v1.1はパラメータを全てクエリに追加していたが、v2はBodyにJSONを渡すようになり統一感が出た。

今回はv2の仕様に沿って実装することにする。

Rustで利用するクレート

Cargo.tomlに記述している。

各クレートの用途を簡単に記載する。

HTTPリクエストreqwest
UNIX時刻取得chrono
JSON取得serde, serde_derive, serde_json
パーセントエンコードpercent-encoding
HMAC-SHA1hmac-sha1
BASE64base64

Twitter OAuth 1.0a

Twitter APIの認証形式。読み取りだけならOAuth 2.0で実装が容易だが、ツイートする際はOAuth 1.0aが要求される。

認証方法はドキュメント通り。

OAuth 1.0a
OAuth 1.0aは、Twitter APIでの認証のためのユーザー認証メソッドです。このメソッドを使用すると、認証を受けたアプリがユーザーの代わりを務めることができます。また、広告APIやメディアAPIなど多くのTwitter APIを使用する際に必要となります。

まず、前提としてTwitter Developerの登録が必要。承認後v2対応のアプリケーションを作成し、

  • Consumer key
  • Consumer secret
  • Access token
  • Access token secret

の4つの文字列を生成する。

OAuth 2.0は上2つのみ用い、OAuth 1.0aは4つ全て使う。APIを叩くうえで毎回使うので構造体で保持することにした。

struct Twitter {
  consumer_key: String,
  consumer_secret: String,
  access_token_key: String,
  access_token_secret: String
}

以降OAuth 1.0aの認証はOAuthシグネチャ(署名)の計算が大きな壁となる。シグネチャ計算のやツイートリクエストのメソッドはこの構造体に実装していく。

impl Twitter {
  pub fn new(consumer_key: String, consumer_secret: String,
             access_token_key: String, access_token_secret: String)
             -> Twitter {
    Twitter {consumer_key,    consumer_secret, 
             access_token_key, access_token_secret}
  }
  ...
}

OAuthパラメータ

認証はOAuthパラメータをヘッダに付与することで行う。最小構成は以下の通り。

oauth_nonceリクエスト毎に一意の文字列
oauth_timestamp現在のUNIX時間
oauth_signature_method”HMAC-SHA1″
oauth_version“1.0”
oauth_consumer_keyConsumer key
oauth_tokenAccess token
oauth_signature上記パラメータをもとに計算

RustでUNIX時間を取得するにはchronoクレートを利用する。

use chrono::Utc;

let timestamp = format!("{}", Utc::now().timestamp());

oauth_nonceはリクエスト毎に一意であればよいのでUNIX時間と同じ値を使える。OAuthパラメータは以降昇順で扱うためstd::collections::BTreeMapを用いる。

use std::collections::BTreeMap;

let mut oauth_params: HashMap<&str, &str> = HashMap::new();
oauth_params.insert("oauth_nonce", &timestamp);
oauth_params.insert("oauth_timestamp", &timestamp);
oauth_params.insert("oauth_signature_method", "HMAC-SHA1");
oauth_params.insert("oauth_version", "1.0");
oauth_params.insert("oauth_consumer_key", &self.consummer_key);
oauth_params.insert("oauth_token", &self.access_token_key);

OAuthシグネチャの計算

OAuthシグネチャの計算フローは以下の通り。v2はv1.1と異なりjsonパラメータを用いないことに注意。

一番左のブロックの値は全て分かっているので他4項目について記載する。

今回は以下パラメータを用い、v2のツイートリクエストを例にシグネチャ計算を説明する。

HTTPメソッド“POST”  ※大文字
エンドポイント“https://api.twitter.com/2/tweets”
oauth_nonce“1640553392”
oauth_timestamp“1640553392”
oauth_signature_method”HMAC-SHA1″
oauth_version“1.0”
oauth_consumer_key“cChZNFj6T5R0TigYB9yd1w”
oauth_token“NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0”
Consumer secret“kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw”
Access token secret“LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE”

補題:RFC3986に基づくパーセントエンコード

HTTPリクエストやOAuthシグネチャの計算過程でパーセントエンコード(URLエンコード)が必要となる。

Twitter APIではRFC3986という規格に基づいたパーセントエンコードを行う必要があり、これは

  • *(アスタリスク)
  • ー(ハイフン)
  • _(アンダースコア)
  • .(ピリオド)

の文字を除外して適用するパーセントエンコードである。(可読性のため全角で表記)

Rustではpercent-encodingというクレートがあり、コードのように除外する文字を指定できる。

use percent_encoding::{utf8_percent_encode, AsciiSet};

const FRAGMENT: AsciiSet = percent_encoding::NON_ALPHANUMERIC
                             .remove(b'*')
                             .remove(b'-')
                             .remove(b'_')
                             .remove(b'.');

utf8_percent_encodeメソッドの第2引数でFRAGMENTを指定することでRFC3986に基づいたパーセントエンコードを行う。

1.シグネチャキーの生成

シグネチャキーは比較的容易に生成できる。

  1. Consumer secretとAccess token secretに対してRFCに基づいたパーセントエンコード
  2. 「&」で結合

Rustで実装したコードを以下に示す。

let key: String = format!("{}&{}",
      utf8_percent_encode(consummer_secret, &Self::FRAGMENT),
      utf8_percent_encode(access_token_secret, &Self::FRAGMENT));
(例)シグネチャキー:
kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw&LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE

2.パラメータ文字列の生成

ここでの「パラメータ」とは、v1ではOAuthパラメータとクエリパラメータの集合であり、v2ではOAuthパラメータの集合を指す。

  1. パラメータの各キーと値に対してRFC3986に基づいたパーセントエンコード
  2. パラメータとキーで昇順ソートして key1=val1&key2=val2&… のように「=」と「&」で結合

これをRustで実装したコードを以下に示す。またキーはエンコードの対象にならないので先に昇順ソートしてからパーセントエンコードと結合を適用している。

// エンコードして結合
let param_string = params
  .iter()
  .map(|(key, value)| {
    format!("{}={}",
            utf8_percent_encode(key, &Self::FRAGMENT),
            utf8_percent_encode(value, &Self::FRAGMENT))
  })
  .collect::<Vec<String>>()
  .join("&");
(例)昇順ソート後:
oauth_consumer_key“cChZNFj6T5R0TigYB9yd1w”
oauth_nonce“1640553392”
oauth_signature_method”HMAC-SHA1″
oauth_timestamp“1640553392”
oauth_token“NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0”
oauth_version“1.0”
(例)パラメータ文字列:
oauth_consumer_key=cChZNFj6T5R0TigYB9yd1w &oauth_nonce=1640553392&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1640553392&oauth_token=NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0 &oauth_version=1.0

3.シグネチャベースの生成

ここではHTTPメソッド、エンドポイント、パラメータ文字列の3つを用いる。HTTPメソッドは大文字であることに注意。

HTTPメソッド(method)“POST”
エンドポイント(endpoint)“https://api.twitter.com/2/tweets”
パラメータ文字列(param_string)“oauth_consumer_key=cChZNFj6T5R0TigYB9yd1w &oauth_nonce=1640553392
&oauth_signature_method=HMAC-SHA1
&oauth_timestamp=1640553392
&oauth_token=NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0 &oauth_version=1.0”
  1. エンドポイントURLとパラメータ文字列をRFC3986に基づいてパーセントエンコード
    • パラメータ結合に用いた「&」がエンコードされる
  2. HTTPメソッド、エンドポイント、パラメータ文字列を「&」で結合

Rustで実装したコードを以下に示す。

let base = format!("{}&{}&{}",
      utf8_percent_encode(method, &Self::FRAGMENT),
      utf8_percent_encode(endpoint, &Self::FRAGMENT),
      utf8_percent_encode(&param_string, &Self::FRAGMENT));
(例)エンドポイントのエンコード:
https%3A%2F%2Fapi.twitter.com%tweets
(例)パラメータ文字列のエンコード:
oauth_consumer_key%3DcChZNFj6T5R0TigYB9yd1w%26oauth_nonce%3D1640553392%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1640553392%26oauth_token%3DNPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0%26oauth_version%3D1.0
(例)結合:
POST&https%3A%2F%2Fapi.twitter.com%tweets&oauth_consumer_key%3DcChZNFj6T5R0TigYB9yd1w%26oauth_nonce%3D1640553392%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1640553392%26oauth_token%3DNPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0%26oauth_version%3D1.0

4.シグネチャの生成

パラメータ文字列やシグネチャベースの生成は少々複雑だったが、ここまでこればシグネチャは簡単に計算できる。

  1. シグネチャベースとシグネチャキーをHMAC-SHA1でハッシュ化
  2. (1)で得たバイナリ文字列をBASE64でエンコードシグネチャ完成

Rustで実装したコードは以下の通り。hmac-sha1クレートとbase64クレートを使えばいいのでメソッドを適用するだけ。

use base64;
use hmacsha1;

let hash = hmacsha1::hmac_sha1(key.as_bytes(), data.as_bytes());
let signature = base64::encode(&hash);

以上の手順1〜4をget_oauth_signatureメソッドとしてまとめた。

fn get_oauth_signature(
      &self, method: &str, endpoint: &str,
      consumer_secret: &str, access_token_secret: &str,
      params: &BTreeMap<&str, &str>)
      -> String {
  ...
}

取得したOAuthシグネチャをHashMapに追加してヘッダーの生成に用いる。

let oauth_signature = self.get_oauth_signature(
      method, endpoint, 
      &self.consumer_secret, &self.access_token_secret, 
      &oauth_params);

oauth_params.insert("oauth_signature", &oauth_signature);
(例)HMAC-SHA1でハッシュ化:

b2d9affbdd0596a81a8c07603a589e18389d7736
(例)BASE64でエンコード:

YjJkOWFmZmJkZDA1OTZhODFhOGMwNzYwM2E1ODllMTgzODlkNzczNg==

リクエストヘッダーの生成

上で計算したシグネチャを含むOAuthパラメータを用いてHTTPリクエストのヘッダーを生成する。

Authorizationリクエストヘッダーの値として、”OAuth”以降にOAuthパラメータを「<キー>=”<値>”」のようにカンマ区切りで並べる。※値を””で囲う点に注意

またBodyにJSONパラメータを入れるのでContent-Typeの指定も行う。

Authorization: OAuth
oauth_consumer_key=“cChZNFj6T5R0TigYB9yd1w”,
oauth_nonce=“1640553392”,
oauth_signature_method=“HMAC-SHA1”,
oauth_timestamp=“1640553392”,
oauth_token=“NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0”,
oauth_version=“1.0”,
oauth_signature=“YjJkOWFmZmJkZDA1OTZhODFhOGMwNzYwM2E1ODllMTgzODlkNzczNg==”
Content-Type: application/json

これをRustではreqwestクレートのHeaderMapを用いて実現する。

Rustによる実装ではAuthorizationの値を生成するget_request_headerメソッドを作成した。

fn get_request_header(&self, method: &str, endpoint: &str) -> String {
  // ...
  let oauth_header = format!( 
    "OAuth {}", 
    oauth_params
      .into_iter()
      .map(|(key, value)| { 
        format!(r#"{}="{}""#, 
          utf8_percent_encode(key, &Self::FRAGMENT),
          utf8_percent_encode(value, &Self::FRAGMENT)) 
      }) 
     .collect::<Vec<String>>() 
      .join(", ")
  )
}

リクエストヘッダーはreqwestクレートのHeaderMapを用いて実現した。

use reqwest::header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE};

let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, header_auth.parse().unwrap());
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());

ツイートリクエスト

以下の画像はツイート内容を「本文」としたときの例である。

※v2と異なりv1.1ではツイート内容をクエリで指定したため事前にパーセントエンコードする必要がある

これをRustでpostメソッドとして実装した。HTTPリクエストにはreqwestクレート、返ってきたJSONのデシリアライズにserde_jsonクレートを用いる。

pub async fn post(&self, path: &str, params: HashMap<&str, &str>) -> Result<Value, reqwest::Error> { 
  let endpoint = format!("https://api.twitter.com/2/{}", path); 

  let header_auth = self.get_request_header("POST", &endpoint); 

  let mut headers = HeaderMap::new(); headers.insert(AUTHORIZATION, header_auth.parse().unwrap()); 
  headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); 
  
  reqwest::Client::new() 
    .post(&endpoint) 
    .headers(headers) 
    .json(&params) 
    .send() 
    .await? 
    .json::<Value>()
    .await
}

reqwest v0.10.0以降はHTTPリクエストが標準で非同期処理となり、同期処理はreqwest::blockingに移動している。またblockingはCargo.tomlにfeaturesとして明示しなければ利用できないので注意。

以上でRustによる簡易的な実装おわり。

余談:twitter.rsの使い方

本記事のGitHubリポジトリのコードはlib/lib.rsexamples/tweet-demo.rsから呼び出すように設計している。

main.rsを書き換えることで独自にTwitter APIを叩けるので方法を記載する。

まずプロジェクトのルートディレクトリに.envというファイルを作成し、以下の形式でTwitter Applicationの4つの文字列を記述する。

CK="Consumer key"
CS="Consumer secret"
AT="Access token key"
AS="Access token secret"

.envファイルは配布せず、これをdotenvクレートで読み込むことでコードを変更せずにプログラムを実行できる。

main.rsは以下の通り。

use dotenv::dotenv;
use std::env;
use std::collections::HashMap;
mod twitter;

#[tokio::main]
async fn main() { 
  dotenv().ok();

  let consumer_key = env::var("CK").expect("CK must be set.");
  let consumer_secret = env::var("CS").expect("CS must be set.");
  let access_token_key = env::var("AT").expect("AT must be set.");
  let access_token_secret = env::var("AS").expect("AS must be set."); 
  
  let twitter = twitter::Twitter::new( 
    consumer_key, consumer_secret, 
    access_token_key, access_token_secret); 

  let mut body = HashMap::new(); 
  body.insert("text", "Rustでツイート"); 

  let res = twitter.post("tweets", body).await.unwrap(); 

  println!("{:?}", res);  
}

ツイートを削除したければbodyに{ id : <削除ツイートのID> }を格納し、メソッドはdelete, エンドポイントは”tweets”に変更して実行すればよい。

APIの詳細はドキュメントを参照すること

X API Documentation
Programmatically analyze, learn from, and engage with the conversation on X. Explore X API documentation now.

コメント

タイトルとURLをコピーしました