Skip to content

Commit

Permalink
feat(audio): added bot integration web api audio endpoints
Browse files Browse the repository at this point in the history
Added integration with GET /api/v1/audio/{id} endpoint to downloading audio file from server. Also bot can save files local as cache
  • Loading branch information
Wittano committed Apr 12, 2024
1 parent 9dbece6 commit f7d3d64
Show file tree
Hide file tree
Showing 18 changed files with 270 additions and 80 deletions.
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ APPLICATION_ID= # Your bot application id
SERVER_GUID= # Your server ID
MONGODB_URI= # URI to connect MongoDB database
MONGODB_DB_NAME= # Main database for komputer bot
RAPID_API_KEY= # OPTIONAL! API key for RapidAPIs. In project is use: HumorAP
RAPID_API_KEY= # OPTIONAL! API key for RapidAPIs. In project is use: HumorAP
WEB_API_BASE_URL= # OPTIONAL! Base url to web api
CACHE_AUDIO_DIR= # OPTIONAL! Path to dictionary for local storage audio
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@ build/

.vscode/
result/
assets
assets

# Web files
*.mp3
*.mp4
*.yml
16 changes: 14 additions & 2 deletions bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import (
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/google/uuid"
"github.com/wittano/komputer/bot/internal/api"
"github.com/wittano/komputer/bot/internal/command"
"github.com/wittano/komputer/bot/internal/config"
"github.com/wittano/komputer/bot/internal/joke"
"github.com/wittano/komputer/bot/internal/voice"
"github.com/wittano/komputer/db"
"log/slog"
"os"
"time"
)

Expand All @@ -21,7 +23,10 @@ const (
databaseServiceID = 2
)

const requestIDKey = "requestID"
const (
baseURLKey = "WEB_API_BASE_URL"
requestIDKey = "requestID"
)

type slashCommandHandler struct {
ctx context.Context
Expand Down Expand Up @@ -115,10 +120,17 @@ func createJokeGetServices(globalCtx context.Context, database *db.MongodbDataba
}

func createCommands(globalCtx context.Context, services []joke.GetService, spockVoiceChns map[string]chan struct{}, guildVoiceChats map[string]voice.ChatInfo) map[string]command.DiscordSlashCommandHandler {
var client *api.WebClient
if url, ok := os.LookupEnv(baseURLKey); ok && url != "" {
client = api.NewClient(url)
} else {
slog.WarnContext(globalCtx, "BaseURL for Web API is empty. Web API is disabled")
}

welcomeCmd := command.WelcomeCommand{}
addJokeCmd := command.AddJokeCommand{Service: services[databaseServiceID].(joke.DatabaseJokeService)}
jokeCmd := command.JokeCommand{Services: services}
spockCmd := command.SpockCommand{GlobalCtx: globalCtx, SpockMusicStopChs: spockVoiceChns, GuildVoiceChats: guildVoiceChats}
spockCmd := command.SpockCommand{GlobalCtx: globalCtx, SpockMusicStopChs: spockVoiceChns, GuildVoiceChats: guildVoiceChats, ApiClient: client}
stopSpockCmd := command.SpockStopCommand{SpockMusicStopChs: spockVoiceChns}

return map[string]command.DiscordSlashCommandHandler{
Expand Down
59 changes: 59 additions & 0 deletions bot/internal/api/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package api

import (
"fmt"
"github.com/wittano/komputer/bot/internal/voice"
"io"
"net/http"
"os"
"time"
)

type WebClient struct {
baseURL string
client http.Client
}

func (c WebClient) DownloadAudio(id string) (path string, err error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/audio/%s", c.baseURL, id), nil)
if err != nil {
return "", err
}

res, err := c.client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()

if res.StatusCode != 200 {
body, err := io.ReadAll(res.Body)
if err == nil {
err = fmt.Errorf("failed download audio. Response: %s", body)
}

return "", err
}

dest := voice.Path(id)
f, err := os.Create(dest)
if err != nil {
return "", err
}
defer f.Close()

if _, err = io.Copy(f, res.Body); err != nil {
return "", err
}

return dest, nil
}

func NewClient(url string) *WebClient {
return &WebClient{
baseURL: url,
client: http.Client{
Timeout: time.Second * 5,
},
}
}
68 changes: 41 additions & 27 deletions bot/internal/command/spock.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/wittano/komputer/bot/internal/api"
"github.com/wittano/komputer/bot/internal/voice"
"log/slog"
"os"
Expand All @@ -18,6 +19,7 @@ type SpockCommand struct {
GlobalCtx context.Context
SpockMusicStopChs map[string]chan struct{}
GuildVoiceChats map[string]voice.ChatInfo
ApiClient *api.WebClient
}

func (sc SpockCommand) Command() *discordgo.ApplicationCommand {
Expand Down Expand Up @@ -52,45 +54,55 @@ func (sc SpockCommand) Execute(ctx context.Context, s *discordgo.Session, i *dis
return SimpleMessageResponse{Msg: "Kapitanie gdzie jesteś? Wejdź na kanał głosowy a ja dołącze"}, nil
}

go sc.playAudio(ctx, info.ChannelID, s, i)
logger := slog.With(requestIDKey, ctx.Value(requestIDKey))

return defaultMsg, nil
}
audioID, err := audioID(i.Data.(discordgo.ApplicationCommandInteractionData))
if err != nil {
logger.ErrorContext(ctx, "failed find song path", "error", err)

func (sc SpockCommand) playAudio(ctx context.Context, channelID string, s *discordgo.Session, i *discordgo.InteractionCreate) {
select {
case <-ctx.Done():
return
default:
return nil, nil
}

logger := slog.With(requestIDKey, ctx.Value(requestIDKey))
if err = voice.CheckIfAudioIsDownloaded(audioID); err != nil && sc.ApiClient != nil {
go func() {
logger.Info("download audio with id " + audioID)
_, err := sc.ApiClient.DownloadAudio(audioID)
if err != nil {
logger.Error(fmt.Sprintf("failed download audio. %s", err))
return
}

logger.Info("success download audio with id " + audioID)
sc.playAudio(logger, s, i, info.ChannelID, audioID)
}()

return SimpleMessageResponse{Msg: "Panie Kapitanie. Pobieram utwór. Proszę poczekać"}, nil
} else {
go sc.playAudio(logger, s, i, info.ChannelID, audioID)
}

return defaultMsg, nil
}

func (sc SpockCommand) playAudio(l *slog.Logger, s *discordgo.Session, i *discordgo.InteractionCreate, channelID string, audioID string) {
s.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsGuildVoiceStates)

voiceChat, err := s.ChannelVoiceJoin(i.GuildID, channelID, false, true)
if err != nil {
logger.ErrorContext(ctx, "failed join to voice channel", "error", err)
l.Error("failed join to voice channel", "error", err)

return
}
defer func(voiceChat *discordgo.VoiceConnection) {
if err := voiceChat.Disconnect(); err != nil {
logger.ErrorContext(ctx, "failed disconnect discord from voice channel", "error", err)
l.Error("failed disconnect discord from voice channel", "error", err)
}
}(voiceChat)
defer voiceChat.Close()

songPath, err := songPath(i.Data.(discordgo.ApplicationCommandInteractionData))
if err != nil {
logger.ErrorContext(ctx, "failed find song path", "error", err)

return
}

stopCh, ok := sc.SpockMusicStopChs[i.GuildID]
if !ok {
logger.ErrorContext(ctx, fmt.Sprintf("failed find user on voice channels on '%s' server", i.Member.GuildID), "error", fmt.Errorf("user with ID '%s' wasn't found on any voice chat on '%s' server", i.Member.User.ID, i.GuildID))
l.Error(fmt.Sprintf("failed find user on voice channels on '%s' server", i.Member.GuildID), "error", fmt.Errorf("user with ID '%s' wasn't found on any voice chat on '%s' server", i.Member.User.ID, i.GuildID))

return
}
Expand All @@ -100,22 +112,24 @@ func (sc SpockCommand) playAudio(ctx context.Context, channelID string, s *disco
cancel context.CancelFunc
)

if audioDuration, err := voice.DuractionAudio(songPath); err != nil {
logger.WarnContext(ctx, "failed calculated audio duration", "error", err)
voiceCtx := context.Background()
if audioDuration, err := voice.Duration(audioID); err != nil {
l.WarnContext(voiceCtx, "failed calculated audio duration", "error", err)

playingCtx, cancel = context.WithCancel(context.Background())
playingCtx, cancel = context.WithCancel(voiceCtx)
} else {
playingCtx, cancel = context.WithTimeout(context.Background(), audioDuration)
playingCtx, cancel = context.WithTimeout(voiceCtx, audioDuration)
}

defer cancel()

if err = voice.PlayAudio(playingCtx, voiceChat, songPath, stopCh); err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("failed play '%s' songPath", songPath), "error", err)
path := voice.Path(audioID)

if err = voice.Play(playingCtx, voiceChat, path, stopCh); err != nil {
l.ErrorContext(playingCtx, fmt.Sprintf("failed play '%s' audioID", audioID), "error", err)
}
}

func songPath(data discordgo.ApplicationCommandInteractionData) (path string, err error) {
func audioID(data discordgo.ApplicationCommandInteractionData) (path string, err error) {
for _, o := range data.Options {
switch o.Name {
case idOptionName:
Expand Down
50 changes: 25 additions & 25 deletions bot/internal/voice/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const (
audioBufSize = 16384
)

func PlayAudio(ctx context.Context, vc *discordgo.VoiceConnection, path string, stop <-chan struct{}) (err error) {
func Play(ctx context.Context, vc *discordgo.VoiceConnection, path string, stop <-chan struct{}) (err error) {
select {
case <-ctx.Done():
return context.Canceled
Expand Down Expand Up @@ -103,6 +103,30 @@ func PlayAudio(ctx context.Context, vc *discordgo.VoiceConnection, path string,
}
}

func Duration(path string) (duration time.Duration, err error) {
cmd := exec.Command("ffprobe", "-i", path, "-show_entries", "format=duration", "-v", "quiet", "-of", "csv='p=0'")
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}

if err = cmd.Start(); err != nil {
return
}

output, err := cmd.StdoutPipe()
if err != nil {
return
}
defer output.Close()

rawTime, err := io.ReadAll(output)
if err != nil {
return
}

return time.ParseDuration(string(rawTime) + "s")
}

func sendPCM(ctx context.Context, v *discordgo.VoiceConnection, pcm <-chan []int16) error {
opusEncoder, err := gopus.NewEncoder(frameRate, channels, gopus.Audio)
if err != nil {
Expand Down Expand Up @@ -135,27 +159,3 @@ func sendPCM(ctx context.Context, v *discordgo.VoiceConnection, pcm <-chan []int
}
}
}

func DuractionAudio(path string) (duration time.Duration, err error) {
cmd := exec.Command("ffprobe", "-i", path, "-show_entries", "format=duration", "-v", "quiet", "-of", "csv='p=0'")
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}

if err = cmd.Start(); err != nil {
return
}

output, err := cmd.StdoutPipe()
if err != nil {
return
}
defer output.Close()

rawTime, err := io.ReadAll(output)
if err != nil {
return
}

return time.ParseDuration(string(rawTime) + "s")
}
25 changes: 25 additions & 0 deletions bot/internal/voice/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package voice

import (
"os"
"path/filepath"
)

const (
defaultCacheDirAudio = "assets"
CacheDirAudioKey = "CACHE_AUDIO_DIR"
)

func CheckIfAudioIsDownloaded(id string) (err error) {
_, err = os.Stat(Path(id))
return
}

func Path(id string) string {
cachePath := defaultCacheDirAudio
if cacheDir, ok := os.LookupEnv(CacheDirAudioKey); ok && cacheDir != "" {
cachePath = cacheDir
}

return filepath.Join(cachePath, id+".mp3")
}
2 changes: 1 addition & 1 deletion cmd/web/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"log"
)

const defaultConfigPath = "config.yml"
const defaultConfigPath = "settings.yml"

func main() {
configPath := flag.String("config", defaultConfigPath, "Path to web console configuration audio")
Expand Down
5 changes: 3 additions & 2 deletions db/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import (
const DatabaseName = "komputer"

type AudioInfo struct {
ID primitive.ObjectID `bson:"_id"`
Path string `bson:"path"`
ID primitive.ObjectID `bson:"_id"`
Path string `bson:"path"`
Original string `bson:"original_name"`
}

type MongodbService interface {
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ go 1.22.1

require (
github.com/wittano/komputer/bot v0.0.0-20240411141946-658aa8d45c90
github.com/wittano/komputer/web v0.0.0-20240411141946-658aa8d45c90
github.com/wittano/komputer/web v0.0.0-20240412103354-9dbece601221
go.mongodb.org/mongo-driver v1.14.0
)

require (
github.com/bwmarrin/discordgo v0.28.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
Expand All @@ -32,6 +33,7 @@ require (
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32 // indirect
)
Loading

0 comments on commit f7d3d64

Please sign in to comment.