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_web
と rocket
のサポート機能が付いている。
今回は 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`
コンソールにはこんな感じに表示されている。
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 のバージョン表記が不正である」のようなエラー表示が出た。
1.0.0
のようにパッチバージョンを書いてないといけないので、 line-openapi
を fork して自分で修正した。
他のサービスはパッチバージョンまで書いてあったが、liff
だけ書いてなかったので、一応PRを出しておいた。
たった2文字を追加しただけというクソPR....
あとは、全ての client をメインファイル側で初期化するのがめんどくさいので、クレート側でやってあげた。
苦労したこと
1. ディレクトリ構成
例えば、 line-openapi
にある channel-access-token
を openapi-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
良さそう!
generator
は line-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 で問題は起こった・・・・
/// Event : Webhook event
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "")]
pub enum Event {}
おいおい・・
Event の空 enum が定義されているだけ・・
とりあえず、手動で書き換えることにした。
各種 Event の構造体は type
が定義されているので、一旦 #[serde(untagged)]
で。
untagged
の詳細は以下から。
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.
UnfollowEvent
と MemberLeftEvent
の構造体の定義が全く同じなんですよねぇ・・・・
なので、 メンバー退出イベント(MemberLeftEvent)が降ってきても先に定義されている UnfollowEvent
に引っかかってしまう!!!
こうなったら、もう大変。
untagged
が使えないので、 tag
で一つずつ定義してあげる必要が・・・・
全 Event の構造体で以下の処理をしないといけない
-
r#type
定義を削除 - new() の
r#type
引数を削除
@@ -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待ってます!!!