調整さんで絶対に回答させるline botを作ってみた【2】

にあえん

November 6, 2023

開発楽しい、ナナオです。

前回まででMessaging APIのwebhookとの疎通確認ができました。

今回はもう少し踏み込んで期日設定を行えるようにしていきます。

ShutleにPostgresを導入する

まず、設定された期日を保存しておくためのDBを用意します。

これにはShuttleが提供しているPostgresを使用します。

Shuttle Shared Databases - Shuttle

また、基本的にDBクライアントとしてはSqlXを使用することが推奨されているようなので、依存関係を追加しておきます。

[dependencies]
axum = "0.6.20"
serde_json = "1.0.108"
shuttle-axum = "0.31.0"
shuttle-runtime = "0.31.0"
shuttle-shared-db = { version = "0.31.0", features = ["postgres"] }
sqlx = { version = "0.7.2", features = ["runtime-tokio-native-tls", "postgres"] }

また、SqlX CLIもインストールしておきます。

cargo install sqlx-cli

SQLX CLIでルートパッケージからマイグレーションファイルを追加しておきます。

sqlx migrate add init

これでmigrationsというディレクトリがルートパッケージに作成されるはずです。

続けてマイグレーションファイルにグループテーブルを作成するSQLを実装します。

とりあえずグループIDと期日があれば十分でしょう。

-- Add migration script here
CREATE TABLE line_group (
    id varchar(33) primary key,
    deadline_date date
);

ちなみにグループIDは33桁の文字列で、C[0-9a-f]{32}という正規表現で表されます。

LINEプラットフォーム用語集 - グループID

以下のdocker-composeファイルから、ローカルにDBを起動しておきます。

# docker-compose-local-db.yaml
version: '3'

services:
  db:
    image: postgres:15
    container_name: postgres
    ports:
        - 5432:5432
    environment:
        - POSTGRES_PASSWORD=example
docker compose -f docker-compose-local-db.yaml up -d

あとはマイグレーションが最初に実行されるように実装を追加して、とりあえず実行してみます。

use axum::{routing::post, Router};
use sqlx::PgPool;

async fn webhook(
    axum::extract::Json(data): axum::extract::Json<serde_json::Value>
) {
    println!("request json: {:?}", data);
}

#[shuttle_runtime::main]
async fn main(
    #[shuttle_shared_db::Postgres(
        local_uri = "postgres://postgres:example@localhost:5432/postgres"
    )] pool: PgPool,
) -> shuttle_axum::ShuttleAxum {
    // マイグレーションを実行
    sqlx::migrate!("../migrations")
        .run(&pool)
        .await.unwrap();

    let router = Router::new().route("/webhook", post(webhook));
    Ok(router.into())
}
cargo shuttle run

ローカルで実行後、以下のようにDBが作成されていることが確認できます。

postgres=# \dt
                 リレーション一覧
 スキーマ |       名前       |  タイプ  |  所有者
----------+------------------+----------+----------
 public   | _sqlx_migrations | テーブル | postgres
 public   | line_group       | テーブル | postgres

この状態でデプロイしても動作するか確認しておきましょう。

# プロジェクトの開始
cargo shuttle project start
# デプロイ
cargo shuttle deploy

はい、無事デプロイできました。

ちなみにローカルでわざわざdocker-composeファイル作らなくても、local_uriを指定しないでいればDockerコンテナから勝手に起動してくれるみたいです。

#[shuttle_runtime::main]
async fn main(
    #[shuttle_shared_db::Postgres] pool: PgPool, // local_uriを省略
) -> shuttle_axum::ShuttleAxum {
  // ...以下略...

この状態で起動したDBはランダムなポートで起動しますが、それ以外のユーザー名・パスワード・データベース名はすべてpostgresになっています。

line_groupのデータを追加・取得する

では実際に期日設定を行うための実装を追加しましょう。

line_groupテーブルのdeadline_dateに対応するため、chronoを追加します。

また、SQLXでchronoを扱えるようにfeaturesを追加しておきます。

chrono = "0.4.31"
sqlx = { version = "0.7.2", features = ["runtime-tokio-native-tls", "postgres", "chrono"] }
serde = "1.0.191"

ではline_groupの構造体を追加しましょう。

use sqlx::{FromRow, PgPool};
use chrono::NaiveDate;

#[derive(FromRow)]
struct LineGroup {
    pub id: String,
    pub deadline_date: NaiveDate,
}

構造体を実装したら、とりあえずインサート処理を実装します。

async fn insert_line_group(pool: &PgPool, data: LineGroup) -> Result<(), sqlx::Error> {
    sqlx::query(r"INSERT INTO line_group (id, deadline_date) VALUES ($1, $2);")
        .bind(data.id)
        .bind(data.deadline_date)
        .execute(pool)
        .await?;

    Ok(())
}

POST /line_groupからレコードを作成できるようにしてみます。

#[derive(Clone)]
struct AxumState {
    pool: PgPool,
}

// Deserializeを追加
#[derive(FromRow, Deserialize)]
struct LineGroup {
    pub id: String,
    pub deadline_date: NaiveDate,
}

// ...中略...

async fn create_line_group(
    State(state): State<AxumState>,
    Json(data): Json<LineGroup>,
) {
    insert_line_group(&state.pool, data).await.unwrap();
}

// ...中略...

#[shuttle_runtime::main]
async fn main(
    #[shuttle_shared_db::Postgres] pool: PgPool,
) -> shuttle_axum::ShuttleAxum {
    // ...中略...

    let state = AxumState { pool };

    let router = Router::new()
        .route("/webhook", post(webhook))
        .route("/line_group", post(create_line_group))
        .with_state(state);

    Ok(router.into())
}

リクエストしてみます。

成功しました。

DBも確認しておきます。

postgres=# select * from line_group;
  id  | deadline_date
------+---------------
 test | 2023-11-07

ちゃんと作成されていました!

もう一つ、line_groupの取得を行う関数も実装します。

use sqlx::{FromRow, PgPool, Type};

// ...中略...

// Typeを追加
#[derive(Type, FromRow, Deserialize)]
struct LineGroup {
    pub id: String,
    pub deadline_date: NaiveDate,
}

// LineGroupの取得関数
async fn select_line_group(pool: &PgPool, id: String) -> Result<LineGroup, sqlx::Error> {
    let line_group = sqlx::query_scalar(r"SELECT * FROM line_group WHERE id = $1;")
        .bind(id)
        .fetch_one(pool)
        .await?;

    Ok(line_group)
}

これも先ほどと同じく一時的にエンドポイントを実装して動作確認してみます。

use serde::{Serialize, Deserialize};

// Serializeを追加
#[derive(Type, FromRow, Serialize, Deserialize)]
struct LineGroup {
    pub id: String,
    pub deadline_date: NaiveDate,
}

// ...中略...

async fn get_line_group(
    State(state): State<AxumState>,
    Path(id): Path<String>,
) -> Json<LineGroup> {
    let line_group = select_line_group(&state.pool, id).await.unwrap();
    Json(line_group)
}

// ...中略...

#[shuttle_runtime::main]
async fn main(
    #[shuttle_shared_db::Postgres] pool: PgPool,
) -> shuttle_axum::ShuttleAxum {
    // マイグレーションを実行
    sqlx::migrate!("../migrations")
        .run(&pool)
        .await.unwrap();

    let state = AxumState { pool };

    let router = Router::new()
        .route("/webhook", post(webhook))
        .route("/line_group", post(create_line_group))
        .route("/line_group/:id", get(get_line_group)) // line_groupの取得エンドポイントを追加
        .with_state(state);

    Ok(router.into())
}

実装したGET /line_group/:idエンドポイントからグループを取得してみます。

取得できました!

webhookの署名検証を実装する

続けて、webhookリクエストにある署名を検証する実装をしていきます。

これをしておかないと、どんなリクエストでも受け付けてしまうヤバいエンドポイントになってしまいます。

メッセージ(Webhook)を受信する | LINE Developers

まずは依存関係を追加します。

base64 = "0.21.5"
hmac = "0.12.1"
sha2 = "0.10.8"

ではwebhook関数を修正します。

use axum::{extract::{Path, State}, http::{StatusCode, header::HeaderMap}, Json, routing::{get, post}, Router};

// ...中略...

async fn webhook(
    headers: HeaderMap,
    body: String,
) -> (StatusCode, &'static str) {
    let signature: Option<&axum::http::HeaderValue> = headers.get("x-line-signature");

    let signature = signature
        .expect("署名が存在しません。")
        .to_str()
        .expect("署名を文字列として読み込めませんでした。")
        .to_string();

    let mut mac = HmacSha256::new_from_slice(CHANNEL_SECRET.as_bytes())
        .expect("HMACキーの検証に失敗しました。");

    mac.update(body.as_bytes());

    let code_bytes = mac.finalize().into_bytes();
    let encoded: String = general_purpose::STANDARD.encode(code_bytes);

    if encoded != signature {
        return (StatusCode::UNAUTHORIZED, "無効なリクエストです。サーバー側と署名が一致しませんでした。")
    }

    (StatusCode::OK, "OK")
}

// ...以下略...

実装できました。

実際に動作するか確認してみましょう。

// ローカルでサーバーを起動
cargo shuttle run
// ngrokで公開
ngrok http 8000

Lineのチャネル設定画面から、Webhook URLをngrokのURLに更新してあげたら、前回作成したグループに再びメッセージを送ってみます。

サーバー側でもエラーが出ていないので、無事署名認証処理が実装できました!

あとはこれらの処理を関数化しておきましょう。

/// ヘッダーとリクエストボディの内容から署名検証を行います。
fn verify_signature(
    headers: HeaderMap,
    body: String,
) -> bool {
    let signature: Option<&axum::http::HeaderValue> = headers.get("x-line-signature");

    let signature = signature
        .expect("署名が存在しません。")
        .to_str()
        .expect("署名を文字列として読み込めませんでした。")
        .to_string();

    let mut mac = HmacSha256::new_from_slice(CHANNEL_SECRET.as_bytes())
        .expect("HMACキーの検証に失敗しました。");

    mac.update(body.as_bytes());

    let code_bytes = mac.finalize().into_bytes();
    let encoded: String = general_purpose::STANDARD.encode(code_bytes);

    encoded == signature
}

async fn webhook(
    headers: HeaderMap,
    body: String,
) -> (StatusCode, &'static str) {
    if !verify_signature(headers, body) {
        return (StatusCode::UNAUTHORIZED, "無効なリクエストです。サーバー側と署名が一致しませんでした。")
    }

    (StatusCode::OK, "OK")
}

まとめ

結構長くなってきたので、今回はこの辺にしておきます。

署名認証処理で結構時間がかかってしまいました。

次回はこのメッセージハンドリングをより本格的にして、メッセージ内容から期日設定ができるようにしていきたいと思います。

ではまた。