はじめに
今回は、Rust の Discord Bot フレームワークである Poise と、無料枠で使える Gemini API( Gemini 2.5 Flash1 )を組み合わせて、 Google 検索機能を持った AI Bot を作成します。
要件と実装方針は次の通りです。
- Bot は Discord のスラッシュコマンドで実行できるようにする
- Gemini API は SDK を使わず、
reqwestで REST API を直接呼び出す
開発環境
開発には Rust(Cargo を含む公式ツールチェーン)がインストールされている環境が必要です。
cargo new <プロジェクト名> で新規プロジェクトを作成して、そのディレクトリへ移動します。
依存関係の追加
以下のコマンドを実行して、プロジェクトに必要なクレートを追加します。
cargo add poise serde_json dotenvy
cargo add tokio --features macros,rt-multi-thread
cargo add reqwest --features json
cargo add serde --features derive
コマンドを実行すると Cargo.toml の dependencies は次のようになります2。
[dependencies]
dotenvy = "0.15.7"
poise = "0.6.1"
reqwest = { version = "0.12.28", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
環境変数の設定
プロジェクトのルートディレクトリに .env ファイルを作成し、Gemini と Discord の API 利用に必要な設定値を記述します。
GEMINI_API_MODEL=gemini-2.5-flash
GEMINI_API_KEY=<YOUR_GEMINI_API_KEY>
DISCORD_BOT_TOKEN=<YOUR_DISCORD_BOT_TOKEN>
.env ファイルはソース管理の対象としないようにしておきましょう。
API キーやトークンを取得したうえで、それぞれを設定してください。
- GEMINI_API_KEY: Google AI Studio で取得した API キー
- DISCORD_BOT_TOKEN: Discord Developer Portal から取得した Bot のトークン
Gemini API 呼び出し処理の実装
冒頭に記載した通り、今回はライブラリを使用せず、 reqwest を使って REST API を直接叩く形で実装します。 まずは API のリクエスト仕様に合わせて Rust 側の構造体を定義し、その後に HTTP リクエストを送信する関数を作成します。
今回は説明を簡単にするため、すべてのコードを main.rs に直接書いていきます。
リクエスト構造体の定義
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct GeminiPart {
text: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct GeminiSystemInstruction {
parts: Vec<GeminiPart>,
}
#[derive(Serialize, Deserialize, Debug)]
struct GeminiContent {
parts: Vec<GeminiPart>,
}
#[derive(Serialize, Deserialize, Debug)]
struct GeminiRequest {
system_instruction: Option<GeminiSystemInstruction>,
contents: Vec<GeminiContent>,
tools: Option<Vec<GeminiTool>>,
}
#[derive(Serialize, Deserialize, Debug)]
struct GeminiTool {
google_search: Option<GoogleSearch>,
}
#[derive(Serialize, Deserialize, Debug)]
struct GoogleSearch {}
API 通信関数の実装
定義した構造体を使って、実際に API へ POST リクエストを送る query_gemini_with_search 関数を実装します。
use reqwest::Client as ReqwestClient;
use serde_json::Value;
use std::error;
type Error = Box<dyn error::Error + Send + Sync>;
struct GeminiContext {
client: ReqwestClient,
model: String,
api_key: String,
}
// ...(中略)...
async fn query_gemini_with_search(
context: &GeminiContext,
user_text: &str,
system_instruction: Option<&str>,
) -> Result<Vec<String>, Error> {
let request = GeminiRequest {
system_instruction: system_instruction.map(|s| GeminiSystemInstruction {
parts: vec![GeminiPart {
text: s.to_string(),
}],
}),
contents: vec![GeminiContent {
parts: vec![GeminiPart {
text: user_text.to_string(),
}],
}],
tools: Some(vec![GeminiTool {
google_search: Some(GoogleSearch {}),
}]),
};
let url = format!(
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
context.model, context.api_key
);
let response = context
.client
.post(&url)
.json(&request)
.send()
.await?
.json::<Value>()
.await?;
let texts = response
.get("candidates")
.and_then(|candidates| candidates.get(0))
.and_then(|candidate| candidate.get("content"))
.and_then(|content| content.get("parts"))
.and_then(|parts| parts.as_array())
.map(|parts_array| {
parts_array
.iter()
.filter_map(|part| {
part.get("text")
.and_then(|text| text.as_str())
.map(String::from)
})
.collect::<Vec<String>>()
})
.unwrap_or_else(|| vec!["応答を取得できませんでした。".to_string()]);
Ok(texts)
}
実装のポイント
-
google_searchフィールドに空のオブジェクトを設定するだけで、検索機能が有効になります3 -
system_instructionをquery_gemini_with_searchの引数として受け取ることで、Bot の「キャラ付け」や「振る舞い」を呼び出し元で柔軟に変えられるようにしています - 今回はレスポンス仕様の説明を省略するため
serde_json::Valueとして受け取り、必要な部分だけを抜き出しています - レスポンスの形式が想定と異なる場合は、簡易的に「応答を取得できませんでした。」というメッセージを返しています(エラーハンドリングを簡略化しています)
Discord Bot の実装
API リクエスト処理が完成したので、それを Discord のスラッシュコマンドとして呼び出せるように実装します。 Poise のマクロ機能を使うことで、コマンド定義をシンプルに実装できます。
コマンドの定義
今回は親コマンド /gemini の下に、2 つのサブコマンドを作成します。それぞれのコマンドで異なるシステムプロンプト( system_instruction )を渡すことで、同じ API リクエスト関数を使いながら Bot の役割を切り替えることができます。
type Context<'a> = poise::Context<'a, GeminiContext, Error>;
// ...(中略)...
async fn send_answers(ctx: Context<'_>, answers: Vec<String>) -> Result<(), Error> {
for answer in answers {
// Discord のメッセージ上限(2000 文字)を超える場合は分割して送信
let chars: Vec<char> = answer.chars().collect();
for chunk in chars.chunks(2000) {
ctx.say(chunk.iter().collect::<String>()).await?;
}
}
Ok(())
}
#[poise::command(slash_command, subcommands("talk", "tech"))]
async fn gemini(_: Context<'_>) -> Result<(), Error> {
Ok(())
}
#[poise::command(slash_command, description_localized("ja", "Gemini に話しかけます。"))]
async fn talk(
ctx: Context<'_>,
#[description = "プロンプトを入力"] prompt: String,
) -> Result<(), Error> {
ctx.defer().await?;
let answers = query_gemini_with_search(
ctx.data(),
&prompt,
Some("特に指示がない場合は日本語で回答してください。"),
)
.await?;
send_answers(ctx, answers).await?;
Ok(())
}
#[poise::command(
slash_command,
description_localized("ja", "技術トレンドやライブラリの最新情報を検索して要約します。")
)]
async fn tech(
ctx: Context<'_>,
#[description = "知りたい技術やライブラリ名を入力"] prompt: String,
) -> Result<(), Error> {
ctx.defer().await?;
let system_prompt = "あなたはテクニカルライターです。\
Google検索を使用して最新の公式ドキュメントやリリースノート、技術ブログを探し、その内容に基づいて正確かつ技術的に詳細な回答をしてください。\
特に指示がない場合は日本語で回答してください。";
let answers = query_gemini_with_search(ctx.data(), &prompt, Some(system_prompt)).await?;
send_answers(ctx, answers).await?;
Ok(())
}
実装のポイント
- Discord のスラッシュコマンドは 3 秒以内にレスポンスを返さないとエラー扱いになります。そのため
ctx.defer().await?;として応答を先延ばしにしています。代わりにctx.say(format!("> {}", prompt)).await?;として入力内容を先に表示する実装にしても良いと思います - Discord API 経由で一度に送信できるメッセージの上限は 2000 文字です。サンプルコードでは 2000 文字で単純に分割していますが、この方法ではマークダウン(特にコードブロック)の途中で分割され、表示が崩れる可能性があります。実運用ではマークダウン構造を考慮した分割処理を実装することを推奨します
メイン関数のセットアップ
最後に、Bot を起動するためのメイン関数を実装します。 poise::Framework をセットアップして、起動時にコマンドを Discord へ登録する処理を記述します。
use dotenvy::dotenv;
use poise::serenity_prelude as serenity;
use std::{env, error};
// ...(中略)...
#[tokio::main]
async fn main() -> Result<(), Error> {
dotenv().expect("Failed to load .env file");
let model = env::var("GEMINI_API_MODEL").expect("GEMINI_API_MODEL is not set");
let api_key = env::var("GEMINI_API_KEY").expect("GEMINI_API_KEY is not set");
let token = env::var("DISCORD_BOT_TOKEN").expect("DISCORD_BOT_TOKEN is not set");
let intents = serenity::GatewayIntents::GUILD_MESSAGES;
let options = poise::FrameworkOptions {
commands: vec![gemini()],
..Default::default()
};
let framework = poise::Framework::builder()
.options(options)
.setup(|ctx, _ready, framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
let context = GeminiContext {
client: ReqwestClient::new(),
model,
api_key,
};
Ok(context)
})
})
.build();
let mut client = serenity::ClientBuilder::new(token, intents)
.framework(framework)
.await?;
client.start().await?;
Ok(())
}
完全版のコード
use dotenvy::dotenv;
use poise::serenity_prelude as serenity;
use reqwest::Client as ReqwestClient;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{env, error};
type Error = Box<dyn error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, GeminiContext, Error>;
struct GeminiContext {
client: ReqwestClient,
model: String,
api_key: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct GeminiPart {
text: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct GeminiSystemInstruction {
parts: Vec<GeminiPart>,
}
#[derive(Serialize, Deserialize, Debug)]
struct GeminiContent {
parts: Vec<GeminiPart>,
}
#[derive(Serialize, Deserialize, Debug)]
struct GeminiRequest {
system_instruction: Option<GeminiSystemInstruction>,
contents: Vec<GeminiContent>,
tools: Option<Vec<GeminiTool>>,
}
#[derive(Serialize, Deserialize, Debug)]
struct GeminiTool {
google_search: Option<GoogleSearch>,
}
#[derive(Serialize, Deserialize, Debug)]
struct GoogleSearch {}
async fn query_gemini_with_search(
context: &GeminiContext,
user_text: &str,
system_instruction: Option<&str>,
) -> Result<Vec<String>, Error> {
let request = GeminiRequest {
system_instruction: system_instruction.map(|s| GeminiSystemInstruction {
parts: vec![GeminiPart {
text: s.to_string(),
}],
}),
contents: vec![GeminiContent {
parts: vec![GeminiPart {
text: user_text.to_string(),
}],
}],
tools: Some(vec![GeminiTool {
google_search: Some(GoogleSearch {}),
}]),
};
let url = format!(
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
context.model, context.api_key
);
let response = context
.client
.post(&url)
.json(&request)
.send()
.await?
.json::<Value>()
.await?;
let texts = response
.get("candidates")
.and_then(|candidates| candidates.get(0))
.and_then(|candidate| candidate.get("content"))
.and_then(|content| content.get("parts"))
.and_then(|parts| parts.as_array())
.map(|parts_array| {
parts_array
.iter()
.filter_map(|part| {
part.get("text")
.and_then(|text| text.as_str())
.map(String::from)
})
.collect::<Vec<String>>()
})
.unwrap_or_else(|| vec!["応答を取得できませんでした。".to_string()]);
Ok(texts)
}
async fn send_answers(ctx: Context<'_>, answers: Vec<String>) -> Result<(), Error> {
for answer in answers {
// Discord のメッセージ上限(2000 文字)を超える場合は分割して送信
let chars: Vec<char> = answer.chars().collect();
for chunk in chars.chunks(2000) {
ctx.say(chunk.iter().collect::<String>()).await?;
}
}
Ok(())
}
#[poise::command(slash_command, subcommands("talk", "tech"))]
async fn gemini(_: Context<'_>) -> Result<(), Error> {
Ok(())
}
#[poise::command(slash_command, description_localized("ja", "Gemini に話しかけます。"))]
async fn talk(
ctx: Context<'_>,
#[description = "プロンプトを入力"] prompt: String,
) -> Result<(), Error> {
ctx.defer().await?;
let answers = query_gemini_with_search(
ctx.data(),
&prompt,
Some("特に指示がない場合は日本語で回答してください。"),
)
.await?;
send_answers(ctx, answers).await?;
Ok(())
}
#[poise::command(
slash_command,
description_localized("ja", "技術トレンドやライブラリの最新情報を検索して要約します。")
)]
async fn tech(
ctx: Context<'_>,
#[description = "知りたい技術やライブラリ名を入力"] prompt: String,
) -> Result<(), Error> {
ctx.defer().await?;
let system_prompt = "あなたはテクニカルライターです。\
Google検索を使用して最新の公式ドキュメントやリリースノート、技術ブログを探し、その内容に基づいて正確かつ技術的に詳細な回答をしてください。\
特に指示がない場合は日本語で回答してください。";
let answers = query_gemini_with_search(ctx.data(), &prompt, Some(system_prompt)).await?;
send_answers(ctx, answers).await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Error> {
dotenv().expect("Failed to load .env file");
let model = env::var("GEMINI_API_MODEL").expect("GEMINI_API_MODEL is not set");
let api_key = env::var("GEMINI_API_KEY").expect("GEMINI_API_KEY is not set");
let token = env::var("DISCORD_BOT_TOKEN").expect("DISCORD_BOT_TOKEN is not set");
let intents = serenity::GatewayIntents::GUILD_MESSAGES;
let options = poise::FrameworkOptions {
commands: vec![gemini()],
..Default::default()
};
let framework = poise::Framework::builder()
.options(options)
.setup(|ctx, _ready, framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
let context = GeminiContext {
client: ReqwestClient::new(),
model,
api_key,
};
Ok(context)
})
})
.build();
let mut client = serenity::ClientBuilder::new(token, intents)
.framework(framework)
.await?;
client.start().await?;
Ok(())
}
動作確認
以上で実装が完了したので、Bot を起動して動作確認をしてみます。
cargo run
コンパイルが通って Bot が起動したら Discord 上でスラッシュコマンドが使えるようになります。
(スラッシュコマンドが反映されるまでに時間がかかることがあります。)
無料枠で Gemini API を使用する場合、入力内容が製品の改善に使用される場合があります4。機密情報や個人情報などは絶対に入力しないようにしましょう。
LLM 単体では正確な回答が難しい「今日の天気」を聞いてみます。 Google Search ツールが有効になっているため、Gemini は自動的に検索を行い、その結果を元に回答してくれます。
おわりに
Rust( Poise )と Gemini API を組み合わせて、検索機能を持った Discord Bot を作成しました。
実装していて特に面白かったのは system_instruction (システムプロンプト) による振る舞いの制御です。 今回は汎用的なアシスタントと技術情報の検索要約という 2 つの役割を行うよう振る舞わせましたが、ここの指示を工夫するだけで、翻訳 Bot や特定のキャラクターになりきる Bot など、アイデア次第で可能性は無限に広がります。
ぜひオリジナルの Bot を作ってみてください。
それでは、良い LLM ライフを!
-
記事執筆時点(2025 年 12 月 31 日)で Gemini 3.0 Flash にも無料枠がありますが、Google 検索機能が無料では使えないため Gemini 2.5 を採用しています( 参考情報 ) ↩
-
記事執筆時点(2025 年 12 月 31 日)のバージョンです ↩
-
Grounding with Google Search に記載されている情報です ↩
-
Gemini Developer API pricing や Gemini API Additional Terms of Service に記載されている情報です ↩
