🚀

Rust + RocketでDiscord Botを動かす

2023/12/06に公開

こんにちは、いちきゅーです。
https://twitter.com/0x3FB/status/1730693248715538871

Cloudflare WorkersでDiscord Botを動かしていたのですが、とんでもないアホなことをしてしまったので作り直しになりました。

作り直しなるなら~と思って勉強がてらRust + RocketでDiscord BotのWebhooksサーバーを建ててみました。

かんきょー

  • Rocket
  • Rust 2021
  • cloudflared

setup

とりあえずcargoでプロジェクトを作成

$ cargo new rocket_discord --bin
$ cd rocket_discord/

ついでにCargo.tomlに必要なパッケージを設定します

[package]
name = "rocket_discord"
version = "0.1.0
edition = "2021"

[dependencies]
ed25519-dalek = "2.1.0"
rocket = { version = "0.5.0", features = ["json"] }
hex = "0.4.3"

ed25519-dalekとhexははDiscordからの認証をするために必要なので使います!

hello world

とりあえずHelloWorldしてみます

main.rs
use rocket::{get, launch, routes};

#[get("/")]
fn index() -> &'static str {
    "Hello, World"
}

#[launch]
fn rocket() -> _ {
    rocket::build().mount("/", routes![index])
}

これでcargo runすると8000ポートで起動するためlocalhost:8000/でHello, Worldと返ってくるはずです!

pingインタラクションにpongを返す

interactionのサーバーを登録するときにはpingが飛んできます。このpingはサーバーの動作確認として送られてきます。認証ではheadersに入ってるsigunatureとbotのpublic keyとbodyの内容をゴニョゴニョして認証しなければいけません。

Every Interaction is sent with the following headers:

X-Signature-Ed25519 as a signature
X-Signature-Timestamp as a timestamp

https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization

RocketでHeadersからvalueを出すにはfrom_requestなるものを使う必要があるらしい(他の方法もあるのかな?)ので、それを使ってみます
https://rocket.rs/v0.5/guide/requests/#request-guards

main.rs
use ed25519_dalek::{Verifier, PUBLIC_KEY_LENGTH, Signature, SignatureError, VerifyingKey};
use hex::{decode, FromHexError};
use rocket::{launch, routes, post, Request};
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome};
use rocket::response::{content, status};

struct Discord {
	sigunature: String,
	timestamp: String,
}

#[derive(Debug)]
enum APIErr {
    Invalid,
    ParseHexError(FromHexError),
    InvalidSignature(SignatureError),
    GenerateVerifyKeyError(SignatureError),
    PublicKeyDecodeError(<[u8; PUBLIC_KEY_LENGTH] as TryFrom<Vec<u8>>>::Error),
    SignatureDecodeError(<[u8; Signature::BYTE_SIZE] as TryFrom<Vec<u8>>>::Error),
}

// postでデータが送られてくるからpost指定
#[post("/", format="application/json", data=<input>)] // data=でbodyを指定
fn index(discord: Discord, input: &str) -> status::Custom<content::RawJson<&'static str>> {  // JSONを返せるように型を設定する
    let public_key = "hogehogehugahuga";
    let signature = discord.signature;
    let timestamp = discord.timestamp;
    
    let result = verify(public_key, &*signature, &*timestamp, input);
    if result.is_err() {
        return status::Custom(Status::Unauthorized, content::RawJson("{\"error\": \"Invalid Signature\"}"))
    }
    
    return status::Custom(Status::Ok, content::RawJson("{\"type\": 1}"))
}

// 認証
fn verify(public_ley: &str, signature: &str, timestamp: &str, body: &str) -> Result<(), APIErr> {
	
    let decoded_pubkey: &[u8; PUBLIC_KEY_LENGTH: = &decode(public_key)
        .map_err(APIErr::ParseHexError)?
	.try_into()
	.map_err(APIErr::PublicKeyDecodeError)?;

    let verifying_key = VerifyingKey::from_bytes(decoded_pub)
        .map_err(APIErr::GenerateVerifyKeyError)?;

    let decoded_signature: &[u8; Signature::BYTE_SIZE] = &decode(signature)
        .map_err(APIErr::ParseHexError)?
        .try_into()
        .map_err(APIErr::SignatureDecodeError)?;

    let signature = Signature::from_bytes(decoded_signature);

    Ok(verifying_key.verify(format!("{}{}", timestamp, body).as_bytes(), &signature)
        .map_err(APIErr::InvalidSignature)?)

}

// Headersのデータを取る
#[rocket::async_trait]
impl <'r> FromRequest<'r> for Discord {
    type Error = APIErr;
    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        let signature = request.headers().get_one("x-signature-ed25519");
        let timestamp = request.headers().get_one("x-signature-timestamp");

        if signature.is_none() {
            return Outcome::Error((Status::Unauthorized, APIErr::Invalid));
        }
        if timestamp.is_none() {
            return Outcome::Error((Status::Unauthorized, APIErr::Invalid));
        }

        Outcome::Success(Discord{
            sigunature: signature.unwrap().to_string(),
            timestamp: timestamp.unwrap().to_string(),
        })

    }
}

#[launch]
fn rocket() -> _ {
    rocket::build().mount("/", routes![index])
}

これでDiscordから飛んでくるPingに対して返せるようになりました!
impl <'r> FromRequest<'r> for DiscordでHeadersに入ってるsignatureをindex関数に受け渡すように設定します。

そしてindex関数で受け渡されたsignature類をverify関数で認証してエラーがなければpongレスポンスを返す形にしています!!

index関数の中のpubkeyにはDiscordのDeveloper PortalからPublic Keyを取得してきて入れてください、環境変数とかに入れるといいのかも

動作確認

cloudflaredでtunnelを作って公開して動作チェックしてみます

$ cloudflared tunnel --url localhost:8000/
---
2023-12-05T15:34:33Z INF Requesting new quick Tunnel on trycloudflare.com...
2023-12-05T15:34:37Z INF +--------------------------------------------------------------------------------------------+
2023-12-05T15:34:37Z INF |  Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):  |
2023-12-05T15:34:37Z INF |  https://aaaaaaaaaaaaaaaaaaaaaaaaaaa.trycloudflare.com                                     |
2023-12-05T15:34:37Z INF +--------------------------------------------------------------------------------------------+

ここのURLをコピーし、Discord Developer PortalからBotを選択し、Interactions Endpoint URLに先程のURLをペーストして[save changes]を押すと認証が完了します。

あとは好きなように型作ってーって感じで進められます!
やっぱりnodejsと違って型がガチガチな言語なので、Discord APIみたいなデカくて型がごちゃごちゃしてるAPIを叩くようなコードには向いてない感じはしました....

以上です!ありがとうございました~

参考

https://dev.classmethod.jp/articles/rust-rocket/
https://discord.com/developers/docs/interactions/receiving-and-responding
https://rocket.rs/v0.5/guide/requests/#requests

Discussion