From 7cc7e6c7d0f4dfa8bfd2fd08f7b80c9c0a83ac78 Mon Sep 17 00:00:00 2001 From: Kemari Date: Wed, 4 Feb 2026 21:21:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[v9]=20=E8=A9=B1=E8=80=85=E9=81=B8=E6=8A=9E?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=20Fixes=20#211?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/internal/api/voicevox/client.go | 35 ++ src/internal/api/voicevox/types.go | 15 +- .../bot/command/general/help/help-commands.go | 7 +- src/internal/bot/command/general/tts.go | 2 + src/internal/bot/command/general/tts/set.go | 32 ++ .../bot/command/general/tts/set/voice.go | 202 +++++++++++ src/internal/bot/handler/interaction.go | 23 ++ .../bot/messageComponent/tts_voice.go | 316 ++++++++++++++++++ 8 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 src/internal/bot/command/general/tts/set.go create mode 100644 src/internal/bot/command/general/tts/set/voice.go create mode 100644 src/internal/bot/messageComponent/tts_voice.go diff --git a/src/internal/api/voicevox/client.go b/src/internal/api/voicevox/client.go index 5328eb3f..d31c9479 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 3418d234..8e71355a 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 e019f79f..439ad443 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 30d75175..ae5e4204 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 00000000..271f4b1a --- /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 00000000..75ba81f9 --- /dev/null +++ b/src/internal/bot/command/general/tts/set/voice.go @@ -0,0 +1,202 @@ +package set + +import ( + "context" + "fmt" + "log" + "time" + "unibot/internal" + "unibot/internal/api/voicevox" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" +) + +const speakerPageSize = 20 + +type speakerPage struct { + Options []discordgo.SelectMenuOption +} + +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 + + 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 " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + }) + if err != nil { + log.Println("Failed to edit deferred interaction on timeout:", err) + } + } + }() + defer close(done) + + speakers, err := fetchSpeakers(ctx) + if err != nil { + log.Println("Failed to fetch speakers:", err) + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "話者情報の取得に失敗しました。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + pages := buildSpeakerPages(speakers, speakerPageSize) + if len(pages) == 0 { + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "話者情報が取得できませんでした。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + currentSpeakerID := getCurrentSpeakerID(ctx, i.Member.User.ID) + content, components := buildVoiceMessage(0, pages, currentSpeakerID) + + s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &content, + Components: &components, + }) +} + +func fetchSpeakers(ctx *internal.BotContext) ([]voicevox.Speaker, error) { + requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + return ctx.VoiceVox.GetSpeakers(requestCtx) +} + +func buildSpeakerPages(speakers []voicevox.Speaker, perPage int) []speakerPage { + pages := make([]speakerPage, 0) + current := speakerPage{Options: []discordgo.SelectMenuOption{}} + + 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(current.Options) > 0 && len(current.Options)+len(speakerOptions) > perPage { + pages = append(pages, current) + current = speakerPage{Options: []discordgo.SelectMenuOption{}} + } + current.Options = append(current.Options, speakerOptions...) + } + + if len(current.Options) > 0 { + pages = append(pages, current) + } + + 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: "tts_set_voice_select", + Placeholder: "話者を選択してください", + Options: pages[pageIndex].Options, + }, + }, + }, + } + + if maxPage > 1 { + prevID := fmt.Sprintf("tts_set_voice_page:%d", pageIndex-1) + nextID := fmt.Sprintf("tts_set_voice_page:%d", 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 +} diff --git a/src/internal/bot/handler/interaction.go b/src/internal/bot/handler/interaction.go index 3274b1f2..44d0b991 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 00000000..8570d051 --- /dev/null +++ b/src/internal/bot/messageComponent/tts_voice.go @@ -0,0 +1,316 @@ +package messageComponent + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + "time" + "unibot/internal" + "unibot/internal/api/voicevox" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" +) + +const speakerPageSize = 20 + +type speakerPage struct { + Options []discordgo.SelectMenuOption +} + +func init() { + RegisterHandler("tts_set_voice_select", HandleTTSSetVoice) + RegisterHandler("tts_set_voice_page", 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 := i.Member.User.ID + + memberRepo := repository.NewMemberRepository(ctx.DB) + if err := memberRepo.Create(memberID); err != nil { + log.Println("Error creating member:", 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, + }, + }) + return + } + + repo := repository.NewTTSPersonalSettingRepository(ctx.DB) + setting, err := repo.GetByMember(memberID) + if err != nil { + log.Println("Error fetching TTS personal setting:", 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, + }, + }) + 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) + 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, + }, + }) + return + } + } else { + setting.SpeakerID = speakerID + if err := repo.Update(setting); err != nil { + log.Println("Error updating TTS personal setting:", 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, + }, + }) + return + } + } + + label := resolveSpeakerLabel(ctx, speakerID) + 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{}, + }, + }) +} + +// HandleTTSSetVoicePage は話者選択のページ送りを処理します +func HandleTTSSetVoicePage(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + customID := i.MessageComponentData().CustomID + parts := strings.Split(customID, ":") + if len(parts) != 2 { + return + } + + pageIndex, err := strconv.Atoi(parts[1]) + if err != nil { + return + } + + speakers, err := fetchSpeakers(ctx) + if err != nil { + log.Println("Failed to fetch speakers:", 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, + }, + }) + return + } + + pages := buildSpeakerPages(speakers, speakerPageSize) + if len(pages) == 0 { + 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, + }, + }) + return + } + + currentSpeakerID := getCurrentSpeakerID(ctx, i.Member.User.ID) + content, components := buildVoiceMessage(pageIndex, pages, currentSpeakerID) + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: content, + Components: components, + }, + }) +} + +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 fetchSpeakers(ctx *internal.BotContext) ([]voicevox.Speaker, error) { + requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + return ctx.VoiceVox.GetSpeakers(requestCtx) +} + +func buildSpeakerPages(speakers []voicevox.Speaker, perPage int) []speakerPage { + pages := make([]speakerPage, 0) + current := speakerPage{Options: []discordgo.SelectMenuOption{}} + + 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(current.Options) > 0 && len(current.Options)+len(speakerOptions) > perPage { + pages = append(pages, current) + current = speakerPage{Options: []discordgo.SelectMenuOption{}} + } + current.Options = append(current.Options, speakerOptions...) + } + + if len(current.Options) > 0 { + pages = append(pages, current) + } + + 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: "tts_set_voice_select", + Placeholder: "話者を選択してください", + Options: pages[pageIndex].Options, + }, + }, + }, + } + + if maxPage > 1 { + prevID := fmt.Sprintf("tts_set_voice_page:%d", pageIndex-1) + nextID := fmt.Sprintf("tts_set_voice_page:%d", 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 +} From f279efc441832e8d8508d8346c322d3c64ccd679 Mon Sep 17 00:00:00 2001 From: Kemari Date: Wed, 4 Feb 2026 22:00:13 +0900 Subject: [PATCH 2/2] fix --- .../bot/command/general/tts/set/voice.go | 158 ++++-------- .../bot/messageComponent/tts_voice.go | 227 +++++++----------- src/internal/bot/ttsutil/voice_picker.go | 203 ++++++++++++++++ 3 files changed, 326 insertions(+), 262 deletions(-) create mode 100644 src/internal/bot/ttsutil/voice_picker.go diff --git a/src/internal/bot/command/general/tts/set/voice.go b/src/internal/bot/command/general/tts/set/voice.go index 75ba81f9..455809d8 100644 --- a/src/internal/bot/command/general/tts/set/voice.go +++ b/src/internal/bot/command/general/tts/set/voice.go @@ -1,23 +1,14 @@ package set import ( - "context" - "fmt" "log" "time" "unibot/internal" - "unibot/internal/api/voicevox" - "unibot/internal/repository" + "unibot/internal/bot/ttsutil" "github.com/bwmarrin/discordgo" ) -const speakerPageSize = 20 - -type speakerPage struct { - Options []discordgo.SelectMenuOption -} - func LoadVoiceCommandContext() *discordgo.ApplicationCommandOption { return &discordgo.ApplicationCommandOption{ Type: discordgo.ApplicationCommandOptionSubCommand, @@ -28,6 +19,25 @@ func LoadVoiceCommandContext() *discordgo.ApplicationCommandOption { 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() { @@ -42,8 +52,8 @@ func Voice(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.Interact Description: "話者情報の取得に失敗しました。", Color: config.Colors.Error, Footer: &discordgo.MessageEmbedFooter{ - Text: "Requested by " + i.Member.DisplayName(), - IconURL: i.Member.AvatarURL(""), + Text: "Requested by " + requesterName, + IconURL: requesterAvatar, }, Timestamp: time.Now().Format(time.RFC3339), }, @@ -56,147 +66,61 @@ func Voice(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.Interact }() defer close(done) - speakers, err := fetchSpeakers(ctx) + speakers, err := ttsutil.FetchSpeakers(ctx) if err != nil { log.Println("Failed to fetch speakers:", err) - s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ Embeds: &[]*discordgo.MessageEmbed{ { Title: "エラー", Description: "話者情報の取得に失敗しました。", Color: config.Colors.Error, Footer: &discordgo.MessageEmbedFooter{ - Text: "Requested by " + i.Member.DisplayName(), - IconURL: i.Member.AvatarURL(""), + 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 := buildSpeakerPages(speakers, speakerPageSize) + pages := ttsutil.BuildSpeakerPages(speakers, ttsutil.SpeakerPageSize) if len(pages) == 0 { - s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ Embeds: &[]*discordgo.MessageEmbed{ { Title: "エラー", Description: "話者情報が取得できませんでした。", Color: config.Colors.Error, Footer: &discordgo.MessageEmbedFooter{ - Text: "Requested by " + i.Member.DisplayName(), - IconURL: i.Member.AvatarURL(""), + 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 := getCurrentSpeakerID(ctx, i.Member.User.ID) - content, components := buildVoiceMessage(0, pages, currentSpeakerID) + currentSpeakerID := ttsutil.GetCurrentSpeakerID(ctx, memberID) + content, components := ttsutil.BuildVoiceMessage(0, pages, currentSpeakerID) - s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ Content: &content, Components: &components, }) -} - -func fetchSpeakers(ctx *internal.BotContext) ([]voicevox.Speaker, error) { - requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - return ctx.VoiceVox.GetSpeakers(requestCtx) -} - -func buildSpeakerPages(speakers []voicevox.Speaker, perPage int) []speakerPage { - pages := make([]speakerPage, 0) - current := speakerPage{Options: []discordgo.SelectMenuOption{}} - - 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(current.Options) > 0 && len(current.Options)+len(speakerOptions) > perPage { - pages = append(pages, current) - current = speakerPage{Options: []discordgo.SelectMenuOption{}} - } - current.Options = append(current.Options, speakerOptions...) - } - - if len(current.Options) > 0 { - pages = append(pages, current) - } - - 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: "tts_set_voice_select", - Placeholder: "話者を選択してください", - Options: pages[pageIndex].Options, - }, - }, - }, - } - - if maxPage > 1 { - prevID := fmt.Sprintf("tts_set_voice_page:%d", pageIndex-1) - nextID := fmt.Sprintf("tts_set_voice_page:%d", 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 + if err != nil { + log.Println("Failed to edit deferred interaction:", err) } - return setting.SpeakerID } diff --git a/src/internal/bot/messageComponent/tts_voice.go b/src/internal/bot/messageComponent/tts_voice.go index 8570d051..dda2f10c 100644 --- a/src/internal/bot/messageComponent/tts_voice.go +++ b/src/internal/bot/messageComponent/tts_voice.go @@ -1,28 +1,20 @@ package messageComponent import ( - "context" - "fmt" "log" "strconv" "strings" "time" "unibot/internal" - "unibot/internal/api/voicevox" + "unibot/internal/bot/ttsutil" "unibot/internal/repository" "github.com/bwmarrin/discordgo" ) -const speakerPageSize = 20 - -type speakerPage struct { - Options []discordgo.SelectMenuOption -} - func init() { - RegisterHandler("tts_set_voice_select", HandleTTSSetVoice) - RegisterHandler("tts_set_voice_page", HandleTTSSetVoicePage) + RegisterHandler(ttsutil.VoiceSelectCustomID, HandleTTSSetVoice) + RegisterHandler(ttsutil.VoicePageCustomIDPrefix, HandleTTSSetVoicePage) } // HandleTTSSetVoice は話者選択のセレクトメニューを処理します @@ -34,12 +26,32 @@ func HandleTTSSetVoice(ctx *internal.BotContext, s *discordgo.Session, i *discor } speakerID := values[0] - memberID := i.Member.User.ID + 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) - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Embeds: []*discordgo.MessageEmbed{ @@ -52,7 +64,9 @@ func HandleTTSSetVoice(ctx *internal.BotContext, s *discordgo.Session, i *discor }, Flags: discordgo.MessageFlagsEphemeral, }, - }) + }); err != nil { + log.Println("Failed to respond interaction:", err) + } return } @@ -60,7 +74,7 @@ func HandleTTSSetVoice(ctx *internal.BotContext, s *discordgo.Session, i *discor setting, err := repo.GetByMember(memberID) if err != nil { log.Println("Error fetching TTS personal setting:", err) - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Embeds: []*discordgo.MessageEmbed{ @@ -73,7 +87,9 @@ func HandleTTSSetVoice(ctx *internal.BotContext, s *discordgo.Session, i *discor }, Flags: discordgo.MessageFlagsEphemeral, }, - }) + }); err != nil { + log.Println("Failed to respond interaction:", err) + } return } @@ -84,7 +100,7 @@ func HandleTTSSetVoice(ctx *internal.BotContext, s *discordgo.Session, i *discor setting.SpeakerID = speakerID if err := repo.Create(setting); err != nil { log.Println("Error creating TTS personal setting:", err) - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Embeds: []*discordgo.MessageEmbed{ @@ -97,14 +113,16 @@ func HandleTTSSetVoice(ctx *internal.BotContext, s *discordgo.Session, i *discor }, 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) - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Embeds: []*discordgo.MessageEmbed{ @@ -117,13 +135,15 @@ func HandleTTSSetVoice(ctx *internal.BotContext, s *discordgo.Session, i *discor }, Flags: discordgo.MessageFlagsEphemeral, }, - }) + }); err != nil { + log.Println("Failed to respond interaction:", err) + } return } } - label := resolveSpeakerLabel(ctx, speakerID) - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + label := ttsutil.ResolveSpeakerLabel(ctx, speakerID) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseUpdateMessage, Data: &discordgo.InteractionResponseData{ Embeds: []*discordgo.MessageEmbed{ @@ -136,13 +156,15 @@ func HandleTTSSetVoice(ctx *internal.BotContext, s *discordgo.Session, i *discor }, 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.Split(customID, ":") + parts := strings.SplitN(customID, ":", 2) if len(parts) != 2 { return } @@ -152,10 +174,10 @@ func HandleTTSSetVoicePage(ctx *internal.BotContext, s *discordgo.Session, i *di return } - speakers, err := fetchSpeakers(ctx) + speakers, err := ttsutil.FetchSpeakers(ctx) if err != nil { log.Println("Failed to fetch speakers:", err) - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Embeds: []*discordgo.MessageEmbed{ @@ -168,13 +190,15 @@ func HandleTTSSetVoicePage(ctx *internal.BotContext, s *discordgo.Session, i *di }, Flags: discordgo.MessageFlagsEphemeral, }, - }) + }); err != nil { + log.Println("Failed to respond interaction:", err) + } return } - pages := buildSpeakerPages(speakers, speakerPageSize) + pages := ttsutil.BuildSpeakerPages(speakers, ttsutil.SpeakerPageSize) if len(pages) == 0 { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Embeds: []*discordgo.MessageEmbed{ @@ -187,130 +211,43 @@ func HandleTTSSetVoicePage(ctx *internal.BotContext, s *discordgo.Session, i *di }, Flags: discordgo.MessageFlagsEphemeral, }, - }) + }); err != nil { + log.Println("Failed to respond interaction:", err) + } return } - currentSpeakerID := getCurrentSpeakerID(ctx, i.Member.User.ID) - content, components := buildVoiceMessage(pageIndex, pages, currentSpeakerID) + 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) - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseUpdateMessage, Data: &discordgo.InteractionResponseData{ Content: content, Components: components, }, - }) -} - -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 fetchSpeakers(ctx *internal.BotContext) ([]voicevox.Speaker, error) { - requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - return ctx.VoiceVox.GetSpeakers(requestCtx) -} - -func buildSpeakerPages(speakers []voicevox.Speaker, perPage int) []speakerPage { - pages := make([]speakerPage, 0) - current := speakerPage{Options: []discordgo.SelectMenuOption{}} - - 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(current.Options) > 0 && len(current.Options)+len(speakerOptions) > perPage { - pages = append(pages, current) - current = speakerPage{Options: []discordgo.SelectMenuOption{}} - } - current.Options = append(current.Options, speakerOptions...) - } - - if len(current.Options) > 0 { - pages = append(pages, current) - } - - 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: "tts_set_voice_select", - Placeholder: "話者を選択してください", - Options: pages[pageIndex].Options, - }, - }, - }, - } - - if maxPage > 1 { - prevID := fmt.Sprintf("tts_set_voice_page:%d", pageIndex-1) - nextID := fmt.Sprintf("tts_set_voice_page:%d", 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 + }); err != nil { + log.Println("Failed to respond interaction:", err) } - return setting.SpeakerID } diff --git a/src/internal/bot/ttsutil/voice_picker.go b/src/internal/bot/ttsutil/voice_picker.go new file mode 100644 index 00000000..a9a901e3 --- /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 "", "", "" +}