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:
2026-06-25 14:22:48 +03:30
commit 05fcdf7458
26 changed files with 3302 additions and 0 deletions

View File

@@ -0,0 +1,458 @@
package handler
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"uptodownBot/internal/dl"
"uptodownBot/internal/domain"
)
// Step state stored per user during the download flow
type stepState struct {
URL string
MediaInfo *domain.MediaInfo
Step int // 0=type, 1=quality, 2=language, 3=container
MediaType domain.MediaType
Quality string
Language string
Container string
FormatIDs []string
PlaylistState *domain.PlaylistState
URLMessageID int // original URL message ID for editing
}
// getUserStepState retrieves the current step state for a user.
func (h *Handler) getUserStepState(chatID int64) *stepState {
h.mu.Lock()
defer h.mu.Unlock()
return h.stepStates[chatID]
}
func (h *Handler) setUserStepState(chatID int64, ss *stepState) {
h.mu.Lock()
defer h.mu.Unlock()
h.stepStates[chatID] = ss
}
func (h *Handler) clearStepState(chatID int64) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.stepStates, chatID)
}
// handleURLInput is the entry point when a user sends a URL or types in Download mode.
func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, userID int64) {
url, ok := domain.ValidateURL(text)
if !ok {
h.sendText(ctx, chatID, "Please send a valid URL (http/https).")
return
}
// Check for active job
if job := h.getActiveJob(userID); job != nil {
h.sendText(ctx, chatID, "You already have an active download. Cancel it first.")
return
}
// Check quota
info, err := h.YtDlp.GetMediaInfo(url)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL") {
h.sendText(ctx, chatID, "This URL is not supported.")
} else if strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies") {
h.sendText(ctx, chatID, "This content requires authentication (cookies).")
} else if strings.Contains(errMsg, "live") {
h.sendText(ctx, chatID, "Cannot download live streams.")
} else {
h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg))
}
return
}
if info.IsPlaylist {
h.handlePlaylist(ctx, chatID, userID, url, info)
return
}
if len(info.Formats) == 0 {
h.sendText(ctx, chatID, "No formats available for this URL.")
return
}
// Check quota before starting flow
q, err := h.Repo.GetOrCreateQuotas(userID)
if err == nil {
q = h.resetQuotasIfNeeded(q)
estimatedMB := info.EstimatedSize / (1024 * 1024)
if estimatedMB > 0 {
if q.DailyUsedMB+estimatedMB > q.DailyLimitMB ||
q.WeeklyUsedMB+estimatedMB > q.WeeklyLimitMB ||
q.MonthlyUsedMB+estimatedMB > q.MonthlyLimitMB {
h.sendText(ctx, chatID, fmt.Sprintf(
"Quota exceeded. Daily: %d/%d MB, Weekly: %d/%d MB, Monthly: %d/%d MB.",
q.DailyUsedMB, q.DailyLimitMB, q.WeeklyUsedMB, q.WeeklyLimitMB, q.MonthlyUsedMB, q.MonthlyLimitMB))
return
}
}
}
ss := &stepState{
URL: url,
MediaInfo: info,
Step: 0,
}
h.setUserStepState(chatID, ss)
h.editText(ctx, chatID, 0, fmt.Sprintf("URL: %s\nTitle: %s\n\nSelect media type:", url, info.Title), &models.InlineKeyboardMarkup{
InlineKeyboard: mediaTypeKeyboard().InlineKeyboard,
})
}
// handleTypeSelection processes the media type choice.
func (h *Handler) handleTypeSelection(ctx context.Context, chatID int64, msgID int, userID int64, choice string) {
ss := h.getUserStepState(chatID)
if ss == nil {
return
}
switch choice {
case "audio":
ss.MediaType = domain.MediaTypeAudio
case "video":
ss.MediaType = domain.MediaTypeVideo
case "both":
ss.MediaType = domain.MediaTypeVideoAudio
default:
return
}
ss.Step = 1
// Filter and deduplicate formats by type
filtered := dl.FilterFormatsByType(ss.MediaInfo.Formats, ss.MediaType)
if len(filtered) == 0 {
h.editText(ctx, chatID, msgID, fmt.Sprintf("No %s formats available for this URL.", choice), &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back", CallbackData: "back_step"}, {Text: "Cancel", CallbackData: "cancel_dl"}},
},
})
return
}
deduped := dl.DeduplicateResolutions(filtered)
ss.FormatIDs = make([]string, len(deduped))
labels := make([]string, len(deduped))
for i, f := range deduped {
ss.FormatIDs[i] = f.ID
labels[i] = formatLabelForDisplay(f)
}
// Add "Best" option first
ss.FormatIDs = append([]string{"best"}, ss.FormatIDs...)
labels = append([]string{"Best"}, labels...)
h.setUserStepState(chatID, ss)
kb := formatKeyboard("quality_", labels)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Title: %s\n\nSelect quality:", ss.MediaInfo.Title), &kb)
}
func formatLabelForDisplay(f domain.Format) string {
if f.Resolution != "" {
label := f.Resolution
if f.Filesize > 0 {
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
}
return label
}
if f.Bitrate != "" {
label := f.Bitrate
if f.Filesize > 0 {
label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize))
}
return label
}
return f.ID
}
// handleQualitySelection processes the quality choice.
func (h *Handler) handleQualitySelection(ctx context.Context, chatID int64, msgID int, userID int64, quality string) {
ss := h.getUserStepState(chatID)
if ss == nil {
return
}
ss.Quality = quality
ss.Step = 2
// Check if we need language selection
if len(ss.MediaInfo.Languages) > 1 {
kb := languageKeyboard(ss.MediaInfo.Languages)
h.editText(ctx, chatID, msgID, "Select language:", &kb)
return
}
// Skip to container selection
ss.Step = 3
containers := dl.ContainerOptions(ss.MediaType)
kb := containerKeyboard(containers)
h.editText(ctx, chatID, msgID, "Select container format:", &kb)
}
// handleLanguageSelection processes the language choice.
func (h *Handler) handleLanguageSelection(ctx context.Context, chatID int64, msgID int, userID int64, language string) {
ss := h.getUserStepState(chatID)
if ss == nil {
return
}
ss.Language = language
ss.Step = 3
h.setUserStepState(chatID, ss)
containers := dl.ContainerOptions(ss.MediaType)
kb := containerKeyboard(containers)
h.editText(ctx, chatID, msgID, "Select container format:", &kb)
}
// handleContainerSelection processes the container format choice and starts the download.
func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, msgID int, userID int64, container string) {
ss := h.getUserStepState(chatID)
if ss == nil {
return
}
ss.Container = container
// Build the format object
format := &domain.Format{ID: ss.Quality}
for _, f := range ss.MediaInfo.Formats {
if f.ID == ss.Quality {
format = &f
break
}
}
// Create the download job
job := &domain.DownloadJob{
ID: h.generateJobID(),
UserID: userID,
ChatID: chatID,
MessageID: msgID,
URL: ss.URL,
MediaType: ss.MediaType,
SelectedFormat: format,
Container: container,
Language: ss.Language,
Status: domain.StatusDownloading,
Title: ss.MediaInfo.Title,
FileSize: format.Filesize,
}
h.setActiveJob(userID, job)
h.clearStepState(chatID)
// Start download in background
go h.runDownload(ctx, job, msgID)
}
// runDownload manages the download lifecycle, progress updates, and completion.
func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob, msgID int) {
// Show initial progress message
kb := progressKeyboard(0, "", "")
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: job.ChatID,
Text: fmt.Sprintf("Downloading: %s", job.Title),
ReplyMarkup: &kb,
})
if err != nil {
slog.Error("send progress msg failed", "error", err)
return
}
job.MessageID = msg.ID
updates, err := h.YtDlp.StartDownload(job, h.downloadDir)
if err != nil {
h.editText(ctx, job.ChatID, job.MessageID, fmt.Sprintf("Download failed: %s", err.Error()), nil)
job.Status = domain.StatusFailed
h.mu.Lock()
delete(h.activeJobs, job.UserID)
h.mu.Unlock()
return
}
lastProgress := 0.0
for upd := range updates {
h.mu.Lock()
currentJob := h.activeJobs[job.UserID]
h.mu.Unlock()
if currentJob == nil {
// Job was cancelled
return
}
if upd.Status == domain.StatusCancelled {
h.editText(ctx, job.ChatID, job.MessageID, "Download cancelled.", nil)
h.mu.Lock()
delete(h.activeJobs, job.UserID)
h.mu.Unlock()
return
}
if upd.Status == domain.StatusFailed {
h.editText(ctx, job.ChatID, job.MessageID, "Download failed.", nil)
h.mu.Lock()
delete(h.activeJobs, job.UserID)
h.mu.Unlock()
return
}
// Update progress every ~5%
if upd.Progress-lastProgress >= domain.ProgressThreshold || upd.Status == domain.StatusCompleted {
lastProgress = upd.Progress
// Apply quota at >50%
if upd.Progress > domain.QuotaProgressThreshold && !job.QuotaApplied && job.FileSize > 0 {
job.QuotaApplied = true
h.applyQuota(job.UserID, job.FileSize)
}
kb := progressKeyboard(upd.Progress, upd.Speed, upd.ETA)
text := fmt.Sprintf("Downloading: %s", job.Title)
h.editText(ctx, job.ChatID, job.MessageID, text, &kb)
}
}
// Download completed
h.mu.Lock()
delete(h.activeJobs, job.UserID)
h.mu.Unlock()
if job.Status == domain.StatusCompleted {
// Find the downloaded file
files, err := filepath.Glob(filepath.Join(h.downloadDir, "*"))
if err == nil {
for _, f := range files {
fileInfo, err := os.Stat(f)
if err != nil {
continue
}
if fileInfo.Size() > h.maxFileSize {
h.editText(ctx, job.ChatID, job.MessageID,
fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil)
os.Remove(f)
return
}
// Check file size quota after download
if !job.QuotaApplied && job.FileSize > 0 {
job.QuotaApplied = true
h.applyQuota(job.UserID, job.FileSize)
}
formatID := job.SelectedFormat.ID
h.sendDocument(ctx, job.ChatID, f, job.Title)
os.Remove(f)
// Save to history
_ = h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{
URL: job.URL,
Title: job.Title,
MediaType: job.MediaType.String(),
FormatID: formatID,
Container: job.Container,
FileSize: job.FileSize,
Status: "completed",
})
h.editText(ctx, job.ChatID, job.MessageID, "Download completed!", nil)
return
}
}
h.editText(ctx, job.ChatID, job.MessageID, "Download completed but file not found.", nil)
}
}
func (h *Handler) sendDocument(ctx context.Context, chatID int64, filePath, title string) {
f, err := os.Open(filePath)
if err != nil {
slog.Error("open file for send", "path", filePath, "error", err)
return
}
defer f.Close()
doc := &models.InputFileUpload{
Filename: title + filepath.Ext(filePath),
Data: f,
}
if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{
ChatID: chatID,
Document: doc,
}); err != nil {
slog.Error("send document failed", "error", err)
}
}
// handleCancelDownload cancels the current download for a user.
func (h *Handler) handleCancelDownload(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
defer h.answerCb(ctx, cbID)
job := h.getActiveJob(userID)
if job != nil {
dl.CancelDownload(job)
h.mu.Lock()
delete(h.activeJobs, userID)
h.mu.Unlock()
h.editText(ctx, chatID, msgID, "Download cancelled.", nil)
return
}
h.clearStepState(chatID)
h.editText(ctx, chatID, msgID, "Cancelled.", nil)
}
// handleBackStep goes back one step in the download flow.
func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
defer h.answerCb(ctx, cbID)
ss := h.getUserStepState(chatID)
if ss == nil {
return
}
ss.Step--
switch ss.Step {
case -1:
// Go back to URL input
h.clearStepState(chatID)
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
case 0:
h.editText(ctx, chatID, msgID, "Select media type:", &models.InlineKeyboardMarkup{
InlineKeyboard: mediaTypeKeyboard().InlineKeyboard,
})
case 1:
// Re-show quality selector
filtered := dl.FilterFormatsByType(ss.MediaInfo.Formats, ss.MediaType)
deduped := dl.DeduplicateResolutions(filtered)
labels := make([]string, len(deduped))
for i, f := range deduped {
labels[i] = formatLabelForDisplay(f)
}
labels = append([]string{"Best"}, labels...)
kb := formatKeyboard("quality_", labels)
h.editText(ctx, chatID, msgID, "Select quality:", &kb)
case 2:
if len(ss.MediaInfo.Languages) > 1 {
kb := languageKeyboard(ss.MediaInfo.Languages)
h.editText(ctx, chatID, msgID, "Select language:", &kb)
} else {
ss.Step = 1
h.handleBackStep(ctx, chatID, msgID, cbID, userID)
}
}
}
// handleDownloadPrompt shows the download prompt (asks for URL).
func (h *Handler) handleDownloadPrompt(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, userID, PendingURL,
"Send me the URL to download:", nil)
}

502
internal/handler/handler.go Normal file
View File

@@ -0,0 +1,502 @@
package handler
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/google/uuid"
"uptodownBot/internal/dl"
"uptodownBot/internal/domain"
"uptodownBot/internal/repo"
)
type PendingKind string
const (
PendingURL PendingKind = "url"
)
type PendingInput struct {
Kind PendingKind
UserID int64
CreatedAt time.Time
MsgID int
Data map[string]string
}
type Handler struct {
Bot *tgbot.Bot
Repo repo.Repository
YtDlp *dl.Client
AllowedUsers map[int64]bool
activeJobs map[int64]*domain.DownloadJob
stepStates map[int64]*stepState
lastMsgTime map[int64]time.Time
pendingInput map[int64]*PendingInput
mu sync.Mutex
downloadDir string
maxFileSize int64
}
func NewHandler(bot *tgbot.Bot, store repo.Repository, ytdlp *dl.Client, allowed map[int64]bool, downloadDir string, maxFileSize int64) *Handler {
h := &Handler{
Bot: bot,
Repo: store,
YtDlp: ytdlp,
AllowedUsers: allowed,
activeJobs: make(map[int64]*domain.DownloadJob),
stepStates: make(map[int64]*stepState),
lastMsgTime: make(map[int64]time.Time),
pendingInput: make(map[int64]*PendingInput),
downloadDir: downloadDir,
maxFileSize: maxFileSize,
}
go h.periodicCleanup()
return h
}
func (h *Handler) periodicCleanup() {
for {
time.Sleep(domain.CleanupInterval)
h.mu.Lock()
// Clean rate limit entries
cutoff := time.Now().Add(-domain.RateLimitInterval * 2)
for uid, t := range h.lastMsgTime {
if t.Before(cutoff) {
delete(h.lastMsgTime, uid)
}
}
// Clean stale pending inputs
pendingCutoff := time.Now().Add(-5 * time.Minute)
for chatID, pi := range h.pendingInput {
if pi.CreatedAt.Before(pendingCutoff) {
delete(h.pendingInput, chatID)
}
}
h.mu.Unlock()
// Clean old temp files (>1h)
h.cleanOldTempFiles()
}
}
func (h *Handler) cleanOldTempFiles() {
entries, err := os.ReadDir(h.downloadDir)
if err != nil {
return
}
cutoff := time.Now().Add(-domain.FileRetention)
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
path := filepath.Join(h.downloadDir, entry.Name())
if err := os.Remove(path); err != nil {
slog.Warn("failed to remove old temp file", "path", path, "error", err)
}
}
}
}
func (h *Handler) isRateLimited(userID int64) bool {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < domain.RateLimitInterval {
return true
}
h.lastMsgTime[userID] = now
return false
}
func (h *Handler) getOrCreateUser(chatID int64) (int64, error) {
return h.Repo.GetOrCreateUser(chatID)
}
func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] {
return
}
if msg.Text == "" {
return
}
userID, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
return
}
if h.isRateLimited(userID) {
return
}
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
if msg.Text[0] == '/' {
h.mu.Lock()
delete(h.pendingInput, msg.Chat.ID)
h.mu.Unlock()
h.routeCommand(ctx, msg, userID)
return
}
h.mu.Lock()
pi, hasPI := h.pendingInput[msg.Chat.ID]
if hasPI {
delete(h.pendingInput, msg.Chat.ID)
}
h.mu.Unlock()
if hasPI {
h.processPendingInput(ctx, msg, userID, pi)
return
}
// Treat plain text as a potential URL
h.handleURLInput(ctx, msg.Chat.ID, msg.Text, userID)
}
func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, userID int64) {
cmd := msg.Text
if i := strings.IndexByte(msg.Text, ' '); i >= 0 {
cmd = msg.Text[:i]
}
switch cmd {
case "/start":
h.handleStart(ctx, msg, userID)
case "/settings":
h.handleSettings(ctx, msg, userID)
case "/help":
h.handleHelp(ctx, msg)
case "/history":
h.handleHistory(ctx, msg, userID)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
}
func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) {
chatID := cb.Message.Message.Chat.ID
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[chatID] {
h.answerCbWithText(ctx, cb.ID, "Unauthorized")
return
}
userID, err := h.getOrCreateUser(chatID)
if err != nil {
h.answerCb(ctx, cb.ID)
return
}
if h.isRateLimited(userID) {
h.answerCb(ctx, cb.ID)
return
}
slog.Info("callback", "chat_id", chatID, "data", cb.Data)
h.mu.Lock()
delete(h.pendingInput, chatID)
h.mu.Unlock()
msgID := cb.Message.Message.ID
data := cb.Data
switch {
case data == "noop":
h.answerCb(ctx, cb.ID)
case data == "cancel_dl":
h.handleCancelDownload(ctx, chatID, msgID, cb.ID, userID)
case data == "back_step":
h.handleBackStep(ctx, chatID, msgID, cb.ID, userID)
case data == "back_menu":
h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
case data == "download":
h.handleDownloadPrompt(ctx, chatID, msgID, cb.ID, userID)
case data == "settings":
h.settingsCallback(ctx, chatID, msgID, cb.ID, userID)
case data == "help":
h.handleHelp(ctx, cb.Message.Message)
h.answerCb(ctx, cb.ID)
case data == "history":
h.handleHistory(ctx, cb.Message.Message, userID)
h.answerCb(ctx, cb.ID)
case data == "pending_cancel":
h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
default:
h.routePrefixedCallback(ctx, chatID, msgID, cb.ID, userID, data)
}
}
func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID int, cbID string, userID int64, data string) {
defer h.answerCb(ctx, cbID)
switch {
case strings.HasPrefix(data, "type_"):
h.handleTypeSelection(ctx, chatID, msgID, userID, data[5:])
case strings.HasPrefix(data, "quality_"):
h.handleQualitySelection(ctx, chatID, msgID, userID, data[8:])
case strings.HasPrefix(data, "lang_"):
h.handleLanguageSelection(ctx, chatID, msgID, userID, data[5:])
case strings.HasPrefix(data, "container_"):
h.handleContainerSelection(ctx, chatID, msgID, userID, data[10:])
case strings.HasPrefix(data, "pl_page_"):
h.handlePlaylistPage(ctx, chatID, msgID, userID, data[8:])
case strings.HasPrefix(data, "pl_entry_"):
h.handlePlaylistEntry(ctx, chatID, msgID, userID, data[9:])
case data == "pl_select_all":
h.handlePlaylistSelectAll(ctx, chatID, msgID, userID)
case data == "pl_confirm":
h.handlePlaylistConfirm(ctx, chatID, msgID, userID)
case data == "back_settings":
h.backToSettings(ctx, chatID, msgID, userID)
case data == "delaccount_prompt":
h.deleteAccountPrompt(ctx, chatID, msgID, userID)
case strings.HasPrefix(data, "delaccount_"):
h.deleteAccountConfirm(ctx, chatID, msgID, userID)
case strings.HasPrefix(data, "settings_"):
h.handleSettingsSelection(ctx, chatID, msgID, userID, data[9:])
case strings.HasPrefix(data, "settings_set_"):
h.handleSettingsSet(ctx, chatID, msgID, userID, data[13:])
}
}
func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, data map[string]string) {
h.mu.Lock()
h.pendingInput[chatID] = &PendingInput{
Kind: kind,
UserID: userID,
CreatedAt: time.Now(),
MsgID: msgID,
Data: data,
}
h.mu.Unlock()
}
func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, userID int64, pi *PendingInput) {
text := strings.TrimSpace(msg.Text)
switch pi.Kind {
case PendingURL:
h.handleURLInput(ctx, msg.Chat.ID, text, userID)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown input type")
}
}
func (h *Handler) promptForInput(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, prompt string, data map[string]string) {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Cancel", CallbackData: "pending_cancel"}},
},
}
if msgID == 0 {
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: chatID, Text: prompt, ReplyMarkup: kb,
})
if err != nil {
slog.Warn("send prompt failed", "chat_id", chatID, "error", err)
return
}
h.setPending(ctx, chatID, msg.ID, userID, kind, data)
} else {
h.editText(ctx, chatID, msgID, prompt, &kb)
h.setPending(ctx, chatID, msgID, userID, kind, data)
}
}
func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
if msgID == 0 {
if kb != nil {
h.sendWithKB(ctx, chatID, text, *kb)
} else {
h.sendText(ctx, chatID, text)
}
} else {
h.editText(ctx, chatID, msgID, text, kb)
}
}
// -- helpers --
func (h *Handler) sendText(ctx context.Context, chatID int64, text string) {
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil {
slog.Warn("send text failed", "chat_id", chatID, "error", err)
}
}
func (h *Handler) editText(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
p := &tgbot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text}
if kb != nil {
p.ReplyMarkup = kb
}
if _, err := h.Bot.EditMessageText(ctx, p); err != nil {
slog.Warn("edit text failed", "chat_id", chatID, "msg_id", msgID, "error", err)
}
}
func (h *Handler) sendWithKB(ctx context.Context, chatID int64, text string, kb models.InlineKeyboardMarkup) {
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}); err != nil {
slog.Warn("send with keyboard failed", "chat_id", chatID, "error", err)
}
}
func (h *Handler) answerCb(ctx context.Context, callbackID string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil {
slog.Debug("answer callback failed", "error", err)
}
}
func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID, Text: text}); err != nil {
slog.Debug("answer callback with text failed", "error", err)
}
}
func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
defer h.answerCb(ctx, cbID)
h.clearActiveJob(userID)
h.editText(ctx, chatID, msgID, "Main Menu:", nil)
h.sendWithKB(ctx, chatID, "Main Menu:", mainKeyboard())
}
func (h *Handler) clearActiveJob(userID int64) {
h.mu.Lock()
defer h.mu.Unlock()
if job, ok := h.activeJobs[userID]; ok {
dl.CancelDownload(job)
delete(h.activeJobs, userID)
}
}
func (h *Handler) getActiveJob(userID int64) *domain.DownloadJob {
h.mu.Lock()
defer h.mu.Unlock()
return h.activeJobs[userID]
}
func (h *Handler) setActiveJob(userID int64, job *domain.DownloadJob) {
h.mu.Lock()
defer h.mu.Unlock()
h.activeJobs[userID] = job
}
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return strconv.FormatInt(bytes, 10) + "B"
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
switch exp {
case 0:
return fmt.Sprintf("%.1fKB", float64(bytes)/float64(div))
case 1:
return fmt.Sprintf("%.1fMB", float64(bytes)/float64(div))
case 2:
return fmt.Sprintf("%.1fGB", float64(bytes)/float64(div))
default:
return fmt.Sprintf("%.1fTB", float64(bytes)/float64(div))
}
}
func (h *Handler) generateJobID() string {
return uuid.NewString()[:8]
}
// handleStart shows the main menu.
func (h *Handler) handleStart(ctx context.Context, msg *models.Message, userID int64) {
slog.Info("start", "user_id", userID, "chat_id", msg.Chat.ID)
h.sendWithKB(ctx, msg.Chat.ID, "Main Menu:", mainKeyboard())
}
// handleHelp shows the help text.
func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
text := `Commands:
/start - Show main menu
/settings - Open settings
/help - Show this help
/history - View download history
Just send me a URL to start downloading.`
h.sendText(ctx, msg.Chat.ID, text)
}
// handleSettings opens the settings menu.
func (h *Handler) handleSettings(ctx context.Context, msg *models.Message, userID int64) {
prefs, err := h.Repo.GetOrCreatePreferences(userID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
text, kb := h.buildSettingsKeyboard(userID, prefs)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// handleHistory opens the download history.
func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, userID int64) {
records, err := h.Repo.GetHistory(userID, 10, 0)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading history")
return
}
if len(records) == 0 {
h.sendText(ctx, msg.Chat.ID, "No download history yet.")
return
}
text := "Recent downloads:\n"
for i, r := range records {
text += fmt.Sprintf("%d. %s (%s)\n", i+1, r.Title, formatBytes(r.FileSize))
}
h.sendText(ctx, msg.Chat.ID, text)
}
// applyQuota adds file size to the user's quota counters.
func (h *Handler) applyQuota(userID int64, fileSize int64) {
q, err := h.Repo.GetOrCreateQuotas(userID)
if err != nil {
return
}
q = h.resetQuotasIfNeeded(q)
sizeMB := fileSize / (1024 * 1024)
if sizeMB < 1 {
sizeMB = 1
}
q.DailyUsedMB += sizeMB
q.WeeklyUsedMB += sizeMB
q.MonthlyUsedMB += sizeMB
_ = h.Repo.UpdateQuotas(userID, q)
}
// resetQuotasIfNeeded resets quota counters if the period has changed.
func (h *Handler) resetQuotasIfNeeded(q *domain.UserQuotas) *domain.UserQuotas {
today := time.Now().UTC().Format(domain.DateLayout)
_, weekNum := time.Now().UTC().ISOWeek()
weekStart := fmt.Sprintf("%d-W%02d", time.Now().UTC().Year(), weekNum)
monthStart := time.Now().UTC().Format("2006-01")
if q.DailyDate != today {
q.DailyUsedMB = 0
q.DailyDate = today
}
if q.WeekStart != weekStart {
q.WeeklyUsedMB = 0
q.WeekStart = weekStart
}
if q.MonthStart != monthStart {
q.MonthlyUsedMB = 0
q.MonthStart = monthStart
}
return q
}

View 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"},
},
},
}
}

View File

@@ -0,0 +1,195 @@
package handler
import (
"context"
"fmt"
"strconv"
"time"
"uptodownBot/internal/domain"
)
func (h *Handler) handlePlaylist(ctx context.Context, chatID int64, userID int64, url string, info *domain.MediaInfo) {
state := &domain.PlaylistState{
Entries: info.PlaylistEntries,
Page: 0,
PerPage: 10,
Selected: make(map[string]bool),
}
ss := &stepState{
URL: url,
MediaInfo: info,
PlaylistState: state,
}
h.setUserStepState(chatID, ss)
kb := playlistNavKeyboard(state, 0)
h.sendWithKB(ctx, chatID, fmt.Sprintf("Playlist: %s (%d videos)\n\nSelect videos to download:", info.Title, info.PlaylistCount), kb)
}
func (h *Handler) getPlaylistState(chatID int64) *domain.PlaylistState {
ss := h.getUserStepState(chatID)
if ss == nil {
return nil
}
return ss.PlaylistState
}
func (h *Handler) handlePlaylistPage(ctx context.Context, chatID int64, msgID int, userID int64, pageStr string) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return
}
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
if page < 0 || page >= totalPages {
return
}
state.Page = page
selectedCount := countSelected(state)
kb := playlistNavKeyboard(state, selectedCount)
ss := h.getUserStepState(chatID)
title := ""
if ss != nil {
title = ss.MediaInfo.Title
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
}
func (h *Handler) handlePlaylistEntry(ctx context.Context, chatID int64, msgID int, userID int64, entryID string) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
state.Selected[entryID] = !state.Selected[entryID]
selectedCount := countSelected(state)
kb := playlistNavKeyboard(state, selectedCount)
ss := h.getUserStepState(chatID)
title := ""
if ss != nil {
title = ss.MediaInfo.Title
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
}
func (h *Handler) handlePlaylistSelectAll(ctx context.Context, chatID int64, msgID int, userID int64) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
allSelected := true
for _, e := range state.Entries {
if !state.Selected[e.ID] {
allSelected = false
break
}
}
for _, e := range state.Entries {
state.Selected[e.ID] = !allSelected
}
selectedCount := 0
if !allSelected {
selectedCount = len(state.Entries)
}
kb := playlistNavKeyboard(state, selectedCount)
ss := h.getUserStepState(chatID)
title := ""
if ss != nil {
title = ss.MediaInfo.Title
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Playlist: %s (%d videos)", title, len(state.Entries)), &kb)
}
func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID int, userID int64) {
state := h.getPlaylistState(chatID)
if state == nil {
return
}
var selected []domain.PlaylistEntry
for _, e := range state.Entries {
if state.Selected[e.ID] {
selected = append(selected, e)
}
}
if len(selected) == 0 {
h.editText(ctx, chatID, msgID, "No videos selected.", nil)
return
}
h.clearStepState(chatID)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Downloading %d videos sequentially...", len(selected)), nil)
for i, entry := range selected {
h.sendText(ctx, chatID, fmt.Sprintf("Downloading (%d/%d): %s", i+1, len(selected), entry.Title))
info, err := h.YtDlp.GetMediaInfo(entry.URL)
if err != nil {
h.sendText(ctx, chatID, fmt.Sprintf("Failed: %s", err.Error()))
continue
}
prefs, prefErr := h.Repo.GetOrCreatePreferences(userID)
if prefErr != nil {
prefs = &domain.UserPreferences{
DefaultMediaType: domain.MediaTypeVideoAudio,
DefaultQuality: "best",
DefaultContainer: "mp4",
}
}
format := &domain.Format{ID: prefs.DefaultQuality}
for _, f := range info.Formats {
if f.ID == prefs.DefaultQuality {
format = &f
break
}
}
job := &domain.DownloadJob{
ID: h.generateJobID(),
UserID: userID,
ChatID: chatID,
URL: entry.URL,
MediaType: prefs.DefaultMediaType,
SelectedFormat: format,
Container: prefs.DefaultContainer,
Language: prefs.DefaultLanguage,
Status: domain.StatusDownloading,
Title: entry.Title,
FileSize: format.Filesize,
}
h.setActiveJob(userID, job)
h.runDownload(ctx, job, 0)
// Wait for active job to complete
for {
j := h.getActiveJob(userID)
if j == nil || j.Status == domain.StatusCompleted || j.Status == domain.StatusFailed || j.Status == domain.StatusCancelled {
break
}
time.Sleep(500 * time.Millisecond)
}
}
h.sendText(ctx, chatID, "All playlist downloads completed.")
}
func countSelected(state *domain.PlaylistState) int {
count := 0
for _, s := range state.Selected {
if s {
count++
}
}
return count
}

View File

@@ -0,0 +1,246 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/go-telegram/bot/models"
"uptodownBot/internal/domain"
)
func (h *Handler) buildSettingsKeyboard(userID int64, prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
mediaTypeLabel := "Video+Audio"
switch prefs.DefaultMediaType {
case domain.MediaTypeAudio:
mediaTypeLabel = "Audio Only"
case domain.MediaTypeVideo:
mediaTypeLabel = "Video Only"
}
qualityLabel := prefs.DefaultQuality
if qualityLabel == "" {
qualityLabel = "best"
}
containerLabel := prefs.DefaultContainer
if containerLabel == "" {
containerLabel = "mp4"
}
langLabel := prefs.DefaultLanguage
if langLabel == "" {
langLabel = "Default"
}
rows := [][]models.InlineKeyboardButton{
{
{Text: fmt.Sprintf("Type: %s", mediaTypeLabel), CallbackData: "settings_media_type"},
{Text: fmt.Sprintf("Quality: %s", qualityLabel), CallbackData: "settings_quality"},
},
{
{Text: fmt.Sprintf("Container: %s", containerLabel), CallbackData: "settings_container"},
{Text: fmt.Sprintf("Language: %s", langLabel), CallbackData: "settings_language"},
},
{{Text: "Delete my data", CallbackData: "delaccount_prompt"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
}
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
defer h.answerCb(ctx, cbID)
prefs, err := h.Repo.GetOrCreatePreferences(userID)
if err != nil {
return
}
text, kb := h.buildSettingsKeyboard(userID, prefs)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, userID int64) {
prefs, err := h.Repo.GetOrCreatePreferences(userID)
if err != nil {
return
}
text, kb := h.buildSettingsKeyboard(userID, prefs)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) handleSettingsSelection(ctx context.Context, chatID int64, msgID int, userID int64, setting string) {
prefs, err := h.Repo.GetOrCreatePreferences(userID)
if err != nil {
return
}
switch setting {
case "media_type":
text, kb := h.buildMediaTypePicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "quality":
text, kb := h.buildQualityPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "container":
text, kb := h.buildContainerPicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
case "language":
text, kb := h.buildLanguagePicker(prefs)
h.editText(ctx, chatID, msgID, text, &kb)
}
}
func (h *Handler) buildMediaTypePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
options := []struct {
label string
value domain.MediaType
data string
}{
{"Audio Only", domain.MediaTypeAudio, "settings_set_type_audio"},
{"Video Only", domain.MediaTypeVideo, "settings_set_type_video"},
{"Video+Audio", domain.MediaTypeVideoAudio, "settings_set_type_both"},
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range options {
label := opt.label
if opt.value == prefs.DefaultMediaType {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select default media type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildQualityPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
qualities := []string{"best", "2160p", "1080p", "720p", "480p", "360p"}
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, q := range qualities {
label := q
if q == prefs.DefaultQuality {
label = "> " + label
}
row = append(row, models.InlineKeyboardButton{
Text: label, CallbackData: "settings_set_quality_" + q,
})
if len(row) == 3 || i == len(qualities)-1 {
rows = append(rows, row)
row = nil
}
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select default quality:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildContainerPicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
containers := []string{"mp4", "mkv", "webm", "mp3", "m4a", "opus"}
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, c := range containers {
label := c
if c == prefs.DefaultContainer {
label = "> " + label
}
row = append(row, models.InlineKeyboardButton{
Text: label, CallbackData: "settings_set_container_" + c,
})
if len(row) == 3 || i == len(containers)-1 {
rows = append(rows, row)
row = nil
}
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select default container:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) buildLanguagePicker(prefs *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
languages := []string{"", "en", "es", "ja", "ko", "fr", "de", "zh", "ar", "pt", "ru"}
langLabels := map[string]string{
"": "Default", "en": "English", "es": "Spanish", "ja": "Japanese",
"ko": "Korean", "fr": "French", "de": "German", "zh": "Chinese",
"ar": "Arabic", "pt": "Portuguese", "ru": "Russian",
}
rows := [][]models.InlineKeyboardButton{}
row := []models.InlineKeyboardButton{}
for i, l := range languages {
label := langLabels[l]
if l == prefs.DefaultLanguage {
label = "> " + label
}
row = append(row, models.InlineKeyboardButton{
Text: label, CallbackData: "settings_set_language_" + l,
})
if len(row) == 2 || i == len(languages)-1 {
rows = append(rows, row)
row = nil
}
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select default language:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) handleSettingsSet(ctx context.Context, chatID int64, msgID int, userID int64, setting string) {
parts := strings.SplitN(setting, "_", 2)
if len(parts) < 2 {
return
}
action := parts[0]
value := parts[1]
prefs, err := h.Repo.GetOrCreatePreferences(userID)
if err != nil {
return
}
changed := false
switch action {
case "type":
switch value {
case "audio":
prefs.DefaultMediaType = domain.MediaTypeAudio
case "video":
prefs.DefaultMediaType = domain.MediaTypeVideo
case "both":
prefs.DefaultMediaType = domain.MediaTypeVideoAudio
}
changed = true
case "quality":
prefs.DefaultQuality = value
changed = true
case "container":
prefs.DefaultContainer = value
changed = true
case "language":
prefs.DefaultLanguage = value
changed = true
}
if changed {
_ = h.Repo.UpdatePreferences(userID, prefs)
}
h.backToSettings(ctx, chatID, msgID, userID)
}
// deleteAccountPrompt shows the deletion confirmation prompt.
func (h *Handler) deleteAccountPrompt(ctx context.Context, chatID int64, msgID int, userID int64) {
kb := deleteConfirmKeyboard(userID)
h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (preferences, quota, history).\nAre you sure?", &kb)
}
// deleteAccountConfirm permanently deletes all user data.
func (h *Handler) deleteAccountConfirm(ctx context.Context, chatID int64, msgID int, userID int64) {
if err := h.Repo.DeleteUserData(userID); err != nil {
slog.Error("delete user data", "user_id", userID, "error", err)
h.editText(ctx, chatID, msgID, "Error deleting data. Please try again.", nil)
return
}
h.editText(ctx, chatID, msgID, "All your data has been deleted. You can start fresh by sending /start.", nil)
}