feat: テキストチャンネルの自動参加設定を追加

- 複数のテキストチャンネルをサポートするために、TTSインスタンスの構造を変更
- 自動参加テキストチャンネルの設定と解除をUIセレクトメニューで実装
- 再接続時にテキストチャンネルに通知を送信する機能を強化
- コードの可読性向上のために、エラーハンドリングとロギングを改善

🤖 Generated with [Claude Code](https://claude.ai/code)
This commit is contained in:
mii443
2025-05-28 16:08:34 +09:00
parent 733646b6b8
commit f0327e232a
11 changed files with 382 additions and 193 deletions

View File

@ -34,7 +34,10 @@ pub async fn config_command(
let tts_client = data_read let tts_client = data_read
.get::<TTSClientData>() .get::<TTSClientData>()
.expect("Cannot get TTSClientData"); .expect("Cannot get TTSClientData");
let voicevox_speakers = tts_client.voicevox_client.get_styles().await let voicevox_speakers = tts_client
.voicevox_client
.get_styles()
.await
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
tracing::error!("Failed to get VOICEVOX styles: {}", e); tracing::error!("Failed to get VOICEVOX styles: {}", e);
vec![("VOICEVOX API unavailable".to_string(), 1)] vec![("VOICEVOX API unavailable".to_string(), 1)]
@ -58,11 +61,7 @@ pub async fn config_command(
.placeholder("読み上げAPIを選択"), .placeholder("読み上げAPIを選択"),
); );
let server_button = CreateActionRow::Buttons(vec![CreateButton::new("TTS_CONFIG_SERVER") let mut components = vec![engine_select];
.label("サーバー設定")
.style(ButtonStyle::Primary)]);
let mut components = vec![engine_select, server_button];
for (index, speaker_chunk) in voicevox_speakers[0..24].chunks(25).enumerate() { for (index, speaker_chunk) in voicevox_speakers[0..24].chunks(25).enumerate() {
let mut options = Vec::new(); let mut options = Vec::new();
@ -86,6 +85,12 @@ pub async fn config_command(
)); ));
} }
let server_button = CreateActionRow::Buttons(vec![CreateButton::new("TTS_CONFIG_SERVER")
.label("サーバー設定")
.style(ButtonStyle::Primary)]);
components.push(server_button);
command command
.create_response( .create_response(
&ctx.http, &ctx.http,

View File

@ -81,32 +81,44 @@ pub async fn setup_command(
return Ok(()); return Ok(());
} }
let text_channel_id = { let text_channel_ids = {
if let Some(mode) = command.data.options.get(0) { if let Some(mode) = command.data.options.get(0) {
match &mode.value { match &mode.value {
serenity::all::CommandDataOptionValue::String(value) => { serenity::all::CommandDataOptionValue::String(value) => {
match value.as_str() { match value.as_str() {
"TEXT_CHANNEL" => command.channel_id, "TEXT_CHANNEL" => vec![command.channel_id],
"NEW_THREAD" => { "NEW_THREAD" => {
command vec![command
.channel_id .channel_id
.create_thread(&ctx.http, CreateThread::new("TTS").auto_archive_duration(AutoArchiveDuration::OneHour).kind(serenity::all::ChannelType::PublicThread)) .create_thread(&ctx.http, CreateThread::new("TTS").auto_archive_duration(AutoArchiveDuration::OneHour).kind(serenity::all::ChannelType::PublicThread))
.await .await
.unwrap() .unwrap()
.id .id]
} }
"VOICE_CHANNEL" => channel_id, "VOICE_CHANNEL" => vec![channel_id],
_ => channel_id, _ => if channel_id != command.channel_id {
vec![command.channel_id, channel_id]
} else {
vec![channel_id]
},
} }
}, },
_ => channel_id, _ => if channel_id != command.channel_id {
vec![command.channel_id, channel_id]
} else {
vec![channel_id]
},
} }
} else { } else {
channel_id if channel_id != command.channel_id {
vec![command.channel_id, channel_id]
} else {
vec![channel_id]
}
} }
}; };
let instance = TTSInstance::new(text_channel_id, channel_id, guild.id); let instance = TTSInstance::new(text_channel_ids.clone(), channel_id, guild.id);
storage.insert(guild.id, instance.clone()); storage.insert(guild.id, instance.clone());
// Save to database // Save to database
@ -121,7 +133,7 @@ pub async fn setup_command(
tracing::error!("Failed to save TTS instance to database: {}", e); tracing::error!("Failed to save TTS instance to database: {}", e);
} }
text_channel_id text_channel_ids[0]
}; };
command command

View File

@ -78,7 +78,7 @@ pub async fn stop_command(
return Ok(()); return Ok(());
} }
let text_channel_id = storage.get(&guild.id).unwrap().text_channel; let text_channel_id = storage.get(&guild.id).unwrap().text_channels[0];
storage.remove(&guild.id); storage.remove(&guild.id);
// Remove from database // Remove from database

View File

@ -1,7 +1,10 @@
use serenity::{prelude::Context, all::{CreateMessage, CreateEmbed}}; use serenity::{
all::{CreateEmbed, CreateMessage},
prelude::Context,
};
use std::time::Duration; use std::time::Duration;
use tokio::time; use tokio::time;
use tracing::{error, info, warn, instrument}; use tracing::{error, info, instrument, warn};
use crate::data::{DatabaseClientData, TTSData}; use crate::data::{DatabaseClientData, TTSData};
@ -69,7 +72,9 @@ impl ConnectionMonitor {
let data_read = ctx.data.read().await; let data_read = ctx.data.read().await;
data_read data_read
.get::<TTSData>() .get::<TTSData>()
.ok_or_else(|| ConnectionMonitorError::VoiceChannelCheck("Cannot get TTSStorage".to_string()))? .ok_or_else(|| {
ConnectionMonitorError::VoiceChannelCheck("Cannot get TTSStorage".to_string())
})?
.clone() .clone()
}; };
@ -77,7 +82,11 @@ impl ConnectionMonitor {
let data_read = ctx.data.read().await; let data_read = ctx.data.read().await;
data_read data_read
.get::<DatabaseClientData>() .get::<DatabaseClientData>()
.ok_or_else(|| ConnectionMonitorError::VoiceChannelCheck("Cannot get DatabaseClientData".to_string()))? .ok_or_else(|| {
ConnectionMonitorError::VoiceChannelCheck(
"Cannot get DatabaseClientData".to_string(),
)
})?
.clone() .clone()
}; };
@ -86,7 +95,8 @@ impl ConnectionMonitor {
for (guild_id, instance) in storage.iter() { for (guild_id, instance) in storage.iter() {
// Check if bot is still connected to voice channel // Check if bot is still connected to voice channel
let manager = songbird::get(ctx).await let manager = songbird::get(ctx)
.await
.ok_or(ConnectionMonitorError::SongbirdManagerNotFound)?; .ok_or(ConnectionMonitorError::SongbirdManagerNotFound)?;
let call = manager.get(*guild_id); let call = manager.get(*guild_id);
@ -114,8 +124,12 @@ impl ConnectionMonitor {
if should_reconnect { if should_reconnect {
// Try to reconnect with retry logic // Try to reconnect with retry logic
let attempts = self.reconnection_attempts.get(guild_id).copied().unwrap_or(0); let attempts = self
.reconnection_attempts
.get(guild_id)
.copied()
.unwrap_or(0);
if attempts >= MAX_RECONNECTION_ATTEMPTS { if attempts >= MAX_RECONNECTION_ATTEMPTS {
error!( error!(
guild_id = %guild_id, guild_id = %guild_id,
@ -129,7 +143,8 @@ impl ConnectionMonitor {
// Apply exponential backoff // Apply exponential backoff
if attempts > 0 { if attempts > 0 {
let backoff_duration = Duration::from_secs(RECONNECTION_BACKOFF_SECS * (2_u64.pow(attempts))); let backoff_duration =
Duration::from_secs(RECONNECTION_BACKOFF_SECS * (2_u64.pow(attempts)));
warn!( warn!(
guild_id = %guild_id, guild_id = %guild_id,
attempt = attempts + 1, attempt = attempts + 1,
@ -146,17 +161,24 @@ impl ConnectionMonitor {
attempts = attempts + 1, attempts = attempts + 1,
"Successfully reconnected to voice channel" "Successfully reconnected to voice channel"
); );
// Reset reconnection attempts on success // Reset reconnection attempts on success
self.reconnection_attempts.remove(guild_id); self.reconnection_attempts.remove(guild_id);
// Send notification message to text channel with embed // Send notification message to text channel with embed
let embed = CreateEmbed::new() let embed = CreateEmbed::new()
.title("🔄 自動再接続しました") .title("🔄 自動再接続しました")
.description("読み上げを停止したい場合は `/stop` コマンドを使用してください。") .description("読み上げを停止したい場合は `/stop` コマンドを使用してください。")
.color(0x00ff00); .color(0x00ff00);
if let Err(e) = instance.text_channel.send_message(&ctx.http, CreateMessage::new().embed(embed)).await {
error!(guild_id = %guild_id, error = %e, "Failed to send reconnection message"); // Send message to the first text channel
if let Some(&text_channel) = instance.text_channels.first() {
if let Err(e) = text_channel
.send_message(&ctx.http, CreateMessage::new().embed(embed))
.await
{
error!(guild_id = %guild_id, error = %e, "Failed to send reconnection message");
}
} }
} }
Err(e) => { Err(e) => {
@ -168,7 +190,7 @@ impl ConnectionMonitor {
error = %e, error = %e,
"Failed to reconnect to voice channel" "Failed to reconnect to voice channel"
); );
if new_attempts >= MAX_RECONNECTION_ATTEMPTS { if new_attempts >= MAX_RECONNECTION_ATTEMPTS {
guilds_to_remove.push(*guild_id); guilds_to_remove.push(*guild_id);
self.reconnection_attempts.remove(guild_id); self.reconnection_attempts.remove(guild_id);
@ -201,10 +223,10 @@ impl ConnectionMonitor {
error!(guild_id = %guild_id, error = %e, "Failed to remove bot from voice channel"); error!(guild_id = %guild_id, error = %e, "Failed to remove bot from voice channel");
} }
} }
info!(guild_id = %guild_id, "Removed disconnected TTS instance"); info!(guild_id = %guild_id, "Removed disconnected TTS instance");
} }
Ok(()) Ok(())
} }
@ -215,21 +237,29 @@ impl ConnectionMonitor {
ctx: &Context, ctx: &Context,
instance: &crate::tts::instance::TTSInstance, instance: &crate::tts::instance::TTSInstance,
) -> Result<bool> { ) -> Result<bool> {
let channels = instance.guild.channels(&ctx.http).await let channels = instance.guild.channels(&ctx.http).await.map_err(|e| {
.map_err(|e| ConnectionMonitorError::VoiceChannelCheck(format!("Failed to get guild channels: {}", e)))?; ConnectionMonitorError::VoiceChannelCheck(format!(
"Failed to get guild channels: {}",
e
))
})?;
if let Some(channel) = channels.get(&instance.voice_channel) { if let Some(channel) = channels.get(&instance.voice_channel) {
let members = channel.members(&ctx.cache) let members = channel.members(&ctx.cache).map_err(|e| {
.map_err(|e| ConnectionMonitorError::VoiceChannelCheck(format!("Failed to get channel members: {}", e)))?; ConnectionMonitorError::VoiceChannelCheck(format!(
"Failed to get channel members: {}",
e
))
})?;
let user_count = members.iter().filter(|member| !member.user.bot).count(); let user_count = members.iter().filter(|member| !member.user.bot).count();
info!( info!(
guild_id = %instance.guild, guild_id = %instance.guild,
channel_id = %instance.voice_channel, channel_id = %instance.voice_channel,
user_count = user_count, user_count = user_count,
"Checked voice channel users" "Checked voice channel users"
); );
Ok(user_count > 0) Ok(user_count > 0)
} else { } else {
warn!( warn!(

View File

@ -1,14 +1,14 @@
use std::fmt::Debug; use std::fmt::Debug;
use bb8_redis::{bb8::Pool, RedisConnectionManager, redis::AsyncCommands};
use crate::{ use crate::{
errors::{NCBError, Result, constants::*}, errors::{constants::*, NCBError, Result},
tts::{ tts::{
gcp_tts::structs::voice_selection_params::VoiceSelectionParams, instance::TTSInstance, gcp_tts::structs::voice_selection_params::VoiceSelectionParams, instance::TTSInstance,
tts_type::TTSType, tts_type::TTSType,
}, },
}; };
use serenity::model::id::{GuildId, UserId, ChannelId}; use bb8_redis::{bb8::Pool, redis::AsyncCommands, RedisConnectionManager};
use serenity::model::id::{ChannelId, GuildId, UserId};
use std::collections::HashMap; use std::collections::HashMap;
use super::{dictionary::Dictionary, server_config::ServerConfig, user_config::UserConfig}; use super::{dictionary::Dictionary, server_config::ServerConfig, user_config::UserConfig};
@ -22,7 +22,7 @@ impl Database {
pub fn new(pool: Pool<RedisConnectionManager>) -> Self { pub fn new(pool: Pool<RedisConnectionManager>) -> Self {
Self { pool } Self { pool }
} }
pub async fn new_with_url(redis_url: String) -> Result<Self> { pub async fn new_with_url(redis_url: String) -> Result<Self> {
let manager = RedisConnectionManager::new(redis_url)?; let manager = RedisConnectionManager::new(redis_url)?;
let pool = Pool::builder() let pool = Pool::builder()
@ -62,13 +62,13 @@ impl Database {
} }
#[tracing::instrument] #[tracing::instrument]
async fn get_config<T: serde::de::DeserializeOwned>( async fn get_config<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
&self, let mut connection = self
key: &str, .pool
) -> Result<Option<T>> { .get()
let mut connection = self.pool.get().await .await
.map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?; .map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?;
let config: String = connection.get(key).await.unwrap_or_default(); let config: String = connection.get(key).await.unwrap_or_default();
if config.is_empty() { if config.is_empty() {
@ -85,24 +85,20 @@ impl Database {
} }
#[tracing::instrument] #[tracing::instrument]
async fn set_config<T: serde::Serialize + Debug>( async fn set_config<T: serde::Serialize + Debug>(&self, key: &str, config: &T) -> Result<()> {
&self, let mut connection = self
key: &str, .pool
config: &T, .get()
) -> Result<()> { .await
let mut connection = self.pool.get().await
.map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?; .map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?;
let config_str = serde_json::to_string(config)?; let config_str = serde_json::to_string(config)?;
connection.set::<_, _, ()>(key, config_str).await?; connection.set::<_, _, ()>(key, config_str).await?;
Ok(()) Ok(())
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn get_server_config( pub async fn get_server_config(&self, server_id: u64) -> Result<Option<ServerConfig>> {
&self,
server_id: u64,
) -> Result<Option<ServerConfig>> {
self.get_config(&Self::server_key(server_id)).await self.get_config(&Self::server_key(server_id)).await
} }
@ -112,20 +108,12 @@ impl Database {
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn set_server_config( pub async fn set_server_config(&self, server_id: u64, config: ServerConfig) -> Result<()> {
&self,
server_id: u64,
config: ServerConfig,
) -> Result<()> {
self.set_config(&Self::server_key(server_id), &config).await self.set_config(&Self::server_key(server_id), &config).await
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn set_user_config( pub async fn set_user_config(&self, user_id: u64, config: UserConfig) -> Result<()> {
&self,
user_id: u64,
config: UserConfig,
) -> Result<()> {
self.set_config(&Self::user_key(user_id), &config).await self.set_config(&Self::user_key(user_id), &config).await
} }
@ -134,8 +122,9 @@ impl Database {
let config = ServerConfig { let config = ServerConfig {
dictionary: Dictionary::new(), dictionary: Dictionary::new(),
autostart_channel_id: None, autostart_channel_id: None,
voice_state_announce: Some(true), autostart_text_channel_id: None,
read_username: Some(true), voice_state_announce: Some(false),
read_username: Some(false),
}; };
self.set_server_config(server_id, config).await self.set_server_config(server_id, config).await
@ -173,10 +162,7 @@ impl Database {
} }
#[tracing::instrument] #[tracing::instrument]
pub async fn get_user_config_or_default( pub async fn get_user_config_or_default(&self, user_id: u64) -> Result<Option<UserConfig>> {
&self,
user_id: u64,
) -> Result<Option<UserConfig>> {
match self.get_user_config(user_id).await? { match self.get_user_config(user_id).await? {
Some(config) => Ok(Some(config)), Some(config) => Ok(Some(config)),
None => { None => {
@ -187,11 +173,7 @@ impl Database {
} }
/// Save TTS instance to database /// Save TTS instance to database
pub async fn save_tts_instance( pub async fn save_tts_instance(&self, guild_id: GuildId, instance: &TTSInstance) -> Result<()> {
&self,
guild_id: GuildId,
instance: &TTSInstance,
) -> Result<()> {
let key = Self::tts_instance_key(guild_id.get()); let key = Self::tts_instance_key(guild_id.get());
let list_key = Self::tts_instances_list_key(); let list_key = Self::tts_instances_list_key();
@ -199,19 +181,21 @@ impl Database {
self.set_config(&key, instance).await?; self.set_config(&key, instance).await?;
// Add guild_id to the list of active instances // Add guild_id to the list of active instances
let mut connection = self.pool.get().await let mut connection = self
.pool
.get()
.await
.map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?; .map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?;
connection.sadd::<_, _, ()>(&list_key, guild_id.get()).await?; connection
.sadd::<_, _, ()>(&list_key, guild_id.get())
.await?;
Ok(()) Ok(())
} }
/// Load TTS instance from database /// Load TTS instance from database
#[tracing::instrument] #[tracing::instrument]
pub async fn load_tts_instance( pub async fn load_tts_instance(&self, guild_id: GuildId) -> Result<Option<TTSInstance>> {
&self,
guild_id: GuildId,
) -> Result<Option<TTSInstance>> {
let key = Self::tts_instance_key(guild_id.get()); let key = Self::tts_instance_key(guild_id.get());
self.get_config(&key).await self.get_config(&key).await
} }
@ -222,12 +206,16 @@ impl Database {
let key = Self::tts_instance_key(guild_id.get()); let key = Self::tts_instance_key(guild_id.get());
let list_key = Self::tts_instances_list_key(); let list_key = Self::tts_instances_list_key();
let mut connection = self.pool.get().await let mut connection = self
.pool
.get()
.await
.map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?; .map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?;
let _: std::result::Result<(), bb8_redis::redis::RedisError> = connection.del(&key).await; let _: std::result::Result<(), bb8_redis::redis::RedisError> = connection.del(&key).await;
let _: std::result::Result<(), bb8_redis::redis::RedisError> = connection.srem(&list_key, guild_id.get()).await; let _: std::result::Result<(), bb8_redis::redis::RedisError> =
connection.srem(&list_key, guild_id.get()).await;
Ok(()) Ok(())
} }
@ -236,9 +224,12 @@ impl Database {
pub async fn get_all_tts_instances(&self) -> Result<Vec<(GuildId, TTSInstance)>> { pub async fn get_all_tts_instances(&self) -> Result<Vec<(GuildId, TTSInstance)>> {
let list_key = Self::tts_instances_list_key(); let list_key = Self::tts_instances_list_key();
let mut connection = self.pool.get().await let mut connection = self
.pool
.get()
.await
.map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?; .map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?;
let guild_ids: Vec<u64> = connection.smembers(&list_key).await.unwrap_or_default(); let guild_ids: Vec<u64> = connection.smembers(&list_key).await.unwrap_or_default();
let mut instances = Vec::new(); let mut instances = Vec::new();
@ -274,39 +265,34 @@ impl Database {
self.get_config(&key).await self.get_config(&key).await
} }
pub async fn delete_user_config( pub async fn delete_user_config(&self, guild_id: GuildId, user_id: UserId) -> Result<()> {
&self,
guild_id: GuildId,
user_id: UserId,
) -> Result<()> {
let key = Self::user_config_key(guild_id.get(), user_id.get()); let key = Self::user_config_key(guild_id.get(), user_id.get());
let mut connection = self.pool.get().await let mut connection = self
.pool
.get()
.await
.map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?; .map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?;
let _: std::result::Result<(), bb8_redis::redis::RedisError> = connection.del(&key).await; let _: std::result::Result<(), bb8_redis::redis::RedisError> = connection.del(&key).await;
Ok(()) Ok(())
} }
// Additional server config methods // Additional server config methods
pub async fn save_server_config( pub async fn save_server_config(&self, guild_id: GuildId, config: &ServerConfig) -> Result<()> {
&self,
guild_id: GuildId,
config: &ServerConfig,
) -> Result<()> {
let key = Self::server_config_key(guild_id.get()); let key = Self::server_config_key(guild_id.get());
self.set_config(&key, config).await self.set_config(&key, config).await
} }
pub async fn load_server_config( pub async fn load_server_config(&self, guild_id: GuildId) -> Result<Option<ServerConfig>> {
&self,
guild_id: GuildId,
) -> Result<Option<ServerConfig>> {
let key = Self::server_config_key(guild_id.get()); let key = Self::server_config_key(guild_id.get());
self.get_config(&key).await self.get_config(&key).await
} }
pub async fn delete_server_config(&self, guild_id: GuildId) -> Result<()> { pub async fn delete_server_config(&self, guild_id: GuildId) -> Result<()> {
let key = Self::server_config_key(guild_id.get()); let key = Self::server_config_key(guild_id.get());
let mut connection = self.pool.get().await let mut connection = self
.pool
.get()
.await
.map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?; .map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?;
let _: std::result::Result<(), bb8_redis::redis::RedisError> = connection.del(&key).await; let _: std::result::Result<(), bb8_redis::redis::RedisError> = connection.del(&key).await;
Ok(()) Ok(())
@ -322,10 +308,7 @@ impl Database {
self.set_config(&key, dictionary).await self.set_config(&key, dictionary).await
} }
pub async fn load_dictionary( pub async fn load_dictionary(&self, guild_id: GuildId) -> Result<HashMap<String, String>> {
&self,
guild_id: GuildId,
) -> Result<HashMap<String, String>> {
let key = Self::dictionary_key(guild_id.get()); let key = Self::dictionary_key(guild_id.get());
let dict: Option<HashMap<String, String>> = self.get_config(&key).await?; let dict: Option<HashMap<String, String>> = self.get_config(&key).await?;
Ok(dict.unwrap_or_default()) Ok(dict.unwrap_or_default())
@ -333,7 +316,10 @@ impl Database {
pub async fn delete_dictionary(&self, guild_id: GuildId) -> Result<()> { pub async fn delete_dictionary(&self, guild_id: GuildId) -> Result<()> {
let key = Self::dictionary_key(guild_id.get()); let key = Self::dictionary_key(guild_id.get());
let mut connection = self.pool.get().await let mut connection = self
.pool
.get()
.await
.map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?; .map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?;
let _: std::result::Result<(), bb8_redis::redis::RedisError> = connection.del(&key).await; let _: std::result::Result<(), bb8_redis::redis::RedisError> = connection.del(&key).await;
Ok(()) Ok(())
@ -345,7 +331,10 @@ impl Database {
pub async fn list_active_instances(&self) -> Result<Vec<u64>> { pub async fn list_active_instances(&self) -> Result<Vec<u64>> {
let list_key = Self::tts_instances_list_key(); let list_key = Self::tts_instances_list_key();
let mut connection = self.pool.get().await let mut connection = self
.pool
.get()
.await
.map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?; .map_err(|e| NCBError::Database(format!("Pool connection failed: {}", e)))?;
let guild_ids: Vec<u64> = connection.smembers(&list_key).await.unwrap_or_default(); let guild_ids: Vec<u64> = connection.smembers(&list_key).await.unwrap_or_default();
Ok(guild_ids) Ok(guild_ids)
@ -355,9 +344,9 @@ impl Database {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::errors::constants;
use bb8_redis::redis::AsyncCommands; use bb8_redis::redis::AsyncCommands;
use serial_test::serial; use serial_test::serial;
use crate::errors::constants;
// Helper function to create test database (requires Redis running) // Helper function to create test database (requires Redis running)
async fn create_test_database() -> Result<Database> { async fn create_test_database() -> Result<Database> {
@ -422,16 +411,19 @@ mod tests {
}; };
let guild_id = GuildId::new(12345); let guild_id = GuildId::new(12345);
let test_instance = TTSInstance::new( let test_instance =
ChannelId::new(123), TTSInstance::new_single(ChannelId::new(123), ChannelId::new(456), guild_id);
ChannelId::new(456),
guild_id
);
// Clear any existing data // Clear any existing data
if let Ok(mut conn) = db.pool.get().await { if let Ok(mut conn) = db.pool.get().await {
let _: () = conn.del(Database::tts_instance_key(guild_id.get())).await.unwrap_or_default(); let _: () = conn
let _: () = conn.srem(Database::tts_instances_list_key(), guild_id.get()).await.unwrap_or_default(); .del(Database::tts_instance_key(guild_id.get()))
.await
.unwrap_or_default();
let _: () = conn
.srem(Database::tts_instances_list_key(), guild_id.get())
.await
.unwrap_or_default();
} else { } else {
return; // Skip if can't get connection return; // Skip if can't get connection
} }
@ -452,7 +444,7 @@ mod tests {
let loaded_instance = load_result.unwrap(); let loaded_instance = load_result.unwrap();
if let Some(instance) = loaded_instance { if let Some(instance) = loaded_instance {
assert_eq!(instance.guild, test_instance.guild); assert_eq!(instance.guild, test_instance.guild);
assert_eq!(instance.text_channel, test_instance.text_channel); assert_eq!(instance.text_channels, test_instance.text_channels);
assert_eq!(instance.voice_channel, test_instance.voice_channel); assert_eq!(instance.voice_channel, test_instance.voice_channel);
} }
@ -485,4 +477,4 @@ mod tests {
assert!(constants::REDIS_MAX_CONNECTIONS > 0); assert!(constants::REDIS_MAX_CONNECTIONS > 0);
assert!(constants::REDIS_MIN_IDLE_CONNECTIONS <= constants::REDIS_MAX_CONNECTIONS); assert!(constants::REDIS_MIN_IDLE_CONNECTIONS <= constants::REDIS_MAX_CONNECTIONS);
} }
} }

View File

@ -10,6 +10,7 @@ pub struct DictionaryOnlyServerConfig {
pub struct ServerConfig { pub struct ServerConfig {
pub dictionary: Dictionary, pub dictionary: Dictionary,
pub autostart_channel_id: Option<u64>, pub autostart_channel_id: Option<u64>,
pub autostart_text_channel_id: Option<u64>,
pub voice_state_announce: Option<bool>, pub voice_state_announce: Option<bool>,
pub read_username: Option<bool>, pub read_username: Option<bool>,
} }

View File

@ -324,6 +324,8 @@ pub mod constants {
pub const CHANNEL_LEAVE_SUCCESS: &str = "CHANNEL_LEAVE_SUCCESS"; pub const CHANNEL_LEAVE_SUCCESS: &str = "CHANNEL_LEAVE_SUCCESS";
pub const AUTOSTART_CHANNEL_SET: &str = "AUTOSTART_CHANNEL_SET"; pub const AUTOSTART_CHANNEL_SET: &str = "AUTOSTART_CHANNEL_SET";
pub const SET_AUTOSTART_CHANNEL_CLEAR: &str = "SET_AUTOSTART_CHANNEL_CLEAR"; pub const SET_AUTOSTART_CHANNEL_CLEAR: &str = "SET_AUTOSTART_CHANNEL_CLEAR";
pub const SET_AUTOSTART_TEXT_CHANNEL: &str = "SET_AUTOSTART_TEXT_CHANNEL";
pub const SET_AUTOSTART_TEXT_CHANNEL_CLEAR: &str = "SET_AUTOSTART_TEXT_CHANNEL_CLEAR";
// TTS configuration constants // TTS configuration constants
pub const TTS_CONFIG_SERVER_ADD_DICTIONARY: &str = "TTS_CONFIG_SERVER_ADD_DICTIONARY"; pub const TTS_CONFIG_SERVER_ADD_DICTIONARY: &str = "TTS_CONFIG_SERVER_ADD_DICTIONARY";

View File

@ -55,55 +55,60 @@ impl EventHandler for Handler {
} }
let rows = modal.data.components.clone(); let rows = modal.data.components.clone();
// Extract rule name with proper error handling // Extract rule name with proper error handling
let rule_name = match rows.get(0) let rule_name =
.and_then(|row| row.components.get(0)) match rows
.and_then(|component| { .get(0)
if let ActionRowComponent::InputText(text) = component { .and_then(|row| row.components.get(0))
text.value.as_ref() .and_then(|component| {
} else { if let ActionRowComponent::InputText(text) = component {
None text.value.as_ref()
} else {
None
}
}) {
Some(name) => {
if let Err(e) = validation::validate_rule_name(name) {
tracing::error!("Invalid rule name: {}", e);
return;
}
name.clone()
} }
}) { None => {
Some(name) => { tracing::error!("Cannot extract rule name from modal");
if let Err(e) = validation::validate_rule_name(name) {
tracing::error!("Invalid rule name: {}", e);
return; return;
} }
name.clone() };
},
None => {
tracing::error!("Cannot extract rule name from modal");
return;
}
};
// Extract 'from' field with validation // Extract 'from' field with validation
let from = match rows.get(1) let from =
.and_then(|row| row.components.get(0)) match rows
.and_then(|component| { .get(1)
if let ActionRowComponent::InputText(text) = component { .and_then(|row| row.components.get(0))
text.value.as_ref() .and_then(|component| {
} else { if let ActionRowComponent::InputText(text) = component {
None text.value.as_ref()
} else {
None
}
}) {
Some(pattern) => {
if let Err(e) = validation::validate_regex_pattern(pattern) {
tracing::error!("Invalid regex pattern: {}", e);
return;
}
pattern.clone()
} }
}) { None => {
Some(pattern) => { tracing::error!("Cannot extract regex pattern from modal");
if let Err(e) = validation::validate_regex_pattern(pattern) {
tracing::error!("Invalid regex pattern: {}", e);
return; return;
} }
pattern.clone() };
},
None => {
tracing::error!("Cannot extract regex pattern from modal");
return;
}
};
// Extract 'to' field with validation // Extract 'to' field with validation
let to = match rows.get(2) let to = match rows
.get(2)
.and_then(|row| row.components.get(0)) .and_then(|row| row.components.get(0))
.and_then(|component| { .and_then(|component| {
if let ActionRowComponent::InputText(text) = component { if let ActionRowComponent::InputText(text) = component {
@ -118,7 +123,7 @@ impl EventHandler for Handler {
return; return;
} }
replacement.clone() replacement.clone()
}, }
None => { None => {
tracing::error!("Cannot extract replacement text from modal"); tracing::error!("Cannot extract replacement text from modal");
return; return;
@ -143,12 +148,15 @@ impl EventHandler for Handler {
} }
}; };
match database.get_server_config_or_default(modal.guild_id.unwrap().get()).await { match database
.get_server_config_or_default(modal.guild_id.unwrap().get())
.await
{
Ok(Some(config)) => config, Ok(Some(config)) => config,
Ok(None) => { Ok(None) => {
tracing::error!("No server config found"); tracing::error!("No server config found");
return; return;
}, }
Err(e) => { Err(e) => {
tracing::error!("Database error: {}", e); tracing::error!("Database error: {}", e);
return; return;
@ -166,7 +174,10 @@ impl EventHandler for Handler {
} }
}; };
if let Err(e) = database.set_server_config(modal.guild_id.unwrap().get(), config).await { if let Err(e) = database
.set_server_config(modal.guild_id.unwrap().get(), config)
.await
{
tracing::error!("Failed to save server config: {}", e); tracing::error!("Failed to save server config: {}", e);
return; return;
} }
@ -502,8 +513,64 @@ impl EventHandler for Handler {
.create_response( .create_response(
&ctx.http, &ctx.http,
CreateInteractionResponse::UpdateMessage( CreateInteractionResponse::UpdateMessage(
CreateInteractionResponseMessage::new() CreateInteractionResponseMessage::new().content(response_content),
.content(response_content), ),
)
.await
.unwrap();
}
id if id == SET_AUTOSTART_TEXT_CHANNEL => {
let autostart_text_channel_id = match message_component.data.kind {
ComponentInteractionDataKind::StringSelect { ref values, .. } => {
if values.len() == 0 {
None
} else if values[0] == "SET_AUTOSTART_TEXT_CHANNEL_CLEAR" {
None
} else {
Some(
u64::from_str_radix(
&values[0]
.strip_prefix("SET_AUTOSTART_TEXT_CHANNEL_")
.unwrap(),
10,
)
.unwrap(),
)
}
}
_ => panic!("Cannot get index"),
};
{
let data_read = ctx.data.read().await;
let database = data_read
.get::<DatabaseClientData>()
.expect("Cannot get DatabaseClientData")
.clone();
let mut config = database
.get_server_config_or_default(message_component.guild_id.unwrap().get())
.await
.unwrap()
.unwrap();
config.autostart_text_channel_id = autostart_text_channel_id;
database
.set_server_config(message_component.guild_id.unwrap().get(), config)
.await
.unwrap();
}
let response_content = if autostart_text_channel_id.is_some() {
"自動参加テキストチャンネルを設定しました。"
} else {
"自動参加テキストチャンネルを解除しました。"
};
message_component
.create_response(
&ctx.http,
CreateInteractionResponse::UpdateMessage(
CreateInteractionResponseMessage::new().content(response_content),
), ),
) )
.await .await
@ -534,17 +601,15 @@ impl EventHandler for Handler {
.unwrap(); .unwrap();
let mut options = Vec::new(); let mut options = Vec::new();
// 解除オプションを追加 // 解除オプションを追加
let clear_option = CreateSelectMenuOption::new( let clear_option =
"解除", CreateSelectMenuOption::new("解除", "SET_AUTOSTART_CHANNEL_CLEAR")
"SET_AUTOSTART_CHANNEL_CLEAR", .description("自動参加チャンネルを解除します")
) .default_selection(autostart_channel_id == 0);
.description("自動参加チャンネルを解除します")
.default_selection(autostart_channel_id == 0);
options.push(clear_option); options.push(clear_option);
for (id, channel) in channels { for (id, channel) in channels.clone() {
if channel.kind != ChannelType::Voice { if channel.kind != ChannelType::Voice {
continue; continue;
} }
@ -562,6 +627,33 @@ impl EventHandler for Handler {
options.push(option); options.push(option);
} }
let mut text_channel_options = Vec::new();
let clear_option =
CreateSelectMenuOption::new("解除", "SET_AUTOSTART_TEXT_CHANNEL_CLEAR")
.description("自動参加テキストチャンネルを解除します")
.default_selection(config.autostart_text_channel_id.is_none());
text_channel_options.push(clear_option);
for (id, channel) in channels {
if channel.kind != ChannelType::Text {
continue;
}
let description = channel
.topic
.unwrap_or_else(|| String::from("No topic provided."));
let option = CreateSelectMenuOption::new(
&channel.name,
format!("SET_AUTOSTART_TEXT_CHANNEL_{}", id.get()),
)
.description(description)
.default_selection(
channel.id.get() == config.autostart_text_channel_id.unwrap_or(0),
);
text_channel_options.push(option);
}
message_component message_component
.create_response( .create_response(
&ctx.http, &ctx.http,
@ -577,6 +669,16 @@ impl EventHandler for Handler {
.min_values(0) .min_values(0)
.max_values(1), .max_values(1),
), ),
CreateActionRow::SelectMenu(
CreateSelectMenu::new(
"SET_AUTOSTART_TEXT_CHANNEL",
CreateSelectMenuKind::String {
options: text_channel_options,
},
)
.min_values(0)
.max_values(1),
),
CreateActionRow::Buttons(vec![CreateButton::new( CreateActionRow::Buttons(vec![CreateButton::new(
"TTS_CONFIG_SERVER_BACK", "TTS_CONFIG_SERVER_BACK",
) )

View File

@ -31,7 +31,7 @@ pub async fn message(ctx: Context, message: Message) {
let instance = storage.get_mut(&guild_id).unwrap(); let instance = storage.get_mut(&guild_id).unwrap();
if instance.text_channel != message.channel_id { if !instance.contains_text_channel(message.channel_id) {
return; return;
} }

View File

@ -62,7 +62,14 @@ pub async fn voice_state_update(ctx: Context, old: Option<VoiceState>, new: Voic
.expect("Cannot get songbird client.") .expect("Cannot get songbird client.")
.clone(); .clone();
let instance = TTSInstance::new(new_channel, new_channel, guild_id); let text_channel_ids =
if let Some(text_channel_id) = config.autostart_text_channel_id {
vec![text_channel_id.into(), new_channel]
} else {
vec![new_channel]
};
let instance = TTSInstance::new(text_channel_ids, new_channel, guild_id);
storage.insert(guild_id, instance.clone()); storage.insert(guild_id, instance.clone());
// Save to database // Save to database
@ -82,7 +89,10 @@ pub async fn voice_state_update(ctx: Context, old: Option<VoiceState>, new: Voic
let tts_client = data let tts_client = data
.get::<TTSClientData>() .get::<TTSClientData>()
.expect("Cannot get TTSClientData"); .expect("Cannot get TTSClientData");
let voicevox_speakers = tts_client.voicevox_client.get_speakers().await let voicevox_speakers = tts_client
.voicevox_client
.get_speakers()
.await
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
tracing::error!("Failed to get VOICEVOX speakers: {}", e); tracing::error!("Failed to get VOICEVOX speakers: {}", e);
vec!["VOICEVOX API unavailable".to_string()] vec!["VOICEVOX API unavailable".to_string()]
@ -142,12 +152,15 @@ pub async fn voice_state_update(ctx: Context, old: Option<VoiceState>, new: Voic
} }
if del_flag { if del_flag {
let _ = storage // Archive thread if it exists
.get(&guild_id) if let Some(&channel_id) = storage.get(&guild_id).unwrap().text_channels.first() {
.unwrap() let http = ctx.http.clone();
.text_channel tokio::spawn(async move {
.edit_thread(&ctx.http, EditThread::new().archived(true)) let _ = channel_id
.await; .edit_thread(&http, EditThread::new().archived(true))
.await;
});
}
storage.remove(&guild_id); storage.remove(&guild_id);
// Remove from database // Remove from database

View File

@ -15,22 +15,54 @@ use crate::tts::message::TTSMessage;
pub struct TTSInstance { pub struct TTSInstance {
#[serde(skip)] // Messageは複雑すぎるのでシリアライズしない #[serde(skip)] // Messageは複雑すぎるのでシリアライズしない
pub before_message: Option<Message>, pub before_message: Option<Message>,
pub text_channel: ChannelId, pub text_channels: Vec<ChannelId>,
pub voice_channel: ChannelId, pub voice_channel: ChannelId,
pub guild: GuildId, pub guild: GuildId,
} }
impl TTSInstance { impl TTSInstance {
/// Create a new TTSInstance /// Create a new TTSInstance
pub fn new(text_channel: ChannelId, voice_channel: ChannelId, guild: GuildId) -> Self { pub fn new(text_channels: Vec<ChannelId>, voice_channel: ChannelId, guild: GuildId) -> Self {
Self { Self {
before_message: None, before_message: None,
text_channel, text_channels,
voice_channel, voice_channel,
guild, guild,
} }
} }
/// Create a new TTSInstance with a single text channel
pub fn new_single(text_channel: ChannelId, voice_channel: ChannelId, guild: GuildId) -> Self {
Self::new(vec![text_channel], voice_channel, guild)
}
/// Add a text channel to the instance
pub fn add_text_channel(&mut self, channel_id: ChannelId) {
if !self.text_channels.contains(&channel_id) {
self.text_channels.push(channel_id);
}
}
/// Remove a text channel from the instance
pub fn remove_text_channel(&mut self, channel_id: ChannelId) -> bool {
if let Some(pos) = self.text_channels.iter().position(|&x| x == channel_id) {
self.text_channels.remove(pos);
true
} else {
false
}
}
/// Check if a channel is in the text channels list
pub fn contains_text_channel(&self, channel_id: ChannelId) -> bool {
self.text_channels.contains(&channel_id)
}
/// Get all text channels
pub fn get_text_channels(&self) -> &Vec<ChannelId> {
&self.text_channels
}
pub async fn check_connection(&self, ctx: &Context) -> bool { pub async fn check_connection(&self, ctx: &Context) -> bool {
let manager = match songbird::get(ctx).await { let manager = match songbird::get(ctx).await {
Some(manager) => manager, Some(manager) => manager,