diff --git a/src/internal/api/voicevox/client.go b/src/internal/api/voicevox/client.go index 5328eb3..d31c947 100644 --- a/src/internal/api/voicevox/client.go +++ b/src/internal/api/voicevox/client.go @@ -107,3 +107,38 @@ func (c *Client) Synthesize( return io.ReadAll(res2.Body) } + +// GetSpeakers はVOICEVOXの話者一覧を取得します +func (c *Client) GetSpeakers(ctx context.Context) ([]Speaker, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + c.BaseURL+"/speakers", + nil, + ) + if err != nil { + return nil, err + } + + if c.APIKey != "" { + req.Header.Set("Authorization", "ApiKey "+c.APIKey) + } + + res, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + b, _ := io.ReadAll(res.Body) + return nil, fmt.Errorf("speakers failed: %s", string(b)) + } + + var speakers []Speaker + if err := json.NewDecoder(res.Body).Decode(&speakers); err != nil { + return nil, err + } + + return speakers, nil +} diff --git a/src/internal/api/voicevox/types.go b/src/internal/api/voicevox/types.go index 3418d23..8e71355 100644 --- a/src/internal/api/voicevox/types.go +++ b/src/internal/api/voicevox/types.go @@ -1,3 +1,16 @@ package voicevox -// 将来 audio_query の struct 化したくなったらここに書く +// Speaker は /speakers のレスポンスを表します +type Speaker struct { + Name string `json:"name"` + Styles []SpeakerStyle `json:"styles"` + SpeakerUUID string `json:"speaker_uuid"` + Version string `json:"version"` +} + +// SpeakerStyle は話者のスタイル情報です +type SpeakerStyle struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} diff --git a/src/internal/bot/command/general/help/help-commands.go b/src/internal/bot/command/general/help/help-commands.go index e019f79..439ad44 100644 --- a/src/internal/bot/command/general/help/help-commands.go +++ b/src/internal/bot/command/general/help/help-commands.go @@ -29,7 +29,7 @@ var HelpCommands = []HelpCommand{ }, { Name: "/tts ", - Description: "読み上げにまつわるコマンドです。\nサブコマンド一覧:\n・`join` : ボイスチャンネルに参加します。\n・`leave`: ボイスチャンネルから退出します。\n・`skip` : 現在再生中の音声をスキップします。\n・`speed` : 再生速度を設定します。\n・`dict` : 読み上げ辞書の管理を行います。", + Description: "読み上げにまつわるコマンドです。\nサブコマンド一覧:\n・`join` : ボイスチャンネルに参加します。\n・`leave`: ボイスチャンネルから退出します。\n・`skip` : 現在再生中の音声をスキップします。\n・`speed` : 再生速度を設定します。\n・`dict` : 読み上げ辞書の管理を行います。\n・`set` : 読み上げの設定を変更します。", Usage: "基本はVC接続中に使用を推奨します。", }, { @@ -47,4 +47,9 @@ var HelpCommands = []HelpCommand{ Description: "TTSの再生速度を設定します。", Usage: "VC接続中に`/tts speed 120`のように指定します。", }, + { + Name: "/tts set voice", + Description: "TTSの話者を選択します。", + Usage: "VC接続中に`/tts set voice`で表示される選択肢から話者を選びます。", + }, } diff --git a/src/internal/bot/command/general/tts.go b/src/internal/bot/command/general/tts.go index 30d7517..ae5e420 100644 --- a/src/internal/bot/command/general/tts.go +++ b/src/internal/bot/command/general/tts.go @@ -17,6 +17,7 @@ func LoadTtsCommandContext() *discordgo.ApplicationCommand { tts.LoadLeaveCommandContext(), tts.LoadSkipCommandContext(), tts.LoadDictCommandContext(), + tts.LoadSetCommandContext(), tts.LoadSpeedCommandContext(), }, } @@ -27,6 +28,7 @@ var ttsHandler = map[string]func(ctx *internal.BotContext, s *discordgo.Session, "leave": tts.Leave, "skip": tts.Skip, "dict": tts.Dict, + "set": tts.Set, "speed": tts.Speed, } diff --git a/src/internal/bot/command/general/tts/set.go b/src/internal/bot/command/general/tts/set.go new file mode 100644 index 0000000..271f4b1 --- /dev/null +++ b/src/internal/bot/command/general/tts/set.go @@ -0,0 +1,32 @@ +package tts + +import ( + "unibot/internal" + "unibot/internal/bot/command/general/tts/set" + + "github.com/bwmarrin/discordgo" +) + +func LoadSetCommandContext() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommandGroup, + Name: "set", + Description: "TTSの設定を変更します", + Options: []*discordgo.ApplicationCommandOption{ + set.LoadVoiceCommandContext(), + }, + } +} + +var setHandler = map[string]func(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate){ + "voice": set.Voice, +} + +func Set(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + subCommandGroup := i.ApplicationCommandData().Options[0] + subCommand := subCommandGroup.Options[0] + + if handler, exists := setHandler[subCommand.Name]; exists { + handler(ctx, s, i) + } +} diff --git a/src/internal/bot/command/general/tts/set/voice.go b/src/internal/bot/command/general/tts/set/voice.go new file mode 100644 index 0000000..455809d --- /dev/null +++ b/src/internal/bot/command/general/tts/set/voice.go @@ -0,0 +1,126 @@ +package set + +import ( + "log" + "time" + "unibot/internal" + "unibot/internal/bot/ttsutil" + + "github.com/bwmarrin/discordgo" +) + +func LoadVoiceCommandContext() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "voice", + Description: "読み上げの話者を設定します", + } +} + +func Voice(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + config := ctx.Config + memberID, requesterName, requesterAvatar := ttsutil.GetInteractionUser(i) + if requesterName == "" { + log.Println("Voice: missing user information on interaction") + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "ユーザー情報の取得に失敗しました。", + Color: config.Colors.Error, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + if err != nil { + log.Println("Failed to edit deferred interaction:", err) + } + return + } + + done := make(chan struct{}) + go func() { + select { + case <-done: + return + case <-time.After(3 * time.Minute): + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "話者情報の取得に失敗しました。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + requesterName, + IconURL: requesterAvatar, + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + }) + if err != nil { + log.Println("Failed to edit deferred interaction on timeout:", err) + } + } + }() + defer close(done) + + speakers, err := ttsutil.FetchSpeakers(ctx) + if err != nil { + log.Println("Failed to fetch speakers:", err) + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "話者情報の取得に失敗しました。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + requesterName, + IconURL: requesterAvatar, + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + if err != nil { + log.Println("Failed to edit deferred interaction:", err) + } + return + } + + pages := ttsutil.BuildSpeakerPages(speakers, ttsutil.SpeakerPageSize) + if len(pages) == 0 { + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "話者情報が取得できませんでした。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + requesterName, + IconURL: requesterAvatar, + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + if err != nil { + log.Println("Failed to edit deferred interaction:", err) + } + return + } + + currentSpeakerID := ttsutil.GetCurrentSpeakerID(ctx, memberID) + content, components := ttsutil.BuildVoiceMessage(0, pages, currentSpeakerID) + + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &content, + Components: &components, + }) + if err != nil { + log.Println("Failed to edit deferred interaction:", err) + } +} diff --git a/src/internal/bot/handler/interaction.go b/src/internal/bot/handler/interaction.go index 3274b1f..44d0b99 100644 --- a/src/internal/bot/handler/interaction.go +++ b/src/internal/bot/handler/interaction.go @@ -29,6 +29,10 @@ func handleApplicationCommand(ctx *internal.BotContext, s *discordgo.Session, i response.Data = &discordgo.InteractionResponseData{ Flags: discordgo.MessageFlagsEphemeral, } + } else if isTtsSetVoice(i) { + response.Data = &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + } } s.InteractionRespond(i.Interaction, response) if entry, ok := command.Handlers[name]; ok { @@ -36,6 +40,25 @@ func handleApplicationCommand(ctx *internal.BotContext, s *discordgo.Session, i } } +func isTtsSetVoice(i *discordgo.InteractionCreate) bool { + if i.ApplicationCommandData().Name != "tts" { + return false + } + options := i.ApplicationCommandData().Options + if len(options) == 0 { + return false + } + group := options[0] + if group.Type != discordgo.ApplicationCommandOptionSubCommandGroup || group.Name != "set" { + return false + } + if len(group.Options) == 0 { + return false + } + sub := group.Options[0] + return sub.Type == discordgo.ApplicationCommandOptionSubCommand && sub.Name == "voice" +} + func handleMessageComponent(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { customID := i.MessageComponentData().CustomID diff --git a/src/internal/bot/messageComponent/tts_voice.go b/src/internal/bot/messageComponent/tts_voice.go new file mode 100644 index 0000000..dda2f10 --- /dev/null +++ b/src/internal/bot/messageComponent/tts_voice.go @@ -0,0 +1,253 @@ +package messageComponent + +import ( + "log" + "strconv" + "strings" + "time" + "unibot/internal" + "unibot/internal/bot/ttsutil" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" +) + +func init() { + RegisterHandler(ttsutil.VoiceSelectCustomID, HandleTTSSetVoice) + RegisterHandler(ttsutil.VoicePageCustomIDPrefix, HandleTTSSetVoicePage) +} + +// HandleTTSSetVoice は話者選択のセレクトメニューを処理します +func HandleTTSSetVoice(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + config := ctx.Config + values := i.MessageComponentData().Values + if len(values) == 0 { + return + } + + speakerID := values[0] + memberID, _, _ := ttsutil.GetInteractionUser(i) + if memberID == "" { + log.Println("HandleTTSSetVoice: missing user information on interaction") + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "ユーザー情報の取得に失敗しました。", + Color: config.Colors.Error, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } + return + } + + memberRepo := repository.NewMemberRepository(ctx.DB) + if err := memberRepo.Create(memberID); err != nil { + log.Println("Error creating member:", err) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "メンバー情報の作成に失敗しました。", + Color: config.Colors.Error, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } + return + } + + repo := repository.NewTTSPersonalSettingRepository(ctx.DB) + setting, err := repo.GetByMember(memberID) + if err != nil { + log.Println("Error fetching TTS personal setting:", err) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "TTS個人設定の取得に失敗しました。", + Color: config.Colors.Error, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } + return + } + + if setting == nil { + defaultSetting := repository.DefaultTTSPersonalSetting + setting = &defaultSetting + setting.MemberID = memberID + setting.SpeakerID = speakerID + if err := repo.Create(setting); err != nil { + log.Println("Error creating TTS personal setting:", err) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "TTS個人設定の作成に失敗しました。", + Color: config.Colors.Error, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } + return + } + } else { + setting.SpeakerID = speakerID + if err := repo.Update(setting); err != nil { + log.Println("Error updating TTS personal setting:", err) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "TTS個人設定の更新に失敗しました。", + Color: config.Colors.Error, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } + return + } + } + + label := ttsutil.ResolveSpeakerLabel(ctx, speakerID) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "話者設定を更新しました", + Description: "選択した話者: " + label, + Color: config.Colors.Success, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Components: []discordgo.MessageComponent{}, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } +} + +// HandleTTSSetVoicePage は話者選択のページ送りを処理します +func HandleTTSSetVoicePage(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + customID := i.MessageComponentData().CustomID + parts := strings.SplitN(customID, ":", 2) + if len(parts) != 2 { + return + } + + pageIndex, err := strconv.Atoi(parts[1]) + if err != nil { + return + } + + speakers, err := ttsutil.FetchSpeakers(ctx) + if err != nil { + log.Println("Failed to fetch speakers:", err) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "話者情報の取得に失敗しました。", + Color: ctx.Config.Colors.Error, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } + return + } + + pages := ttsutil.BuildSpeakerPages(speakers, ttsutil.SpeakerPageSize) + if len(pages) == 0 { + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "話者情報が取得できませんでした。", + Color: ctx.Config.Colors.Error, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } + return + } + + memberID, _, _ := ttsutil.GetInteractionUser(i) + if memberID == "" { + log.Println("HandleTTSSetVoicePage: missing user information on interaction") + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "ユーザー情報の取得に失敗しました。", + Color: ctx.Config.Colors.Error, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } + return + } + currentSpeakerID := ttsutil.GetCurrentSpeakerID(ctx, memberID) + content, components := ttsutil.BuildVoiceMessage(pageIndex, pages, currentSpeakerID) + + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: content, + Components: components, + }, + }); err != nil { + log.Println("Failed to respond interaction:", err) + } +} diff --git a/src/internal/bot/ttsutil/voice_picker.go b/src/internal/bot/ttsutil/voice_picker.go new file mode 100644 index 0000000..a9a901e --- /dev/null +++ b/src/internal/bot/ttsutil/voice_picker.go @@ -0,0 +1,203 @@ +package ttsutil + +import ( + "context" + "fmt" + "sync" + "time" + "unibot/internal" + "unibot/internal/api/voicevox" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" +) + +const ( + SpeakerPageSize = 20 + VoiceSelectCustomID = "tts_set_voice_select" + VoicePageCustomIDPrefix = "tts_set_voice_page" + speakerSelectMax = 25 +) + +type SpeakerPage struct { + Options []discordgo.SelectMenuOption +} + +type speakerCache struct { + mu sync.RWMutex + speakers []voicevox.Speaker + expires time.Time +} + +var cachedSpeakers speakerCache + +func FetchSpeakers(ctx *internal.BotContext) ([]voicevox.Speaker, error) { + cachedSpeakers.mu.RLock() + if time.Now().Before(cachedSpeakers.expires) && len(cachedSpeakers.speakers) > 0 { + speakers := cachedSpeakers.speakers + cachedSpeakers.mu.RUnlock() + return speakers, nil + } + cachedSpeakers.mu.RUnlock() + + requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + speakers, err := ctx.VoiceVox.GetSpeakers(requestCtx) + if err != nil { + return nil, err + } + + cachedSpeakers.mu.Lock() + cachedSpeakers.speakers = speakers + cachedSpeakers.expires = time.Now().Add(5 * time.Minute) + cachedSpeakers.mu.Unlock() + + return speakers, nil +} + +func BuildSpeakerPages(speakers []voicevox.Speaker, perPage int) []SpeakerPage { + if perPage <= 0 { + perPage = SpeakerPageSize + } + if perPage > speakerSelectMax { + perPage = speakerSelectMax + } + + pages := make([]SpeakerPage, 0) + current := SpeakerPage{Options: make([]discordgo.SelectMenuOption, 0, perPage)} + flush := func() { + if len(current.Options) > 0 { + pages = append(pages, current) + current = SpeakerPage{Options: make([]discordgo.SelectMenuOption, 0, perPage)} + } + } + + for _, speaker := range speakers { + speakerOptions := make([]discordgo.SelectMenuOption, 0, len(speaker.Styles)) + for _, style := range speaker.Styles { + label := fmt.Sprintf("%s / %s", speaker.Name, style.Name) + speakerOptions = append(speakerOptions, discordgo.SelectMenuOption{ + Label: label, + Value: fmt.Sprintf("%d", style.ID), + Description: fmt.Sprintf("ID: %d", style.ID), + }) + } + + if len(speakerOptions) == 0 { + continue + } + + if len(speakerOptions) > speakerSelectMax { + flush() + for start := 0; start < len(speakerOptions); start += speakerSelectMax { + end := start + speakerSelectMax + if end > len(speakerOptions) { + end = len(speakerOptions) + } + pages = append(pages, SpeakerPage{Options: speakerOptions[start:end]}) + } + continue + } + + if len(speakerOptions) > perPage { + flush() + pages = append(pages, SpeakerPage{Options: speakerOptions}) + continue + } + + if len(current.Options)+len(speakerOptions) > perPage { + flush() + } + current.Options = append(current.Options, speakerOptions...) + } + + flush() + return pages +} + +func BuildVoiceMessage(pageIndex int, pages []SpeakerPage, currentSpeakerID string) (string, []discordgo.MessageComponent) { + maxPage := len(pages) + if maxPage == 0 { + return "話者情報が取得できませんでした。", []discordgo.MessageComponent{} + } + if pageIndex < 0 { + pageIndex = 0 + } + if pageIndex >= maxPage { + pageIndex = maxPage - 1 + } + + content := fmt.Sprintf("話者を選択してください。\n現在の話者ID: %s\nページ %d/%d", currentSpeakerID, pageIndex+1, maxPage) + + components := []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: VoiceSelectCustomID, + Placeholder: "話者を選択してください", + Options: pages[pageIndex].Options, + }, + }, + }, + } + + if maxPage > 1 { + prevID := fmt.Sprintf("%s:%d", VoicePageCustomIDPrefix, pageIndex-1) + nextID := fmt.Sprintf("%s:%d", VoicePageCustomIDPrefix, pageIndex+1) + components = append(components, discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + CustomID: prevID, + Label: "前へ", + Style: discordgo.SecondaryButton, + Disabled: pageIndex == 0, + }, + discordgo.Button{ + CustomID: nextID, + Label: "次へ", + Style: discordgo.SecondaryButton, + Disabled: pageIndex >= maxPage-1, + }, + }, + }) + } + + return content, components +} + +func GetCurrentSpeakerID(ctx *internal.BotContext, memberID string) string { + repo := repository.NewTTSPersonalSettingRepository(ctx.DB) + setting, err := repo.GetByMember(memberID) + if err != nil || setting == nil { + return repository.DefaultTTSPersonalSetting.SpeakerID + } + return setting.SpeakerID +} + +func ResolveSpeakerLabel(ctx *internal.BotContext, speakerID string) string { + speakers, err := FetchSpeakers(ctx) + if err != nil { + return "ID: " + speakerID + } + + for _, speaker := range speakers { + for _, style := range speaker.Styles { + if fmt.Sprintf("%d", style.ID) == speakerID { + return fmt.Sprintf("%s / %s", speaker.Name, style.Name) + } + } + } + + return "ID: " + speakerID +} + +func GetInteractionUser(i *discordgo.InteractionCreate) (string, string, string) { + if i.Member != nil && i.Member.User != nil { + return i.Member.User.ID, i.Member.DisplayName(), i.Member.AvatarURL("") + } + if i.User != nil { + return i.User.ID, i.User.Username, i.User.AvatarURL("") + } + return "", "", "" +}