Files
uptodownbot/internal/handler/keyboard.go
db123 cd27b4d28d feat: add thumbnail preview, audio-only detection, /download command, tests, and bugfixes
- Add thumbnail preview with metadata (title, uploader, duration) before download
- Auto-detect audio-only sites (Spotify, SoundCloud, etc.) and skip type selection
- Add /download command for feature parity with UI button
- Fix yt-dlp defer bug: StatusFailed was overwritten to StatusCompleted
- Fix handleURLInput using editText with msgID=0 (now uses sendWithKB)
- Fix filename detection: use job ID prefix for reliable file finding
- Fix double answerCb between handleBackStep and handleDownloadPrompt
- Fix runDownload unused msgID parameter
- Fix context for download goroutine (use context.Background)
- Remove unused startWebhook h parameter
- Remove dead buildToggleKeyboard function
- Add formatBytes, formatDuration, formatLabelForDisplay tests
- Add IsAudioOnlyContent, MediaType.String tests
- Add repo integration tests (users, preferences, quotas, history, delete)
- Add cmd/bot tests (parseAllowedUsers, env helpers)
- Add edge case tests for progress parser
- Add zero/negative safety guard in formatDuration
- Add .gitea, TODO.md, opencode.json to gitignore
2026-06-25 15:07:31 +03:30

223 lines
6.4 KiB
Go

package handler
import (
"fmt"
"strings"
"github.com/go-telegram/bot/models"
"uptodownBot/internal/domain"
)
func mainKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Download", CallbackData: "download"},
{Text: "Settings", CallbackData: "settings"},
},
{
{Text: "History", CallbackData: "history"},
{Text: "Help", CallbackData: "help"},
},
},
}
}
func backKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
}
func settingsBackKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
}
// mediaTypeKeyboard builds the three-button row for media type selection + Cancel.
func mediaTypeKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Audio Only", CallbackData: "type_audio"},
{Text: "Video Only", CallbackData: "type_video"},
{Text: "Video+Audio", CallbackData: "type_both"},
},
{{Text: "Cancel", CallbackData: "cancel_dl"}},
},
}
}
// formatKeyboard builds a keyboard from a slice of format labels.
func formatKeyboard(prefix string, labels []string) models.InlineKeyboardMarkup {
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, label := range labels {
data := prefix + label
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: data})
if len(row) == 2 || i == len(labels)-1 {
rows = append(rows, row)
row = nil
}
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: "back_step"},
{Text: "Cancel", CallbackData: "cancel_dl"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// languageKeyboard builds a keyboard from available languages.
func languageKeyboard(languages []string) models.InlineKeyboardMarkup {
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, lang := range languages {
data := "lang_" + lang
row = append(row, models.InlineKeyboardButton{Text: lang, CallbackData: data})
if len(row) == 2 || i == len(languages)-1 {
rows = append(rows, row)
row = nil
}
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: "back_step"},
{Text: "Cancel", CallbackData: "cancel_dl"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// containerKeyboard builds keyboard for container format selection.
func containerKeyboard(containers []string) models.InlineKeyboardMarkup {
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, c := range containers {
data := "container_" + c
row = append(row, models.InlineKeyboardButton{Text: strings.ToUpper(c), CallbackData: data})
if len(row) == 3 || i == len(containers)-1 {
rows = append(rows, row)
row = nil
}
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: "back_step"},
{Text: "Cancel", CallbackData: "cancel_dl"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// progressKeyboard builds the progress display with a dummy button and cancel.
func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarkup {
progressText := fmt.Sprintf("%.1f%%", pct)
if speed != "" {
progressText += fmt.Sprintf(" | %s/s", speed)
}
if eta != "" {
progressText += fmt.Sprintf(" | ETA %s", eta)
}
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: progressText, CallbackData: "noop"}},
{{Text: "Cancel", CallbackData: "cancel_dl"}},
},
}
}
// playlistNavKeyboard builds the paginated navigation for playlist selection.
func playlistNavKeyboard(state *domain.PlaylistState, selectedCount int) models.InlineKeyboardMarkup {
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
if totalPages < 1 {
totalPages = 1
}
// Build entry rows (2 per row)
rows := [][]models.InlineKeyboardButton{}
start := state.Page * state.PerPage
end := start + state.PerPage
if end > len(state.Entries) {
end = len(state.Entries)
}
for i := start; i < end; i += 2 {
row := []models.InlineKeyboardButton{}
// First entry
e := state.Entries[i]
prefix := " "
if state.Selected[e.ID] {
prefix = "> "
}
// Truncate title for button
title := e.Title
if len(title) > 30 {
title = title[:27] + "..."
}
row = append(row, models.InlineKeyboardButton{
Text: prefix + title, CallbackData: "pl_entry_" + e.ID,
})
// Second entry if available
if i+1 < end {
e2 := state.Entries[i+1]
prefix2 := " "
if state.Selected[e2.ID] {
prefix2 = "> "
}
title2 := e2.Title
if len(title2) > 30 {
title2 = title2[:27] + "..."
}
row = append(row, models.InlineKeyboardButton{
Text: prefix2 + title2, CallbackData: "pl_entry_" + e2.ID,
})
}
rows = append(rows, row)
}
// Navigation row
navRow := []models.InlineKeyboardButton{}
navRow = append(navRow, models.InlineKeyboardButton{
Text: "<", CallbackData: fmt.Sprintf("pl_page_%d", state.Page-1),
})
navRow = append(navRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("Page %d/%d", state.Page+1, totalPages), CallbackData: "noop",
})
navRow = append(navRow, models.InlineKeyboardButton{
Text: ">", CallbackData: fmt.Sprintf("pl_page_%d", state.Page+1),
})
rows = append(rows, navRow)
// Action row
actionRow := []models.InlineKeyboardButton{
{Text: "Select All", CallbackData: "pl_select_all"},
}
if selectedCount > 0 {
actionRow = append(actionRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("Confirm (%d)", selectedCount), CallbackData: "pl_confirm",
})
}
rows = append(rows, actionRow)
// Cancel
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Cancel", CallbackData: "cancel_dl"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// deleteConfirmKeyboard builds the confirmation prompt for account deletion.
func deleteConfirmKeyboard(userID int64) models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", userID)},
{Text: "Cancel", CallbackData: "back_settings"},
},
},
}
}