feat: initial implementation of uptodownbot
Telegram bot for downloading media from any site using yt-dlp. Supports audio/video/both, quality selection, language picker, container format selection, playlist pagination, progress updates, per-user quotas, user preferences, download history, cancel at any step, polling and webhook modes, proxy support, and SQLite persistence.
This commit is contained in:
256
internal/handler/keyboard.go
Normal file
256
internal/handler/keyboard.go
Normal file
@@ -0,0 +1,256 @@
|
||||
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"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildToggleKeyboard creates an on/off picker for a boolean setting.
|
||||
// Matches worktimeBot's pattern with "> " prefix for the current selection.
|
||||
func buildToggleKeyboard(userID int64, setting, label string, st *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
||||
current := false
|
||||
switch setting {
|
||||
case "quality":
|
||||
current = st.DefaultQuality == "best"
|
||||
case "container":
|
||||
current = st.DefaultContainer == "mp4"
|
||||
}
|
||||
options := []struct {
|
||||
label string
|
||||
data string
|
||||
value bool
|
||||
}{
|
||||
{"Enabled", setting + "_enable", true},
|
||||
{"Disabled", setting + "_disable", false},
|
||||
}
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
for _, opt := range options {
|
||||
l := opt.label
|
||||
if opt.value == current {
|
||||
l = "> " + l
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: l, CallbackData: opt.data},
|
||||
})
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back to Settings", CallbackData: "back_settings"},
|
||||
})
|
||||
return label + ":", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
// 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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user