お一人様でも寂しくない! RustとChatGPTで分散SNS上に話し相手を作る

こんにちは、皆さん。アソビュー! Advent Calendar 2023の10日目(B面)です。

11月にアソビューにジョインしました竹村です。今日は、私が最近趣味で作成した、RustとChatGPTを使用したチャットボットの開発についてお話しします。なぜこの二つの技術に焦点を当てたのかというと、Rustの堅牢なメモリ管理と高いパフォーマンス、そしてChatGPTの自然な会話能力に魅力を感じたからです。両者を組み合わせることで、お一人様用の分散SNS上で話し相手になってもらえるのか、その可能性を模索していきたいと思います。

この記事では、RustとChatGPTの選択理由、開発プロセスの紹介、そして開発中に直面した課題とその解決策について詳しく説明していきます。開発を通じての学びや発見も共有できればと思います。

Rustの紹介

Rustは、安全性、速度、並行性を重視したシステムプログラミング言語です。ガベージコレクション(GC)を使用しないメモリ管理により、ランタイムのオーバーヘッドを削減し、高いパフォーマンスを実現します。所有権というユニークなシステムを採用することで、メモリの安全性を保証し、メモリリークや不正アクセスを防ぎます。また、データ競合を防ぐための効果的な並行処理機能を備えており、信頼性と効率性を兼ね備えています。

今回は特徴の一つであるGCを使用しないメモリ管理に興味を持ったことと、その他の特徴がチャットボットの開発に向いていると考えて開発言語に選びました。

ChatGPTの活用

ChatGPTは先進的な自然言語処理モデルであり、人間のような対話を生成する能力が特徴です。今回、分散SNSのインスタンス上に話し相手を作る事が目的なのでChatGPTの高い言語処理能力は外せません。ChatGPTの精度の高い理解と応答能力により、より人間らしい話し相手になってくれることを期待しています。

開発プロセス

設計フェーズ

今回は分散SNSのMisskeyで受け取ったメンションに対してレスポンスを返すという単純なボットを考えます。

チャットボットはMisskeyのWebSocketクライアントとして機能し、botアカウントに対するメンションを受け取ります。これらのメンションはChatGPT APIに送信され、自然言語処理を通じてレスポンスが生成されます。その後、レスポンスはMisskey APIを通じてMisskeyに投稿されます。

実装フェーズ

環境構築

Rustの開発環境を構築するには、まずRustの公式インストーラーであるrustupを使用します。rustupは、Rustのコンパイラであるrustc、パッケージマネージャおよびビルドツールのCargo、標準ライブラリなど、Rust開発に必要なツール類を簡単にインストールできます。 インストーラーはRustの公式ウェブサイト(https://www.rust-lang.org/)から入手してください。

また、MisskeyやChatGPTのAPIにアクセスするにはトークンやAPIキーが必要なのでこの時点で作成しておきましょう。

WebSocketクライアントの実装

まずMisskeyと接続するためのWebSocketクライアントとしての機能を作成します。 最初にターミナルでcargo new chatbotと叩きプロジェクトを作成します。

今回はWebSocketクライアントを作るためにtokio-tungsteniteを使用しするのでCargo.tomlに依存関係を追加します。 また、JSONを扱うためserde_jsonも追加します。

[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.15.0", features = ["rustls-tls"] }
futures = "*"
futures-util = "*"
serde_json = "1.0"

WebSocketクライアントの実装は下記の通り。 起動するとMisskeyサーバーに接続し、メッセージを受け取ったら出力します。

use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use tokio_tungstenite::tungstenite::Error;
use futures_util::stream::StreamExt;
use futures::{SinkExt};

#[tokio::main]
async fn main() -> Result<(), Error> {
    let url = "wss://msky.syaku.me/streaming?i={misskey_apikey}";

    let (mut ws_stream, _) = connect_async(url).await.expect("Failed to connect");

    println!("WebSocket client connected");

    let listen_message = serde_json::json!({
        "type": "connect",
        "body": {
            "channel": "main",
            "id": "main_channel"
        }
    });

    ws_stream.send(Message::Text(listen_message.to_string())).await.unwrap();

    println!("channnel in.");

    let (_write, read) = ws_stream.split();

    let read_messages = read.for_each(|message| async {
        let message = message.expect("Error reading message");
        match message {
            Message::Text(text) => println!("Received text: {}", text),
            Message::Binary(_bin) => println!("Received binary data"),
            _ => (),
        }
    });

    read_messages.await;

    Ok(())
}

ChatGPTによるレスポンスの生成

SNS上でのメッセージを受信できるようになったので次はこの中から自身に対するメンションだけ受け取ってChatGPTのAPIにPOSTする機能を作成します。 APIに対するリクエストにはreqwestを使うのでCargo.tomlに依存関係を追加します。

[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.15.0", features = ["rustls-tls"] }
futures = "*"
futures-util = "*"
serde_json = "1.0"
reqwest = { version = "0.11", features = ["json"] }

まずMisskeyからのメッセージを判定出来るように構造体を定義します。

#[derive(Clone, Serialize, Deserialize)]
pub struct User {
    pub name: Option<String>
}

#[derive(Clone, Serialize, Deserialize)]
pub struct Note {
    pub id: Option<String>,
    pub craeted_at: Option<String>,
    pub text: Option<String>,
    pub cw: Option<String>,
    pub user_id: Option<String>,
    pub user: User,
    pub visibility: Option<String>
}

#[derive(Clone, Serialize, Deserialize)]
pub struct Event {
    pub id: String,
    pub r#type: String,
    pub body: Value
}

#[derive(Clone, Serialize, Deserialize)]
pub struct Response {
    r#type: String,
    body: Event
}

こんな感じでmentionだけに絞って投稿内容を取得してChatGPTに渡します。

let read_messages = read.for_each(|message| async {
    let message = message.expect("Error reading message");
    match message {
        Message::Text(text) => {
            let res: Response = serde_json::from_str(text.as_str()).unwrap();
                
            if res.body.r#type == "mention" {
                let note: Note = serde_json::from_value(res.body.body).unwrap();
                let msg = format!("発言者:{}\n--\n{}", note.user.name.unwrap(), note.text.unwrap());

                let res = post_chat_gpt(&msg).await;
                post_misskey(note.id.unwrap(), res.await, note.visibility.unwrap()).await;
            }
        },
        Message::Binary(_bin) => println!("Received binary data"),
            _ => (),
    }
});

ChatGPTへリクエストする処理は以下のように実装しました。 本当はGPT-4を使いたかったのですが、無料期間だからか何故か選択できなかったのでGPT-3.5を指定しています。 BASE_PROMPTに基本的なルールとキャラクター設定を指定しました。 今回は個人的に好きな老執事キャラにしました。

ところで何故人は男性執事の名前をセバスチャンにしてしまうのでしょう?

const BASE_PROMPT: &str = "
    あなたはSNS上にインストールされたアシスタントbotで名前はセバスチャンです。
    執事という意味ですね。
    先頭の'発言者:'は投稿者の名前です。
    本文中の'@bot'はあなたのアカウント名を表しています。
    あなたの回答はSNS上の投稿となるので、回答の長さは500文字以下を基本としますが、必要に応じて2500文字以下まで広げることができます。
    
    以下はあなたの性格設定です。
    キャラクターとしての特徴は老齢の男性執事です。
    一人称は私、三人称は名前に様を付けます。また、~でございます等の丁寧ながらも少し気取った話し方をします。";

async fn post_chat_gpt(message: &str) -> String {
    let chat_gpt_client = Client::new();
    let _response = chat_gpt_client.post("https://api.openai.com/v1/chat/completions")
        .header("Authorization", format!("Bearer {}", "{chat_gpt_apikey}"))
        .json(&json!({
            "model": "gpt-3.5-turbo",
            "messages": [
                {
                    "role": "system",
                    "content": BASE_PROMPT
                },
                {
                    "role": "user",
                    "content": message
                }
            ]
        }))
        .send()
        .await.unwrap()
        .json::<serde_json::Value>()
        .await.unwrap();
    println!("ChatGPT: {}", serde_json::to_string_pretty(&_response).unwrap());
    return _response["choices"][0]["message"]["content"].as_str().unwrap().to_string()
}

最後にChatGPTが生成したレスポンスをMisskeyに対してPOSTする機能を実装します。

async fn post_misskey(note_id: String, message: String, visibility: String) -> Value {
    let misskey_client = Client::new();
    let response: Value = misskey_client.post("https://msky.syaku.me/api/notes/create")
        .json(&json!({
            "i": "{misskey_apikey}",
            "text": message,
            "replyId": note_id,
            "visibility": visibility
        }))
        .send()
        .await.unwrap()
        .json::<Value>()
        .await.unwrap();

    return response
}

最終的なコードは以下の通りです。

use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use tokio_tungstenite::tungstenite::Error;
use futures_util::stream::StreamExt;
use futures::{SinkExt};
use serde_json::json;
use serde_json::Value;
use serde::{Deserialize, Serialize};
use reqwest::Client;

#[derive(Clone, Serialize, Deserialize)]
pub struct User {
    pub name: Option<String>
}

#[derive(Clone, Serialize, Deserialize)]
pub struct Note {
    pub id: Option<String>,
    pub craeted_at: Option<String>,
    pub text: Option<String>,
    pub cw: Option<String>,
    pub user_id: Option<String>,
    pub user: User,
    pub visibility: Option<String>
}

#[derive(Clone, Serialize, Deserialize)]
pub struct Event {
    pub id: String,
    pub r#type: String,
    pub body: Value
}

#[derive(Clone, Serialize, Deserialize)]
pub struct Response {
    r#type: String,
    body: Event
}

const BASE_PROMPT: &str = "
    あなたはSNS上にインストールされたアシスタントbotで名前はセバスチャンです。
    執事という意味ですね。
    先頭の'発言者:'は投稿者の名前です。
    本文中の'@bot'はあなたのアカウント名を表しています。
    あなたの回答はSNS上の投稿となるので、回答の長さは500文字以下を基本としますが、必要に応じて2500文字以下まで広げることができます。
    
    以下はあなたの性格設定です。
    キャラクターとしての特徴は老齢の男性執事です。
    一人称は私、三人称は名前に様を付けます。また、~でございます等の丁寧ながらも少し気取った話し方をします。";

async fn post_chat_gpt(message: &str) -> String {
    let chat_gpt_client = Client::new();
    let _response = chat_gpt_client.post("https://api.openai.com/v1/chat/completions")
        .header("Authorization", format!("Bearer {}", "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"))
        .json(&json!({
            "model": "gpt-3.5-turbo",
            "messages": [
                {
                    "role": "system",
                    "content": BASE_PROMPT
                },
                {
                    "role": "user",
                    "content": message
                }
            ]
        }))
        .send()
        .await.unwrap()
        .json::<serde_json::Value>()
        .await.unwrap();
    println!("ChatGPT: {}", serde_json::to_string_pretty(&_response).unwrap());
    return _response["choices"][0]["message"]["content"].as_str().unwrap().to_string()
}

async fn post_misskey(note_id: String, message: String, visibility: String) -> Value {
    let misskey_client = Client::new();
    let response: Value = misskey_client.post("https://msky.syaku.me/api/notes/create")
        .json(&json!({
            "i": "LTNwqPbBAbGVnHLiE8mlZLiZo3oGKyro",
            "text": message,
            "replyId": note_id,
            "visibility": visibility
        }))
        .send()
        .await.unwrap()
        .json::<Value>()
        .await.unwrap();

    println!("response: {}", serde_json::to_string_pretty(&response).unwrap());
    return response
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    let url = "wss://msky.syaku.me/streaming?i=LTNwqPbBAbGVnHLiE8mlZLiZo3oGKyro";

    let (mut ws_stream, _) = connect_async(url).await.expect("Failed to connect");

    println!("WebSocket client connected");

    let listen_message = serde_json::json!({
        "type": "connect",
        "body": {
            "channel": "main",
            "id": "main_channel"
        }
    });

    ws_stream.send(Message::Text(listen_message.to_string())).await.unwrap();

    println!("channnel in.");

    let (_write, read) = ws_stream.split();

    let read_messages = read.for_each(|message| async {
        let message = message.expect("Error reading message");
        match message {
            Message::Text(text) => {
                let res: Response = serde_json::from_str(text.as_str()).unwrap();
                
                if res.body.r#type == "mention" {
                    let note: Note = serde_json::from_value(res.body.body).unwrap();
                    let msg = format!("発言者:{}\n--\n{}", note.user.name.unwrap(), note.text.unwrap());

                    let res = post_chat_gpt(&msg).await;
                    post_misskey(note.id.unwrap(), res, note.visibility.unwrap()).await;
                }
            },
            Message::Binary(_bin) => println!("Received binary data"),
            _ => (),
        }
    });

    read_messages.await;

    Ok(())
}

今回Rustで書いてみて所有権とムーブの概念を頭の中で想像できるようになるまでは何も考えずに引数に渡してしまってコンパイルに失敗してしまい頭を抱える場面もありましたが、慣れてくるとコンパイルが通れば少なくとも変数のアクセスでおかしな事は起きないという安心感もありますし良い仕組みだなと思いました。

実際の使用例

実際のSNSでのやりとりはこういう感じになります。 ちゃんとキャラクター設定を守って返信してくれてますね。

※この後きっちり風邪をひいてフラグ回収しました

まとめ

RustとChatGPTを使用して分散SNS上にユニークなキャラ付けをされた話し相手を作成することが出来ました。

ChatGPTの性能のおかげで現時点でも十分に自然な会話が出来ていますし、今後も会話が蓄積されることでより自然なレスポンスになることが期待できます。今後は単発の受け答えだけではなく、スレッドに対応して一連のやりとりが出来るように改修したり、リマインダーや通知などチャットボット側から発言する仕組みを作っていきたいと思います。

アソビューでは一緒に働くメンバーを募集しています!
事業成長や組織改善にコミットしたいエンジニアの方がいらっしゃいましたら、 カジュアル面談もありますので、興味があればご応募いただければと思います。

www.asoview.com