🦋

RustでBlueskyのcliアプリを作った話

2024/04/03に公開

お久しぶりです。harukunです。
このブログを書き始めたのは2024年4月1日。
そう、お気づきでしょうか...もう1年生が終わり2年生になってしまいました。
時間が経つの早すぎる...

今回はそんな1年生の終わりの春休みに少し開発をしたのでそのまとめ記事になっています。

ではごゆっくり🍵

Blueskyとは

Blueskyは、ジャック・ドーシー氏(Twitter創業者)らが立ち上げた新しい分散型SNSです。
従来のSNSは、企業が運営するサーバーにユーザーデータが集中管理されています。一方、Blueskyは複数のサーバーが連携してデータを分散管理するため、ユーザー自身が情報管理を行うことができます。
Blueskyはオープンなプロトコルを採用しており、他の分散型SNSとの相互運用が可能です。そのため、ユーザーは情報を自由に持ち運ぶことができます。
従来のSNSは、アルゴリズムによってユーザーに表示される情報が左右されます。一方、Blueskyはアルゴリズムの影響を受けにくいため、より多様な情報に触れることができます。

↑以上Geminiくんに説明してもらいました。

今までは招待制のclosedなSNSだったのですが、2024年2月7日に招待制が廃止され誰でも利用できるようになりました!

いろいろ特徴があるのですが、大きくまとめると

  • 分散型SNSであること[1]
  • 新しい通信プロトコルであるAT Protocolを使っていること[2]
  • タイムラインを自由にカスタマイズできること
    が挙げられます(他にも沢山いいところがあるので気になる人はぜひ調べてみてね)

とくにタイムラインの拡張性は凄まじく、Twitterではよくわからないアルゴリズムで表示されたものを眺めるだけですが、Blueskyではユーザーが自由にタイムライン(フィードと呼ばれる)を作成・公開することができ

自分の場合、こんな感じでプログラミング関連のワードが含まれている投稿だけ表示させるフィードを作っています。

今回作ったもの

春休みだから何か開発しよう...そうだ!最近BlueskyってSNS知ったんだった...なにやらOSSらしいしBotでも作るか。
(公式ドキュメント全英語で心が折れる)
よ、ヨシ!もうちょい簡単なもの作ろう!昔(Xになる前)Twitterのcliアプリ作ってる人いたな、パクろう!!!

こんな経緯でターミナル上でBlueskyを操作できるcliアプリを開発することを決めました。

完成品(仮)はこちら↓
https://x.com/pieceofharuki/status/1773748137628471372?s=20
github→ https://github.com/noharu36/bsky_cli

現時点では

  • 投稿
  • 投稿の削除
  • 自分のプロフィールの確認
  • フォロワーの情報の確認
  • フォローしている人の情報の確認
    ができます。

今後もちょこちょこできることを増やしたり、見た目をもう少しリッチにしていこうと思ってます。
なにかいい改善案が見つかったら教えてください!

使用した外部クレート

Cargo.toml
[dependencies]
dialoguer = "0.11.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0.197", features = ["derive"]}
serde_json = "1.0"
chrono = "0.4.35"

それぞれ簡単に説明すると

  • dialoguer: インタラクティブな標準入力を可能にする
  • reqwest: HTTPリクエストを送れるようになる
  • tokio: 非同期処理の実装
  • serde, serde_json: Rustのデータ構造(構造体など)をシリアライズ、デシリアライズする
  • chrono: 現在時刻を取得したり、日付をいろんな形式に変換する

実装

ログイン

create_session.rs
use dialoguer::{Input, Password};
use reqwest::{Client, Error};
use serde::Serialize;
use serde_json::Value;

#[derive(Serialize)]
struct JsonData {
    identifier: String,
    password: String,
}

#[derive(Debug)]
pub struct ResData {
    pub did: String,
    pub name: String,
    pub access_jwt: String,
    pub refresh_jwt: String
}

pub async fn create_session() -> Result<ResData, Error> {
    println!("You need login. Prease enter email and password.");
    let email = Input::<String>::new()
        .with_prompt("email").interact().ok().unwrap();
    let pass = Password::new()
        .with_prompt("password").with_confirmation("Once more", "Uncorrect")
        .interact().ok().unwrap();

    //println!("email: {}, pass: {}", email, pass);

    let url = "https://bsky.social/xrpc/com.atproto.server.createSession";
    let json_data = JsonData {identifier: email, password: pass};

    let client = Client::new();

    let res = client.post(url)
        .header("Content-Type", "application/json")
        .body(serde_json::to_string(&json_data).unwrap())
        .send()
        .await?;

    let response_body: Value = serde_json::from_str(&res.text().await?).ok().unwrap();

    //println!("{:?}", response_body);

    let res_data = ResData {
        did: response_body["did"].as_str().unwrap().to_string(),
        name: response_body["handle"].as_str().unwrap().to_string(),
        access_jwt: response_body["accessJwt"].as_str().unwrap().to_string(),
        refresh_jwt: response_body["refreshJwt"].as_str().unwrap().to_string()
    };

    Ok(res_data)
}

まずはログイン。
アカウントに紐づけられたメアドとパスワードを受け取って、アクセストークンなどを返す関数を作ります。

dialoguer::Inputでメアドの入力を受け取り、dialoguer::Passwordでパスワードを受け取ります。
Passwordは入力中のパスワードを隠して表示させず、確認のために2度の入力を必要とします。

urlはここを参照。
正確にはLexiconという独自のスキーマシステムらしいんですが[3]

そしてreqwest::Clientを用いてHTTPクライアントを作成し、入力から受け取ったメアドとパスワードが入った構造体をserde,serde_jsonでJsonにシリアライズしたものをbodyに設定します。
この時、非同期処理を実装するためにtokioを導入し関数にasyncをつけてます。

POSTリクエストが成功しメアドとパスワードが正しかった場合、以下のようなJsonが帰ってきます。

{
  "did": "did:plc:humpf4d2w2kagx5pjffr6agv",
  "didDoc": {
    "@context": [
      "https://www.w3.org/ns/did/v1",
      "https://w3id.org/security/multikey/v1",
      "https://w3id.org/security/suites/secp256k1-2019/v1"
    ],
    "id": "did:plc:humpf4d2w2kagx5pjffr6agv",
    "alsoKnownAs": [
      "at://clitest.bsky.social"
    ],
    "verificationMethod": [
      {
        "id": "did:plc:humpf4d2w2kagx5pjffr6agv#atproto",
        "type": "Multikey",
        "controller": "did:plc:humpf4d2w2kagx5pjffr6agv",
        "publicKeyMultibase": "zQ3shWpTRqBvwNDsxcNMW8xw6fEhYv9heeMVGW5AP7abRsHPM"
      }
    ],
    "service": [
      {
        "id": "#atproto_pds",
        "type": "AtprotoPersonalDataServer",
        "serviceEndpoint": "https://russula.us-west.host.bsky.network"
      }
    ]
  },
  "handle": "clitest.bsky.social",
  "email": "hogehoge@gmail.com",
  "emailConfirmed": false,
  "accessJwt": "ここにアクセストークンが入る",
  "refreshJwt": "ここにリフレッシュトークンが入る"
}

しかしここで欲しいのは、究極

  • did(ユーザーを一意に表現するID)
  • handle(ユーザーが自分で設定できるID。Twitterとかと同じ@から始まるアレ)
  • accessJwt(いわゆるアクセストークン)
  • refreshJwt(いわゆるリフレッシュトークン)
    のみです。

なのでこの情報のみをserde_json::Valueを用いてResDataという構造体に抜き出してきて、関数の返り値にします。

Main関数の骨組み

main.rs
use dialoguer::Select;

#[tokio::main]
async fn main() {
    println!("Hello! I am bsky_cli!");
    let Ok(login_info) = create_session().await else {
        println!("Something wrong. Please try again.");
        return;
    };

    if login_info.did == "null".to_string() {
        println!("Email address or password is incorrect. Please try again.")
    } else {
        //println!("{:?}", login_info);
        println!("Welcome {}, Authentication succeeded!", login_info.name);
        'main: loop {
            let choice = vec!["Post", "Delete", "Get Profile", "Get Follower", "Get Follows", "Exit"];

            let selection = Select::new().with_prompt("What do you want to do?")
                .items(&choice).interact().ok().unwrap();

            match selection {
                0_usize => println!("post"),
                1_usize => println!("delete"),
                2_usize => println!("profile"),
                3_usize => println!("follower"),
                4_usize => println!("follow"),

                5_usize => {
                    println!("bye👋");
                    break 'main
                },
                _ => println!("undefined selection"),
            }
        }
    }


}

次にmain関数を作っていきます。
#[tokio::main]は非同期処理を実装するためのおまじない。

プロジェクトを実行したときにまずはログインをしてもらうために、let-else文を使って先ほど書いたcreate_session関数の返り値をlogin_infoに束縛します。
(let-else文についてはこちら)

ここはゴリ押しなのですが、正しくアカウントを認識できているか(アクセストークンなどが帰ってくるかどうか)をlogin_info.did == "null".to_string()で判定しています。
僕のコードではメアドかパスワードのどちらかが間違っていた場合、ResDataの中身が全てnullになります(あまり美しくないのでここはいつか書き直したい)。

正しくアカウントを認識できていた場合、cliアプリのメイン画面が見れるようにコードを書いていきます!

まずはdialoguer::Selectで、選択肢を作ります。
これは配列を渡してあげることでその要素を矢印キーで選択・決定ができるものになっていて、選んだ選択肢によってmatch式で違う処理を書いてあげることで簡単に分岐ができます。

match式のそれぞれの動作はこのあと書いていきます。
そして、各処理が終わったあともまたメニュー画面に戻って来れるように全体をloopの中に入れて、Exitを選択したときだけbreakして処理が終わるようにします。

これでmain関数はある程度完成!
他の処理をどんどん実装します。

投稿

create_post.rs
use dialoguer::Input;
use reqwest::{Client, Error};
use serde::Serialize;
use chrono::{DateTime, Utc};
use serde_json::Value;

#[derive(Serialize)]
struct Record {
    text: String,
    createdAt: String
}

#[derive(Serialize)]
struct PostData {
    repo: String,
    collection: String,
    record: Record
}

pub async fn create_post(handle: &str, access_jwt: &str) -> Result<(), Error> {
    println!("Tell me what you would like to post.");
    let content = Input::<String>::new().interact().ok().unwrap();

    let url = "https://bsky.social/xrpc/com.atproto.repo.createRecord";
    let now: DateTime<Utc> = Utc::now();
    let post_data = PostData {
        repo: handle.to_string(),
        collection: "app.bsky.feed.post".to_string(),
        record: Record {
            text: content,
            createdAt: format!("{}", now.to_rfc3339())
        }
    };

    let client = Client::new();

    let mut headers = reqwest::header::HeaderMap::new();

    let token = format!("Bearer {}", access_jwt);

    headers.insert("AUTHORIZATION", reqwest::header::HeaderValue::from_str(&token).unwrap());

    let res = client.post(url)
        .header("Content-Type", "application/json")
        .headers(headers)
        .body(serde_json::to_string(&post_data).unwrap())
        .send()
        .await?;

    let posted: Value = serde_json::from_str(&res.text().await?).ok().unwrap();
    
    println!("Success!\nurl {}", posted["uri"].as_str().unwrap().to_string());

    Ok(())
}

create_sessionの時に受け取ったhandleとaccess_jwtを引数として渡し、投稿に成功するとその投稿のurlを表示するようにします。

実装はcreate_sessionと大きくは変わりません。
dialoguer::Inputで投稿の内容を受け取り、reqwestを使ってPOSTリクエストを送ります。
しかし、ここで少し問題になるのが3点

  • 投稿時間
  • header
  • bodyの中身
    です。

まず投稿時間。
投稿時間自体は、chronoを使うことで現在時刻を取得すればいいのですが、
なにやら2024-04-01T06:25:45.667Zのような形(ISO8601という規格らしい)で送らなければいけないようです。
これはchrono::Datatimeのto_rfc3339()メソッドを使うことで解決しました。

次にheader。
create_sessionで受け取ったアクセストークンをAuthorization: Bearer {ここにトークン}のような形でheaderに追加しなければなりません。
reqwest::Clientのheader()メソッドではうまくいかず、headers()メソッドならうまくいくようでした(よくわかってない)。

最後にbody。

curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \
    -H "Authorization: Bearer $ACCESS_JWT" \
    -H "Content-Type: application/json" \
    -d "{\"repo\": \"$BLUESKY_HANDLE\", \"collection\": \"app.bsky.feed.post\", \"record\": {\"text\": \"Hello world! I posted this via the API.\", \"createdAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}"

今回、curlでいうとこのようなものを送信しているのですが、body(-dの部分)のなかにrecordが入っていることがわかります。
rustでは

struct Hoge {
    huga: {
        a1: String,
        a2: String
    },
    piyo: {
        b1: String,
        b2: String
    }
}

のように、構造体の中で入れ子のように書くことができません。

#[derive(Serialize)]
struct Record {
    text: String,
    createdAt: String
}

#[derive(Serialize)]
struct PostData {
    repo: String,
    collection: String,
    record: Record
}

なのでrecordの部分はこのように別の構造体を作ってあげないといけないのですね。

投稿の削除

delete_post.rs
use dialoguer::Input;
use reqwest::{Client, Error};
use serde::Serialize;

#[derive(Serialize)]
struct PostData {
    repo: String,
    collection: String,
    rkey: String
}


pub async fn delete_post(handle: &str, access_jwt: &str) -> Result<(), Error> {
    println!("Tell me the post url.");
    let record_key = Input::<String>::new().interact().ok().unwrap();

    let url = "https://bsky.social/xrpc/com.atproto.repo.deleteRecord";

    let post_data = PostData {
        repo: handle.to_string(),
        collection: "app.bsky.feed.post".to_string(),
        rkey: record_key,
    };
    
    let client = Client::new();

    let mut headers = reqwest::header::HeaderMap::new();

    let token = format!("Bearer {}", access_jwt);

    headers.insert("AUTHORIZATION", reqwest::header::HeaderValue::from_str(&token).unwrap());

    let res = client.post(url)
        .header("Content-Type", "application/json")
        .headers(headers)
        .body(serde_json::to_string(&post_data).unwrap())
        .send()
        .await?;

    if res.status().is_success() {
        println!("Delete success!")
    } else {
        println!("Oh, delete failed. Please try again.")
    }

    Ok(())
}

削除したい投稿のurlの最後のパス

選択してる部分
を入力から受け取り、その投稿を削除します。

|ATP |did(ユーザーを一意に区別)           |collection(投稿)   |rkey(投稿ごとの識別)|
 at://did:plc:humpf4d2w2kagx5pjffr6agv/app.bsky.feed.post/3kp5vwuqlzu2c

なぜurlの最後の部分のみで識別できるのかは、ATProtocol独自のurlを見てみればわかります。
投稿のurlはこのようになっていて、didの部分でユーザーを、collectionの部分でこのurlが投稿であることを(collectionはNSIDでなければいけない[4])、rkeyでどの投稿なのかを識別します。

そこで今回送信するbodyの中身であるPostDataを見てみると、

struct PostData {
    repo: String,
    collection: String,
    rkey: String
}

repo、collection、rkeyが定義されており、ユーザーからはrkey、つまりurlの最後の部分のみ入力して貰えばいいことがわかりますね!
(とはいえこれではユーザーフレンドリーと言えないですよね...うまくurlをパースできればrkeyのみ取り出すことが出来そうなので実装頑張ってみます)

プロフィールの確認

get_profile.rs
use reqwest::{Client, Error};
use serde_json::Value;
use chrono::{Local, DateTime};

#[derive(Debug)]
pub struct Profile {
    pub did: String,
    pub handle: String,
    pub display_name: String,
    pub description: String,
    pub indexed: String,
    pub follower: u64,
    pub follows: u64,
    pub posts_count: u64,
}


pub async fn get_profile(handle: &str, access_jwt: &str) -> Result<(), Error> {
    let url = format!(
        "https://bsky.social/xrpc/app.bsky.actor.getProfile?actor={}",
        handle);

    let client = Client::new();

    let mut headers = reqwest::header::HeaderMap::new();

    let token = format!("Bearer {}", access_jwt);

    headers.insert("AUTHORIZATION", reqwest::header::HeaderValue::from_str(&token).unwrap());

    let res = client.get(url)
        .header("Content-Type", "application/json")
        .headers(headers)
        .send()
        .await?;

    let response_body: Value = serde_json::from_str(&res.text().await?).ok().unwrap();

    let time = DateTime::parse_from_rfc3339(response_body["indexedAt"].as_str().unwrap()).ok().unwrap().with_timezone(&Local);

    let profile = Profile{
        did: response_body["did"].as_str().unwrap().to_string(),
        handle: response_body["handle"].as_str().unwrap().to_string(),
        display_name: response_body["displayName"].as_str().unwrap().to_string(),
        description: response_body["description"].as_str().unwrap_or_else(|| "none").to_string(),
        indexed: time.format("%Y-%m-%dT%H:%M:%S").to_string(),
        follower: response_body["followersCount"].as_u64().unwrap(),
        follows: response_body["followsCount"].as_u64().unwrap(),
        posts_count: response_body["postsCount"].as_u64().unwrap(),
    };

    println!(
"Your profile\n
did: {}
handle: {}
display name: {}
follower: {}  follows: {}
started at {}  {}posts
description ↓
{}
", profile.did, profile.handle, profile.display_name, profile.follower, profile.follows, profile.indexed, profile.posts_count, profile.description
        );

    Ok(())

}

自分のプロフィール情報を表示させる関数を作ります。
POSTリクエストではなくGETリクエストになったくらいで、特に詰まる部分もないですね。
うまくいけばこのように画面にプロフィールが表示されます

フォロー・フォロワーの確認

get_follows.rs
use reqwest::{Client, Error};
use serde_json::Value;
use chrono::{Local, DateTime};
use dialoguer::Select;


#[derive(Debug)]
pub struct Follow {
    pub did: String,
    pub handle: String,
    pub display_name: String,
    pub avatar_url: String,
    pub description: String,
    pub indexed: String
}

pub async fn get_follows(handle: &str, access_jwt: &str) -> Result<(), Error> {
    let url = format!(
        "https://bsky.social/xrpc/app.bsky.graph.getFollows?actor={}",
        handle);

    let client = Client::new();

    let mut headers = reqwest::header::HeaderMap::new();

    let token = format!("Bearer {}", access_jwt);

    headers.insert("AUTHORIZATION", reqwest::header::HeaderValue::from_str(&token).unwrap());

    let res = client.get(url)
        .header("Content-Type", "application/json")
        .headers(headers)
        .send()
        .await?;

    let response_body: Value = serde_json::from_str(&res.text().await?).ok().unwrap();

    let v = response_body["follows"].as_array().unwrap();

    let mut follows: Vec<Follow> = Vec::new();

    for r in v {
        let time = DateTime::parse_from_rfc3339(r["indexedAt"].as_str().unwrap()).ok().unwrap().with_timezone(&Local);
        let e = Follow {
            did: r["did"].as_str().unwrap().to_string(),
            handle: r["handle"].as_str().unwrap().to_string(),
            display_name: r["displayName"].as_str().unwrap_or_else(|| "none").to_string(),
            avatar_url: r["avatar"].as_str().unwrap_or_else(|| "none").to_string(),
            description: r["description"].as_str().unwrap().to_string(),
            indexed: time.format("%Y-%m-%dT%H:%M:%S").to_string(),
        };

        follows.push(e)
    }

    let max_flag = follows.len() / 5;

    print_follows(max_flag, 0, follows);

    Ok(())

}

fn print_follows(max: usize, flag: usize, vec: Vec<Follow>) {
    if max == 0 {
        for r in vec {
            println!(
"
did: {}
handle: {}
display_name: {}
avatar_url: {}
started at {}
description ↓
{}
", r.did, r.handle, r.display_name, r.avatar_url, r.indexed, r.description
                );
        }
    } else if flag == max {
        for i in max*5..vec.len() {
            println!(
"
did: {}
handle: {}
display_name: {}
avatar_url: {}
started at {}
description ↓
{}
", vec[i].did, vec[i].handle, vec[i].display_name, vec[i].avatar_url, vec[i].indexed, vec[i].description
                );
        }
    } else {
        for i in flag*5..flag*5+5 {
            println!(
"
did: {}
handle: {}
display_name: {}
avatar_url: {}
started at {}
description ↓
{}
", vec[i].did, vec[i].handle, vec[i].display_name, vec[i].avatar_url, vec[i].indexed, vec[i].description
                );
        }
        let choice = vec!["More", "Exit"];
        let selection = Select::new().items(&choice).interact().ok().unwrap();
        match selection {
            0_usize => print_follows(max, flag+1, vec),
            1_usize => return,
            _ => println!("undefined selection"),
        }
    }
}

自分がフォローしている人もしくはフォロワーを取得し、表示する関数を作ります。
フォロー・フォロワーの違いはほぼない(followって書いてるとこをfollowerにするくらい)ので、今回はフォローに絞って解説します。

GETリクエストを送るところまでは今までとほぼ同じなのですが、当然フォローしている人は複数人いる可能性があるので、レスポンスを成形してVec<Follow>に突っ込んでいきます。

次は画面にフォローしている人の情報を表示していくのですが、少し考えてみてください。
考えなしに全員を一気に表示させるのはだいぶ無謀です(1000人とかフォローしてると大変なことになりますよね)。

なので画面に五人ずつ表示して、dialoguer::Selectを使ってさらに情報をみるかメニュー画面に戻るか選択できるようにしています。

main関数の仕上げ

lib.rs
pub mod create_session;
pub mod create_post;
pub mod delete_post;
pub mod get_profile;
pub mod get_follower;
pub mod get_follows;

今まで作った関数をlib.rsに追加します。

main.rs
use bsky_cli::create_session::create_session;
use bsky_cli::create_post::create_post;
use bsky_cli::delete_post::delete_post;
use bsky_cli::get_profile::get_profile;
use bsky_cli::get_follower::get_follower;
use bsky_cli::get_follows::get_follows;
use dialoguer::Select;

#[tokio::main]
async fn main() {
    println!("Hello! I am bsky_cli!");
    let Ok(login_info) = create_session().await else {
        println!("Something wrong. Please try again.");
        return;
    };
                .
                .
                .

            match selection {
                0_usize => create_post(&login_info.name, &login_info.access_jwt).await.ok().unwrap(),
                1_usize => delete_post(&login_info.name, &login_info.access_jwt).await.ok().unwrap(),
                2_usize => get_profile(&login_info.name, &login_info.access_jwt).await.ok().unwrap(),
                3_usize => get_follower(&login_info.name, &login_info.access_jwt).await.ok().unwrap(),
                4_usize => get_follows(&login_info.name, &login_info.access_jwt).await.ok().unwrap(),

                5_usize => {
                    println!("bye👋");
                    break 'main
                },
                _ => println!("undefined selection"),
            }
        }
    }


}

最後にmain関数に他の関数をimportして、match式にそれぞれ処理を書いてあげれば完成です!

まとめ・感想

ふと思い立って突発的に開発を始め、だいたい2~3週間である程度完成させることができました。
この1年間、バックエンドをRustで書いてSNS作ろうとしたり、Rustでファミコンエミュレータ作ろうとしたり、エディタ自作しようとしたりといろいろ挑戦はしてきましたが、完成させたものはほとんどありませんでした(Discord Botくらいかな?)。
今回、何度か壁にぶち当たりながらもなんとか完成させることができたのはとても嬉しかったです!

しかし、反省点・改善点も沢山あるなと感じます。
例えば最後のフォロー・フォロワーの確認のところ、もしフォロワーが10万人とかいた時にどうするんだとか、ところどころゴリ押しで書いてるせいで、関数をResult型にしてるのに欲しいエラー吐いてくれなかったとか、そもそもcliアプリを開発する必要があったのか(個人開発だからいいけど、ニーズなくね?とか)...

追加したい機能もあります。
他の人のフォロー・フォロワーの情報を見たり、TLを取得して投稿にいいねを押せるようにしたり...

そういう意味ではまだ完璧な完成はしてないのかなと思います。
今後も開発は続けていくし、新しく実装できたことがあればこの記事を更新していきます。

ずいぶん長くなってしまいましたが、最後まで読んでいただきありがとうございました。
それではまた!






(技術記事書くの初めてで何書いていいのかよくわからんし書き終わるのに3日かかった...)

脚注
  1. https://seleck.cc/1617 ↩︎

  2. https://atproto.com/ ↩︎

  3. https://scrapbox.io/Bluesky/Lexicon ↩︎

  4. https://atproto.com/specs/nsid (僕もまだあまりわかってないです🙇‍♂️) ↩︎

Discussion