Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/internal/api/voicevox/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
15 changes: 14 additions & 1 deletion src/internal/api/voicevox/types.go
Original file line number Diff line number Diff line change
@@ -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"`
}
7 changes: 6 additions & 1 deletion src/internal/bot/command/general/help/help-commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ var HelpCommands = []HelpCommand{
},
{
Name: "/tts <subcommand>",
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接続中に使用を推奨します。",
},
{
Expand All @@ -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`で表示される選択肢から話者を選びます。",
},
}
2 changes: 2 additions & 0 deletions src/internal/bot/command/general/tts.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func LoadTtsCommandContext() *discordgo.ApplicationCommand {
tts.LoadLeaveCommandContext(),
tts.LoadSkipCommandContext(),
tts.LoadDictCommandContext(),
tts.LoadSetCommandContext(),
tts.LoadSpeedCommandContext(),
},
}
Expand All @@ -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,
}

Expand Down
32 changes: 32 additions & 0 deletions src/internal/bot/command/general/tts/set.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
126 changes: 126 additions & 0 deletions src/internal/bot/command/general/tts/set/voice.go
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines 42 to 67
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential resource leak. The goroutine started at line 33 may not properly close when an error occurs before the defer statement at line 57 is reached. If the function returns early (e.g., at line 34 or 56), the goroutine will continue running for 3 minutes unnecessarily. Consider restructuring the code to ensure the done channel is closed in all exit paths, or use a defer statement immediately after creating the channel.

Copilot uses AI. Check for mistakes.

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)
}
}
23 changes: 23 additions & 0 deletions src/internal/bot/handler/interaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,36 @@ 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 {
entry.Handler(ctx, s, 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

Expand Down
Loading
Loading