From 4bad5f72dae2633e6549b23baabd87356e1014ce Mon Sep 17 00:00:00 2001 From: William P Date: Sun, 25 Jan 2026 20:16:58 -0500 Subject: [PATCH] interaction state management, call ytdlp through /download function --- app/main.go | 341 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 334 insertions(+), 7 deletions(-) diff --git a/app/main.go b/app/main.go index 02c05cb..1a4c463 100644 --- a/app/main.go +++ b/app/main.go @@ -1,14 +1,133 @@ package main import ( + "fmt" "log" "os" "os/signal" + "sync" "syscall" + "time" "github.com/bwmarrin/discordgo" ) +// InteractionState holds the state for a specific interaction +type InteractionState struct { + URL string +} + +// DownloadResult represents the result of an async download operation +type DownloadResult struct { + Success bool + Message string + URL string + Format string + Error error +} + +// startAsyncDownload initiates a download in a goroutine and handles progress updates +func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, audioFormat, outputDir string) { + resultChan := make(chan DownloadResult, 1) + + // Start download in goroutine + go func() { + defer close(resultChan) + defer func() { + if r := recover(); r != nil { + // Handle panic from downloadVideo + resultChan <- DownloadResult{ + Success: false, + Message: fmt.Sprintf("Download failed: %v", r), + URL: url, + Format: audioFormat, + Error: fmt.Errorf("%v", r), + } + } + }() + + // Call downloadVideo (it panics on error instead of returning error) + downloadVideo(outputDir, url, DownloadOptions{EmbedThumbnail: true, IncludeSubtitles: true}) + + // If we reach here, download was successful + resultChan <- DownloadResult{ + Success: true, + Message: "Video Downloaded Successfully!", + URL: url, + Format: audioFormat, + Error: nil, + } + }() + + // Handle results asynchronously + go func() { + // First update the original ephemeral message with "Processing..." + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("🔄 Processing download...\nURL: " + url + "\nAudio: " + audioFormat), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + + result := <-resultChan + + if result.Success { + // Update ephemeral message with completion status + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("✅ Download completed successfully!\nURL: " + result.URL + "\nAudio: " + result.Format), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + + // Send non-ephemeral completion message + _, err = s.FollowupMessageCreate(i.Interaction, false, &discordgo.WebhookParams{ + Content: "📥 Video downloaded: " + result.URL, + }) + if err != nil { + log.Printf("Error sending public completion message: %v", err) + } + } else { + // Update ephemeral message with error + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("❌ Download failed: " + result.Message + "\nURL: " + result.URL + "\nAudio: " + result.Format), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + } + }() +} + +// Helper function to create string pointer +func ptr(s string) *string { + return &s +} + +// Global state management +var ( + interactionStates = make(map[string]*InteractionState) + interactionStatesMutex = sync.RWMutex{} +) + +func getInteractionState(token string) *InteractionState { + interactionStatesMutex.RLock() + defer interactionStatesMutex.RUnlock() + return interactionStates[token] +} + +func setInteractionState(token string, state *InteractionState) { + interactionStatesMutex.Lock() + defer interactionStatesMutex.Unlock() + interactionStates[token] = state +} + +func deleteInteractionState(token string) { + interactionStatesMutex.Lock() + defer interactionStatesMutex.Unlock() + delete(interactionStates, token) +} + func main() { out_dir := os.Getenv("OUT_PATH") @@ -48,6 +167,140 @@ func main() { }, } + var componentHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "video_select": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Update components + updatedComponents := []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "video_select", + Placeholder: "Choose a video format...", + MaxValues: 1, + Disabled: true, + Options: []discordgo.SelectMenuOption{ + { + Label: "1920x1080 1080p (mp4, 1910kbps)", + Value: "1080p", + }, + { + Label: "1280x720 720p (mp4, 700kbps)", + Value: "720p", + }, + { + Label: "640x480 480p (mp4, 300kbps)", + Value: "480p", + }, + }, + }, + }, + }, + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "audio_select", + Placeholder: "Choose an audio format...", + MaxValues: 1, + Disabled: false, + Options: []discordgo.SelectMenuOption{ + { + Label: "Medium", + Value: "medium", + }, + { + Label: "Low", + Value: "low", + }, + }, + }, + }, + }, + } + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: "Now select an audio format:", + Components: updatedComponents, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + log.Printf("Error: %v", err) + } + }, + "audio_select": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Get URL from interaction state with fallback search + var state *InteractionState + + // First try current token + state = getInteractionState(i.Interaction.Token) + + // If not found, try to find state associated with this interaction context + if state == nil { + interactionStatesMutex.RLock() + for token, s := range interactionStates { + if s != nil && s.URL != "" { + // Found a state, transfer it to current token + state = s + // Copy to current token and clean up old one + interactionStatesMutex.RUnlock() + setInteractionState(i.Interaction.Token, state) + deleteInteractionState(token) + break + } + } + if state == nil { + interactionStatesMutex.RUnlock() + } + } + if state == nil { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Error: No video URL found. Please start over with /download.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Get selected audio format + selectedValues := i.MessageComponentData().Values + audioFormat := "medium" // default + if len(selectedValues) > 0 { + audioFormat = selectedValues[0] + } + + response := "" + if state.URL != "" { + // Respond immediately to prevent timeout + response = "🚀 Starting download...\nURL: " + state.URL + "\nAudio: " + audioFormat + "\n\nYou'll receive an update when the download completes!" + + // Start async download after responding + go func() { + // Small delay to ensure response is sent first + time.Sleep(100 * time.Millisecond) + startAsyncDownload(s, i, state.URL, audioFormat, out_dir) + }() + + // Clean up state after starting download + deleteInteractionState(i.Interaction.Token) + } else { + response = "I don't see a video here :(" + } + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: response, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + log.Printf("Error: %v", err) + } + }, + } + var commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ "download": func(s *discordgo.Session, i *discordgo.InteractionCreate) { options := i.ApplicationCommandData().Options @@ -55,25 +308,99 @@ func main() { for _, opt := range options { optionMap[opt.Name] = opt } - response := "" + + var url string if option, ok := optionMap["url"]; ok { - response = "It works! Your URL is: " + option.StringValue() + url = option.StringValue() } else { - response = "It works!" + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Error: No URL provided", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return } - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + + // Store URL in interaction state (we'll use the response message ID later) + // For now, store with the current token, then update after getting message ID + setInteractionState(i.Interaction.Token, &InteractionState{URL: url}) + + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: response, + Content: "Select a video format:", Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "video_select", + Placeholder: "Choose a video format...", + MaxValues: 1, + Options: []discordgo.SelectMenuOption{ + { + Label: "1920x1080 1080p (mp4, 1910kbps)", + Value: "1080p", + }, + { + Label: "1280x720 720p (mp4, 700kbps)", + Value: "720p", + }, + { + Label: "640x480 480p (mp4, 300kbps)", + Value: "480p", + }, + }, + }, + }, + }, + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "audio_select", + Placeholder: "Choose an audio format...", + MaxValues: 1, + Disabled: true, + Options: []discordgo.SelectMenuOption{ + { + Label: "Medium", + Value: "medium", + }, + { + Label: "Low", + Value: "low", + }, + }, + }, + }, + }, + }, }, }) + if err != nil { + log.Printf("Error: %v", err) + } }, } s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { - if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { - h(s, i) + switch i.Type { + case discordgo.InteractionApplicationCommand: + if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + if h, ok := componentHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + case discordgo.InteractionMessageComponent: + if h, ok := commandHandlers[i.MessageComponentData().CustomID]; ok { + h(s, i) + } + if h, ok := componentHandlers[i.MessageComponentData().CustomID]; ok { + h(s, i) + } } })