11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

LINE DCAdvent Calendar 2023

Day 4

[Rust・LINEBot] line-openapi を使って Rust で sdk を作った話

Last updated at Posted at 2023-12-03

LINE DC Advent Calendar 2023

Qiitaに投稿初めて以来、初のアドベントカレンダーの参加です!

LINE DC Advent Calendar 2023 の4日目に参加させていただきます!

はじめに

今年の7月頃、LINE OpenAPI が公開された。

ソースはこれ。

そして思った。

2年くらい前に作った Rust製の line-bot-sdk(下記リンク参照)を最新化しよう!!!

ということで、今回は LINE OpenAPI を使用して、 line-bot-sdk-rust の最新化をした過程や、苦労した点、まだ改善できる点を書いていく!

成果物

わちゃわちゃ書いていくので、できた物を最初に載せておく。

  • GitHub

  • Crate

使い方

このクレートを使用して、送られた文字をそのまま返すBot(基本的なEcho Bot)を作成していく。

まずは、 cargo new

$ cargo new sample

actix_webrocket のサポート機能が付いている。

今回は actix_web を使用する。

$ cd sample
$ cargo add line-bot-sdk-rust --features actix_support
$ cargo add actix-web actix-rt dotenv serde_json

環境変数を .env に入れておく。

$ touch .env
$ echo 'LINE_CHANNEL_SECRET=xxxxx' >> .env
$ echo 'LINE_CHANNEL_ACCESS_TOKEN=xxxxx' >> .env

main.rs はこんな感じ

use actix_web::{
    error::ErrorBadRequest, middleware, post, web, App, Error, HttpResponse, HttpServer,
};
use dotenv::dotenv;
use line_bot_sdk_rust::{
    client::LINE,
    line_messaging_api::{
        apis::MessagingApiApi,
        models::{Message, ReplyMessageRequest, TextMessage},
    },
    line_webhook::models::{CallbackRequest, Event, MessageContent},
    parser::signature::validate_signature,
    support::actix::Signature,
};
use std::env;

#[post("/callback")]
async fn callback(signature: Signature, bytes: web::Bytes) -> Result<HttpResponse, Error> {
    // Get channel secret and access token by environment variable
    let channel_secret: &str =
        &env::var("LINE_CHANNEL_SECRET").expect("Failed getting LINE_CHANNEL_SECRET");
    let access_token: &str =
        &env::var("LINE_CHANNEL_ACCESS_TOKEN").expect("Failed getting LINE_CHANNEL_ACCESS_TOKEN");

    let line = LINE::new(access_token.to_string());

    let body: &str = &String::from_utf8(bytes.to_vec()).unwrap();

    if !validate_signature(channel_secret.to_string(), signature.key, body.to_string()) {
        return Err(ErrorBadRequest("x-line-signature is invalid."));
    }

    let request: Result<CallbackRequest, serde_json::Error> = serde_json::from_str(&body);
    match request {
        Err(err) => return Err(ErrorBadRequest(err.to_string())),
        Ok(req) => {
            println!("req: {req:#?}");
            for e in req.events {
                if let Event::MessageEvent(message_event) = e {
                    if let MessageContent::TextMessageContent(text_message) = *message_event.message
                    {
                        let reply_message_request = ReplyMessageRequest {
                            reply_token: message_event.reply_token.unwrap(),
                            messages: vec![Message::Text(TextMessage::new(text_message.text))],
                            notification_disabled: Some(false),
                        };
                        let result = line
                            .messaging_api_client
                            .reply_message(reply_message_request)
                            .await;
                        match result {
                            Ok(r) => println!("{:#?}", r),
                            Err(e) => println!("{:#?}", e),
                        }
                    };
                };
            }
        }
    }

    Ok(HttpResponse::Ok().body("ok"))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    HttpServer::new(|| {
        App::new()
            .wrap(middleware::Logger::default())
            .service(callback)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

ローカル環境で動作確認をするため、ngrok を使用する。

$ ngrok http 8080
ngrok by @inconshreveable                                                            (Ctrl+C to quit)
                                                                                                     
Session Status                online                                                                 
Account                       ななといつ (Plan: Free)                                                
Version                       2.3.41                                                                 
Region                        United States (us)                                                     
Web Interface                 http://127.0.0.1:4041                                                  
Forwarding                    http://xxxxxxxx.ngrok-free.app -> http://localhost:8080      
Forwarding                    https://xxxxxxxx.ngrok-free.app -> http://localhost:8080     
                                                                                                     
Connections                   ttl     opn     rt1     rt5     p50     p90                            
                              0       0       0.00    0.00    0.00    0.00                           

https のアドレスをLINE公式アカウントの webhook URL に設定する。

https://xxxxxxxx.ngrok-free.app/callback

あとは、cargo run

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.40s
     Running `target/debug/sample`

image.png

コンソールにはこんな感じに表示されている。

req: CallbackRequest {
    destination: "U078cd692e67a90a66af06d18865830e3",
    events: [
        MessageEvent(
            MessageEvent {
                source: Some(
                    UserSource(
                        UserSource {
                            user_id: Some(
                                "Ue10d267e7ad66d524781ccf16ca6ddbd",
                            ),
                        },
                    ),
                ),
                timestamp: 1701595931169,
                mode: Active,
                webhook_event_id: "01HGQGAS0XDWZ62V9YP95JHR4V",
                delivery_context: DeliveryContext {
                    is_redelivery: false,
                },
                reply_token: Some(
                    "e750273b9d0e44b9966079b66c7a84a1",
                ),
                message: TextMessageContent(
                    TextMessageContent {
                        id: "484466652302540849",
                        text: "hello, line-bot-sdk-rust",
                        emojis: None,
                        mention: None,
                        quote_token: "PxefLxyWBibOJnd13faocQZuP8iL-UGmbOmYdBXKtII7kL8qffFVAVLjQC2dklq3XTsykucOlQRuKqZ2hQdR2E-eBPM0bmVl1HLZ0PMnDGoUwoOJHetnRwWRZOsjke0Z7QokRyydLvaLroZzAEPdXw",
                        quoted_message_id: None,
                    },
                ),
            },
        ),
    ],
}
ReplyMessageResponse {
    sent_messages: [
        SentMessage {
            id: "484466655439618465",
            quote_token: Some(
                "mkN4-wobbA6RQe-tlY3mD3XDUCDJyUQws_OIpVI1GBh5w-cLxLLpsrzKPbh6geSEsIcYfHmBg0zp6O-8IsZdKaN9MbmflRt4iAHGX2yBNDLueCT90bjn-D6sZbno7nJG30VBL8K303qc9FmCGOsHmw",
            ),
        },
    ],
}

作成手順

line-bot-sdk-rust を作成した過程を書いていく。

$ cargo new line-bot-sdk-rust
$ cd line-bot-sdk-rust

cargo new した後に、 line-openapi を submodule として追加する。

$ git submodule add https://github.com/line/line-openapi

あとは、この openapi の定義ファイルから Rust のコードを生成する。

この生成過程は generator として Rust で書いた。

ここで、liff で「Cargo のバージョン表記が不正である」のようなエラー表示が出た。

image.png

1.0.0 のようにパッチバージョンを書いてないといけないので、 line-openapi を fork して自分で修正した。

他のサービスはパッチバージョンまで書いてあったが、liff だけ書いてなかったので、一応PRを出しておいた。

たった2文字を追加しただけというクソPR....

あとは、全ての client をメインファイル側で初期化するのがめんどくさいので、クレート側でやってあげた。

苦労したこと

1. ディレクトリ構成

例えば、 line-openapi にある channel-access-tokenopenapi-generator-cli を使用してコードを生成したとする。

$ java -jar tools/openapi-generator-cli-7.1.0.jar generate \
    --http-user-agent LINE-Bot-SDK-Rust/1 \
    -i line-openapi/channel-access-token.yml \
    -g rust \
    -o .

生成されたファイルがこちら

$ tree
.
├── Cargo.toml
├── README.md
├── docs
│   ├── ChannelAccessTokenApi.md
│   ├── ChannelAccessTokenKeyIdsResponse.md
│   ├── ErrorResponse.md
│   ├── IssueChannelAccessTokenResponse.md
│   ├── IssueShortLivedChannelAccessTokenResponse.md
│   ├── IssueStatelessChannelAccessTokenResponse.md
│   └── VerifyChannelAccessTokenResponse.md
├── git_push.sh
└── src
    ├── apis
    │   ├── channel_access_token_api.rs
    │   ├── configuration.rs
    │   └── mod.rs
    ├── lib.rs
    └── models
        ├── channel_access_token_key_ids_response.rs
        ├── error_response.rs
        ├── issue_channel_access_token_response.rs
        ├── issue_short_lived_channel_access_token_response.rs
        ├── issue_stateless_channel_access_token_response.rs
        ├── mod.rs
        └── verify_channel_access_token_response.rs

4 directories, 21 files

lib として生成されるので、cargo のワークスペース機能が使えそう。

そうした上で、どういう構成にしよう?と思って色々な crateを見てみると、Rocket辺りが良さそうな構成してる!

これを参考にすると、こういう構成になりそう。

.
├── Cargo.lock
├── Cargo.toml
├── core
│   ├── channel_access_token
│   │   ├── Cargo.toml
│   │   └── src
│   ├── insight
│   │   ├── Cargo.toml
│   │   └── src
│   ├── lib
│   │   ├── Cargo.toml
│   │   └── src
│   ├── liff
│   │   ├── Cargo.toml
│   │   └── src
│   ├── manage_audience
│   │   ├── Cargo.toml
│   │   └── src
│   ├── messaging_api
│   │   ├── Cargo.toml
│   │   └── src
│   ├── module
│   │   ├── Cargo.toml
│   │   └── src
│   ├── module_attach
│   │   ├── Cargo.toml
│   │   └── src
│   ├── shop
│   │   ├── Cargo.toml
│   │   └── src
│   └── webhook
│       ├── Cargo.toml
│       └── src
├── examples
│   ├── Cargo.lock
│   ├── Cargo.toml
│   ├── actix_web_example
│   │   ├── Cargo.toml
│   │   └── src
│   └── rocket_example
│       ├── Cargo.toml
│       └── src
└── generator
    ├── Cargo.toml
    └── src
        └── main.rs

良さそう!

generatorline-openapi から rust のコードを生成する役割。

このクレートを公開するに当たって、openapi-generator で生成されたクレートも crate.io にあげないといけない。。。

line-openapi のライセンスは Apache 2.0 なので、openapi-generator で生成されたコードにLINE Corp のライセンス表記を加えて公開した。

2. reqwest を使用するとクライアントが思ってたんとちゃう

openapi-generator で Rust コードを生成する場合、 reqwest がデフォルトで使用される。

例えば、リプライメッセージを送信する 関数の出力はこうなっている。

/// Send reply message
pub async fn reply_message(
    configuration: &configuration::Configuration,
    params: ReplyMessageParams,
) -> Result<ResponseContent<ReplyMessageSuccess>, Error<ReplyMessageError>> {
    let local_var_configuration = configuration;

    // unbox the parameters
    let reply_message_request = params.reply_message_request;

    let local_var_client = &local_var_configuration.client;

    let local_var_uri_str = format!("{}/v2/bot/message/reply", local_var_configuration.base_path);
    let mut local_var_req_builder =
        local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());

    ...

Client などの構造体はなく、ただ関数が出力されている。

他の関数も configuration: &configuration::Configuration を引数に取っているので、毎回引数に入れてあげないといけない・・・・・??

それはめんどくさい。

hyper でも出力できるらしいので、試しに出力してみる。

--library hyper で生成してみる。

pub struct MessagingApiApiClient<C: hyper::client::connect::Connect>
where
    C: Clone + std::marker::Send + Sync + 'static,
{
    configuration: Rc<configuration::Configuration<C>>,
}

impl<C: hyper::client::connect::Connect> MessagingApiApiClient<C>
where
    C: Clone + std::marker::Send + Sync,
{
    pub fn new(configuration: Rc<configuration::Configuration<C>>) -> MessagingApiApiClient<C> {
        MessagingApiApiClient { configuration }
    }
}

pub trait MessagingApiApi {
    ...

    fn push_messages_by_phone(
        &self,
        pnp_messages_request: crate::models::PnpMessagesRequest,
        x_line_delivery_tag: Option<&str>,
    ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>>;
    fn reply_message(
        &self,
        reply_message_request: crate::models::ReplyMessageRequest,
    ) -> Pin<Box<dyn Future

    ...

そうそう!
こういう感じ!

hyper を使用する方向で決定した瞬間

3. enum がちゃんと出力されない

openapi-generator-cli を使って、作成した Webhook Event の enum で問題は起こった・・・・

src/webhook/models/event.rs
/// Event : Webhook event

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "")]
pub enum Event {}

おいおい・・
Event の空 enum が定義されているだけ・・

とりあえず、手動で書き換えることにした。
各種 Event の構造体は type が定義されているので、一旦 #[serde(untagged)] で。

untagged の詳細は以下から。

src/webhook/models/event.rs
use super::{
    AccountLinkEvent, ActivatedEvent, BeaconEvent, BotResumedEvent, BotSuspendedEvent,
    DeactivatedEvent, FollowEvent, JoinEvent, LeaveEvent, MemberJoinedEvent, MemberLeftEvent,
    MessageEvent, ModuleEvent, PostbackEvent, ThingsEvent, UnfollowEvent, UnsendEvent,
    VideoPlayCompleteEvent,
};

/// Event : Webhook event

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Event {
    MessageEvent(MessageEvent),
    UnsendEvent(UnsendEvent),
    FollowEvent(FollowEvent),
    UnfollowEvent(UnfollowEvent),
    JoinEvent(JoinEvent),
    LeaveEvent(LeaveEvent),
    MemberJoinedEvent(MemberJoinedEvent),
    MemberLeftEvent(MemberLeftEvent),
    PostbackEvent(PostbackEvent),
    VideoPlayCompleteEvent(VideoPlayCompleteEvent),
    BeaconEvent(BeaconEvent),
    AccountLinkEvent(AccountLinkEvent),
    ThingsEvent(ThingsEvent),
    ModuleEvent(ModuleEvent),
    ActivatedEvent(ActivatedEvent),
    DeactivatedEvent(DeactivatedEvent),
    BotSuspendedEvent(BotSuspendedEvent),
    BotResumedEvent(BotResumedEvent),
}

ちなみに、message_content でも source でも起きていたので同じように修正・・・

  • openapi/src/webhook/models/message_content.rs
  • source.rs

ちょっと嫌な予感がしたので、一旦「メンバー退出イベント」を降らせてみる。

req: CallbackRequest {
    destination: "U078cd692e67a90a66af06d18865830e3",
    events: [
        UnfollowEvent(
            UnfollowEvent {
                type: "memberLeft",
                source: Some(
                    GroupSource(
                        GroupSource {
                            type: "group",
                            group_id: "C4e256f4c52df27c374275bb35f4e8e38",
                            user_id: None,
                        },
                    ),
                ),
                timestamp: 1701238025511,
                mode: Active,
                webhook_event_id: "01HGCV0BS3CYF5YTWX474SC5V1",
                delivery_context: DeliveryContext {
                    is_redelivery: false,
                },
            },
        ),
    ],
}

うわー!!!!

UnfollowEvent !!!!!
type: "memberLeft" !!!!

さっきも記載した serdeの公式ドキュメントuntagged の説明を抜粋

There is no explicit tag identifying which variant the data contains.
Serde will try to match the data against each variant in order and the first one that deserializes successfully is the one returned.

UnfollowEventMemberLeftEvent の構造体の定義が全く同じなんですよねぇ・・・・

なので、 メンバー退出イベント(MemberLeftEvent)が降ってきても先に定義されている UnfollowEvent に引っかかってしまう!!!

こうなったら、もう大変。
untagged が使えないので、 tag で一つずつ定義してあげる必要が・・・・

全 Event の構造体で以下の処理をしないといけない

  • r#type 定義を削除
  • new() の r#type 引数を削除
src/webhook/models/message_event.rs
@@ -2,9 +2,6 @@
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 pub struct MessageEvent {
-    /// Type of the event
-    #[serde(rename = "type")]
-    pub r#type: String,
     #[serde(rename = "source", skip_serializing_if = "Option::is_none")]
     pub source: Option<Box<crate::webhook::models::Source>>,
     /// Time of the event in milliseconds.

@@ -26,7 +23,6 @@ pub struct MessageEvent {
 impl MessageEvent {
     /// Webhook event object which contains the sent message.
     pub fn new(
-        r#type: String,
         timestamp: i64,
         mode: crate::webhook::models::EventMode,
         webhook_event_id: String,

@@ -34,7 +30,6 @@ impl MessageEvent {
         message: crate::webhook::models::MessageContent,
     ) -> MessageEvent {
         MessageEvent {
-            r#type,
             source: None,
             timestamp,
             mode,

これを全 Event で修正!!!!!
もはや自動生成の意味・・・・・

んで、enum Event はこう!

use super::{
    AccountLinkEvent, ActivatedEvent, BeaconEvent, BotResumedEvent, BotSuspendedEvent,
    DeactivatedEvent, FollowEvent, JoinEvent, LeaveEvent, MemberJoinedEvent, MemberLeftEvent,
    MessageEvent, ModuleEvent, PostbackEvent, ThingsEvent, UnfollowEvent, UnsendEvent,
    VideoPlayCompleteEvent,
};

/// Event : Webhook event

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Event {
    #[serde(rename = "message")]
    MessageEvent(MessageEvent),
    #[serde(rename = "unsend")]
    UnsendEvent(UnsendEvent),
    #[serde(rename = "follow")]
    FollowEvent(FollowEvent),
    #[serde(rename = "unfollow")]
    UnfollowEvent(UnfollowEvent),
    #[serde(rename = "join")]
    JoinEvent(JoinEvent),
    #[serde(rename = "leave")]
    LeaveEvent(LeaveEvent),
    #[serde(rename = "memberJoined")]
    MemberJoinedEvent(MemberJoinedEvent),
    #[serde(rename = "memberLeft")]
    MemberLeftEvent(MemberLeftEvent),
    #[serde(rename = "postback")]
    PostbackEvent(PostbackEvent),
    #[serde(rename = "videoPlayComplete")]
    VideoPlayCompleteEvent(VideoPlayCompleteEvent),
    #[serde(rename = "beacon")]
    BeaconEvent(BeaconEvent),
    #[serde(rename = "accountLink")]
    AccountLinkEvent(AccountLinkEvent),
    #[serde(rename = "things")]
    ThingsEvent(ThingsEvent),
    #[serde(rename = "module")]
    ModuleEvent(ModuleEvent),
    #[serde(rename = "activated")]
    ActivatedEvent(ActivatedEvent),
    #[serde(rename = "deactivated")]
    DeactivatedEvent(DeactivatedEvent),
    #[serde(rename = "botSuspended")]
    BotSuspendedEvent(BotSuspendedEvent),
    #[serde(rename = "botResumed")]
    BotResumedEvent(BotResumedEvent),
}

改善できる点

何と言っても 3. enum がちゃんと出力されない の enum 問題・・・

ここをどうにか出来ないのか・・・

対処法としては以下の3つくらい?

  • line-openapi 側を修正して enum が正常に出力されるようにする
  • openapi-generator 側を修正して enum が正常に出力されるようにする
  • Rust 側で各種 Event の構造体の r#type の定義を保持しつつ、enum 定義の tag にも type を使用する(できるなら)

有識者さん、良い方法があれば教えてください。

あと、rocket の example(Echo Bot)がまだ完成していないので、誰か書いてほしい・・・・・

改善できる点はまだまだあると思うので、ぜひPR待ってます!!!

11
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?