RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

Rust で Slackbot 開発 ~App 作成から Bot にオウム返ししてもらうまで~

この記事は RevComm Advent Calendar 2022 の 22 日目の記事です。

はじめに

こんにちは、バックエンドエンジニアのまつどしんたろうです。

私は最近 Rust で Slackbot を作っています。

そのBotは、 @Slack名 ++ と投稿すると、Thank you @Slack名 (counter: 1) と返答してくれます。

また、もう一度 @Slack名 ++ と投稿すると、Thank you @Slack名 (counter: 2) となり、感謝された数だけ counter が増えていきます。

この Bot は弊社の制度である 15% ルールを使って開発しました。

15% ルールとは、業務外の活動への積極的な関与を推奨するために、業務時間のうち 15% はコア業務外の活動に取り組んで良いというルールです。

このルールのおかげで、日々、技術力の幅を広げることができています。

今回は、Slackbot にオウム返しをしてもらうところまでを記事にしていきます。

モチベーション

  • 世のため人のために行動するというカルチャーをさらに根付かせたい

  • フルリモートの環境でも感謝の気持ちを気軽に表現できるようにしたい

という思いから開発しました。

インスパイア

この Bot は、PyConJP Staff 用の Slack に導入されていて、感動しぜひ社内にも取り入れたいと思い開発をはじめました。

PyConJP 2022 でも紹介されているのでぜひご覧になってみてください。

https://2022.pycon.jp/timetable?id=ELUNPR

構成

業務では主に Python を使っていますが、触ってみたいという理由で Rust を採用しています。 今回はお試しのため、EC2 に環境を構築します。

  • EC2 (Amazon Linux)
  • Rust
  • Axum

今回、出来上がるコードはこちらです。 Cargo.toml

[package]
name = "slackbot"
version = "0.1.0"
edition = "2021"
publish = false

[dependencies]
axum = "0.6.1"
tokio = { version = "1.0", features = ["full"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = [ "std", "env-filter" ] }
dotenv = "0.15.0"
reqwest = { version = "0.11", features = ["json"] }

.env

VERIFICATION_TOKEN=
BOT_USER=
BOT_USER_OAUTH_TOKEN=

main.rs

use axum::{
    http::StatusCode,
    routing::{get, post},
    Router,
};
use std::net::SocketAddr;

use dotenv::dotenv;

mod slackbot;
use crate::slackbot::slackbot;

#[tokio::main]
async fn main() {
    init().await;

    let app = Router::new()
        .route("/", get(handler))
        .route("/slackbot", post(slackbot));

    let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
    tracing::debug!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn init() {
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::DEBUG)
        .init();

    dotenv().ok();
}

async fn handler() -> (StatusCode, String) {
    (StatusCode::OK, String::from("Hello, World!"))
}

slackbot.rs

use std::env;

use axum::{http::StatusCode, Json};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct Request {
    token: String,
    challenge: Option<String>,
    event: Option<Event>,
}

impl Request {
    fn is_initialize(&self) -> bool {
        self.challenge.is_some()
    }
}

#[derive(Debug, Deserialize)]
struct Event {
    channel: String,
    user: String,
    text: String,
    thread_ts: Option<String>,
}

impl Event {
    fn from_bot(&self, bot_user: String) -> bool {
        self.user == bot_user
    }
}

#[derive(Debug, Serialize)]
struct PostBody {
    text: String,
    channel: String,
    thread_ts: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct Response {
    ok: bool,
    challenge: Option<String>,
}

pub async fn slackbot(Json(req): Json<Request>) -> (StatusCode, Json<Response>) {
    tracing::info!("slackbot");

    // Validate
    let verification_token =
        env::var("VERIFICATION_TOKEN").expect("VERIFICATION_TOKEN must be set");
    if req.token != verification_token {
        tracing::warn!("AuthenticationFailed, token: {}", req.token);
        let res = Json(Response {
            ok: false,
            challenge: None,
        });
        return (StatusCode::BAD_REQUEST, res);
    }

    // Verify from Slack
    if req.is_initialize() {
        let res = Json(Response {
            ok: true,
            challenge: req.challenge,
        });
        return (StatusCode::OK, res);
    }

    // Validate
    match &req.event {
        Some(event) => {
            let bot_user = env::var("BOT_USER").expect("BOT_USER must be set");
            if event.from_bot(bot_user) {
                tracing::debug!("From happy bot");
                let res = Json(Response {
                    ok: false,
                    challenge: None,
                });
                return (StatusCode::BAD_REQUEST, res);
            }
        }
        None => {
            let res = Json(Response {
                ok: false,
                challenge: None,
            });
            return (StatusCode::BAD_REQUEST, res);
        }
    }

    // Execute
    let event = req.event.unwrap();

    let post_body = PostBody {
        text: event.text,
        channel: event.channel,
        thread_ts: event.thread_ts.map(|x| x),
    };

    post_request(post_body).await;

    let res = Json(Response {
        ok: true,
        challenge: None,
    });
    return (StatusCode::OK, res);
}

async fn post_request(post_body: PostBody) -> reqwest::Result<()> {
    tracing::info!("slackbot__post_request");

    let url = "https://slack.com/api/chat.postMessage";
    let bot_user_oauth_token =
        env::var("BOT_USER_OAUTH_TOKEN").expect("BOT_USER_OAUTH_TOKEN must be set");

    let client = reqwest::Client::new();
    let response = client
        .post(url)
        .header(
            reqwest::header::AUTHORIZATION,
            format!("Bearer {}", bot_user_oauth_token),
        )
        .json(&post_body)
        .send()
        .await?;

    Ok(())
}

準備① Slack API の App 作成

1/4 App 作成

Slack api の Your Apps (https://api.slack.com/apps?new_app=1) の Create New App から App を作成します。 今回は From scratch から作成しました。

2/4 Token 取得

取得した Token は .env ファイルに記載します。

  1. Basic Information (https://api.slack.com/apps?) > App Credentials の Verification Token を取得します。
  2. OAuth & Permissions (https://api.slack.com/apps/XXXXXXXXXXXX/oauth?) の Reinstall to Workspace からチャンネルにインストールします。 Bot User OAuth Token が現れるので取得します。
3/4 権限の設定

OAuth & Permissions (https://api.slack.com/apps/XXXXXXXXXXXX/oauth?) の Scope Bot Token Scopes と EventSubscriptions (https://api.slack.com/apps/XXXXXXXXXXXX/event-subscriptions?) の Subscribe to bot events に 必要な権限を追加します。

4/4 Slack チャンネルへの追加

導入したい Slack チャンネルに行き、Slack チャンネル名をクリックし、インテグレーションからアプリを追加します。

実装① Rust のインストールと Rust で POST を受け取れるように設定

1/5 Rust をインストール

rustup を使ってインストールします。詳細は公式ドキュメントを参照してください。

2/5 Axum の Hello world

Axum は Rust の Web フレームワークのひとつです。Axum が豊富に example を提供してくれているので、これを元にして Bot の開発をしていきます。

まずは、最小構成で Hello World をやってみます。

  1. https://github.com/tokio-rs/axum/tree/main/examples/hello-world をclone
  2. Cargo.toml の axum をaxum = "0.6.1" に変更
  3. port を 8080 に変更 (Optional)
  4. REST API にしたいので hundler を修正 (Optional)
  5. cargo run でサーバーを立ち上げます
  6. curl localhost:8080 を叩くと、Hello, World! が返ってきます

main.rs

- use axum::{response::Html, routing::get, Router};
+ use axum::{http::StatusCode, routing::get, Router};
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(handler))

-     let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
+     let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

- async fn handler() -> Html<&'static str> {
-     Html("<h1>Hello, World!</h1>")
+ async fn handler() -> (StatusCode, String) {
+     (StatusCode::OK, String::from("Hello, World!"))
}
3/5 challenge を返す

Slack API に URL を登録する際に、有効な URL だと Slack に知らせる必要があります。

POST で verification_token と challenge というランダムな値が送られてくるので、そのまま challenge を返してあげることで有効だと判断されます。

  1. serde クレート*1を追加します。serde はシリアライズ/デシリアライズをしてくれるクレートです。

  2. 別ファイルでメソッドを追加します。

slackbot.rs

use axum::{http::StatusCode, Json};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct Request {
    token: String,
    challenge: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct Response {
    ok: bool,
    challenge: Option<String>,
}

pub async fn slackbot(Json(req): Json<Request>) -> (StatusCode, Json<Response>) {
    let res = Json(Response {
        ok: true,
        challenge: req.challenge,
    });

    (StatusCode::OK, res)
}

3. main.rs に新しく POST を受け取れる Route を追加します。

main.rs

- use axum::{http::StatusCode, routing::get, Router};
+ use axum::{
+     http::StatusCode
+     routing::{get, post},
+     Router,
+ };
use std::net::SocketAddr;

+ mod slackbot;
+ use crate::slackbot::slackbot;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(handler))
+         .route("/slackbot", post(slackbot));

    let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
4/5 .env を導入する

「準備① 2/4 Token 取得」で取得できた値 .env ファイルに設定します。読み取りには dotenv クレートを使います。

main.rs

use std::net::SocketAddr;

+ use dotenv::dotenv;

mod slackbot;
use crate::slackbot::slackbot;

(中略)

async fn init() {
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::DEBUG)
        .init();

+     dotenv().ok();
}
5/5 tokenでバリデートする。

「4/5 .env を導入する」で設定した値でバリデートします。

slackbot.rs

+ use std::env;
+ 
use axum::{http::StatusCode, Json};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct Request {
    token: String,
    challenge: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct Response {
    ok: bool,
    challenge: Option<String>,
}

pub async fn slackbot(Json(req): Json<Request>) -> (StatusCode, Json<Response>) {
+     // Validate 
+     let verification_token =
+         env::var("VERIFICATION_TOKEN").expect("VERIFICATION_TOKEN must be set");
+ 
+     if req.token != verification_token {
+         tracing::warn!("AuthenticationFailed, token: {}", req.token);
+         let res = Json(Response {
+             ok: false,
+             challenge: None,
+         });
+         return (StatusCode::BAD_REQUEST, res);
+     }

    let res = Json(Response {
        ok: true,
        challenge: req.challenge,
    });

    (StatusCode::OK, res)
}

準備② URL の登録

EC2 で適当なインスタンスを立ち上げて、SSH と HTTP で通信ができるように設定します。

EventSubscriptions (https://api.slack.com/apps/XXXXXXXXXXXX/event-subscriptions?) で、上記で準備した EC2 の URL (例: http://ec2-12-345-678-90.ap-northeast-1.compute.amazonaws.com/slackbot) を RequestURL の欄に入力します。

Slack API から送られてきた challenge を返せていれば Verified になります。

実装② Bot から Slack に投稿する

1/2 Slack からの入力を受け取る

デシリアライズ Event を追記します。

slackbot.rs

#[derive(Debug, Deserialize)]
pub struct Request {
    token: String,
    challenge: Option<String>,
+     event: Option<Event>,
}

+ #[derive(Debug, Deserialize)]
+ struct Event {
+     channel: String,
+     user: String,
+     text: String,
+     thread_ts: Option<String>,
+ }
2/2 Bot からオウム返しする

reqwest クレートで Slack に 投稿します。

  1. Botからの投稿に返答してしまうと無限ループになってしまうため、無視します。

slackbot.rs

#[derive(Debug, Deserialize)]
struct Event {
    channel: String,
    user: String,
    text: String,
    thread_ts: Option<String>,
}

+ impl Event {
+     fn from_bot(&self, bot_user: String) -> bool {
+         self.user == bot_user
+     }
+ }

(中略)

    // Validate
    let verification_token =
        env::var("VERIFICATION_TOKEN").expect("VERIFICATION_TOKEN must be set");
    if req.token != verification_token {
        tracing::warn!("AuthenticationFailed, token: {}", req.token);
        let res = Json(Response {
            ok: false,
            challenge: None,
        });
        return (StatusCode::BAD_REQUEST, res);
    }

+     // Validate
+     match &req.event {
+         Some(event) => {
+             let bot_user = env::var("BOT_USER").expect("BOT_USER must be set");
+             if event.from_bot(bot_user) {
+                 tracing::debug!("From happy bot");
+                 let res = Json(Response {
+                     ok: false,
+                     challenge: None,
+                 });
+                 return (StatusCode::BAD_REQUEST, res);
+             }
+         }
+         None => {
+             let res = Json(Response {
+                 ok: false,
+                 challenge: None,
+             });
+             return (StatusCode::BAD_REQUEST, res);
+         }
+     }

ここでデバッグ用途に tracing クレートを追加しました。

2. リクエストを challenge のあるなしで分岐させます。

slackbot.rs

#[derive(Debug, Deserialize)]
pub struct Request {
    token: String,
    challenge: Option<String>,
    event: Option<Event>,
}

+ impl Request {
+     fn is_initialize(&self) -> bool {
+         self.challenge.is_some()
+     }
+ }

(中略)

    // Validate
    let verification_token =
        env::var("VERIFICATION_TOKEN").expect("VERIFICATION_TOKEN must be set");
    if req.token != verification_token {
        tracing::warn!("AuthenticationFailed, token: {}", req.token);
        let res = Json(Response {
            ok: false,
            challenge: None,
        });
        return (StatusCode::BAD_REQUEST, res);
    }

+     // Verify from Slack
+     if req.is_initialize() {
+         let res = Json(Response {
+             ok: true,
+             challenge: req.challenge,
+         });
+         return (StatusCode::OK, res);
+     }

    // Validate
    match &req.event {
        Some(event) => {
            let bot_user = env::var("BOT_USER").expect("BOT_USER must be set");
            if event.from_bot(bot_user) {
                tracing::debug!("From happy bot");
                let res = Json(Response {
                    ok: false,
                    challenge: None,
                });
                return (StatusCode::BAD_REQUEST, res);
            }
        }
        None => {
            let res = Json(Response {
                ok: false,
                challenge: None,
            });
            return (StatusCode::BAD_REQUEST, res);
        }
    }


+     let res = Json(Response {
+         ok: true,
+         challenge: None,
+     });
+     return (StatusCode::OK, res);

3. Slack への POST リクエストを追加します。

slackbot.rs

impl Event {
    fn from_bot(&self, bot_user: String) -> bool {
        self.user == bot_user
    }
}

+ #[derive(Debug, Serialize)]
+ struct PostBody {
+     text: String,
+     channel: String,
+     thread_ts: Option<String>,
+ }

#[derive(Debug, Serialize)]
pub struct Response {
    ok: bool,
    challenge: Option<String>,
}

(中略)

+     // Execute
+     let event = req.event.unwrap();
+ 
+     let post_body = PostBody {
+         text: event.text,
+         channel: event.channel,
+         thread_ts: event.thread_ts.map(|x| x),
+     };

    post_request(post_body).await;

    let res = Json(Response {
        ok: true,
        challenge: None,
    });
    return (StatusCode::OK, res);
}

+ async fn post_request(post_body: PostBody) -> reqwest::Result<()> {
+     tracing::info!("slackbot__post_request");
+ 
+     let url = "https://slack.com/api/chat.postMessage";
+     let bot_user_oauth_token =
+         env::var("BOT_USER_OAUTH_TOKEN").expect("BOT_USER_OAUTH_TOKEN must be set");
+ 
+     let client = reqwest::Client::new();
+     let response = client
+         .post(url)
+         .header(
+             reqwest::header::AUTHORIZATION,
+             format!("Bearer {}", bot_user_oauth_token),
+         )
+         .json(&post_body)
+         .send()
+         .await?;
+ 
+     Ok(())
+ }

できたコードを EC2 にデプロイして、実際に Slack で入力をすると、オウム返しができるようになりました。設定したチャンネルで何か投稿してみてください。

まとめ

そのうち以下のようなテーマも学習していきたいと考えています。

  • ファイル分割
  • ORM の導入
  • エラーハンドリング

また、今後実運用を見据えての検証として、

  • test 導入
  • linter formatter 導入

などを考えております。

面白かったところ

  • 所有権
  • Option、Some
  • 構造体、型

はまったところ

ファイル、ワークスペース分割のルールが一番はまりました。

面白かったところとはまったところは紙一重で、Python とは書き方が違うところかなと思います。

上記以外にも細かいつまずきが多かったですが、とても楽しく学ぶことができました。

Python は書きやすさを重視した言語だと言うことを改めて実感しました。普段あまり考えずに書いていたところを見直す良い機会となりました。

*1:Rust では Python のパッケージに相当するものをクレートと呼びます。