diff --git a/internal/bot/telegram/telegram.go b/internal/bot/telegram/telegram.go index 4a399a9..3b28967 100644 --- a/internal/bot/telegram/telegram.go +++ b/internal/bot/telegram/telegram.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strconv" "strings" "sync" @@ -12,6 +13,8 @@ import ( "github.com/shopspring/decimal" ) +const alertsPageSize = 5 + // Usecase defines the business logic operations required by the bot. type Usecase interface { RegisterNewUser(ctx context.Context, user *entities.User) error @@ -199,6 +202,7 @@ func (b *Bot) handleCommand(ctx context.Context, tgID entities.TelegramID, chatI func (b *Bot) handleCallback(ctx context.Context, cb *tgbotapi.CallbackQuery) { tgID := entities.TelegramID(cb.From.ID) chatID := cb.Message.Chat.ID + messageID := cb.Message.MessageID // Acknowledge the callback immediately so the button stops spinning. b.api.Request(tgbotapi.NewCallback(cb.ID, "")) //nolint:errcheck @@ -208,17 +212,47 @@ func (b *Bot) handleCallback(ctx context.Context, cb *tgbotapi.CallbackQuery) { case strings.HasPrefix(data, "instrument:"): instrID := entities.InstrumentID(strings.TrimPrefix(data, "instrument:")) b.handleInstrumentSelected(ctx, tgID, chatID, instrID) + + case strings.HasPrefix(data, "alerts_page:"), strings.HasPrefix(data, "alerts_back:"): + rest := data[strings.Index(data, ":")+1:] + page, _ := strconv.Atoi(rest) + b.handleAlertsPage(ctx, tgID, chatID, messageID, page) + + case strings.HasPrefix(data, "alert_select:"): + // format: alert_select:: + rest := strings.TrimPrefix(data, "alert_select:") + idx := strings.LastIndex(rest, ":") + alertID := entities.AlertID(rest[:idx]) + page, _ := strconv.Atoi(rest[idx+1:]) + b.handleAlertSelect(ctx, chatID, messageID, alertID, page) + case strings.HasPrefix(data, "edit_alert:"): alertID := entities.AlertID(strings.TrimPrefix(data, "edit_alert:")) b.handleEditAlertStart(tgID, chatID, alertID) + case strings.HasPrefix(data, "remove_alert:"): - alertID := entities.AlertID(strings.TrimPrefix(data, "remove_alert:")) - b.handleRemoveAlertConfirm(chatID, alertID) + // format: remove_alert:: + rest := strings.TrimPrefix(data, "remove_alert:") + idx := strings.LastIndex(rest, ":") + alertID := entities.AlertID(rest[:idx]) + page, _ := strconv.Atoi(rest[idx+1:]) + b.handleRemoveAlertConfirm(chatID, messageID, alertID, page) + case strings.HasPrefix(data, "confirm_remove:"): - alertID := entities.AlertID(strings.TrimPrefix(data, "confirm_remove:")) - b.handleRemoveAlertDo(ctx, chatID, alertID) - case data == "cancel_remove": - b.sendMenu(chatID, "Removal cancelled.") + // format: confirm_remove:: + rest := strings.TrimPrefix(data, "confirm_remove:") + idx := strings.LastIndex(rest, ":") + alertID := entities.AlertID(rest[:idx]) + page, _ := strconv.Atoi(rest[idx+1:]) + b.handleRemoveAlertDo(ctx, tgID, chatID, messageID, alertID, page) + + case strings.HasPrefix(data, "cancel_remove:"): + // format: cancel_remove:: + rest := strings.TrimPrefix(data, "cancel_remove:") + idx := strings.LastIndex(rest, ":") + alertID := entities.AlertID(rest[:idx]) + page, _ := strconv.Atoi(rest[idx+1:]) + b.handleAlertSelect(ctx, chatID, messageID, alertID, page) } } @@ -296,7 +330,7 @@ func (b *Bot) cmdMyAlerts(ctx context.Context, tgID entities.TelegramID, chatID return } - alerts, err := b.usecase.Alerts(ctx, user.ID, 0, 20) + alerts, err := b.usecase.Alerts(ctx, user.ID, 0, 100) if err != nil { b.log.Error("failed to get alerts", "err", err) b.sendMenu(chatID, "Failed to load alerts.") @@ -307,22 +341,119 @@ func (b *Bot) cmdMyAlerts(ctx context.Context, tgID entities.TelegramID, chatID return } - b.sendMenu(chatID, fmt.Sprintf("Your active alerts (%d):", len(alerts))) + text, kb := buildAlertsPage(alerts, 0) + msg := tgbotapi.NewMessage(chatID, text) + msg.ReplyMarkup = kb + b.sendMsg(msg) +} - for _, alert := range alerts { - text := fmt.Sprintf("%s/%s — %s %s", +// buildAlertsPage constructs the message text and inline keyboard for a paginated alerts list. +func buildAlertsPage(alerts []entities.Alert, page int) (string, tgbotapi.InlineKeyboardMarkup) { + total := len(alerts) + totalPages := (total + alertsPageSize - 1) / alertsPageSize + + start := page * alertsPageSize + end := start + alertsPageSize + if end > total { + end = total + } + pageAlerts := alerts[start:end] + + var sb strings.Builder + fmt.Fprintf(&sb, "Your active alerts (page %d/%d):\n\n", page+1, totalPages) + for i, alert := range pageAlerts { + fmt.Fprintf(&sb, "%d. %s/%s %s %s\n", + start+i+1, alert.Instrument.BaseCurrency, alert.Instrument.QuoteCurrency, alert.Condition, alert.Price.String(), ) - row := tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Edit price", fmt.Sprintf("edit_alert:%s", alert.ID)), - tgbotapi.NewInlineKeyboardButtonData("Remove", fmt.Sprintf("remove_alert:%s", alert.ID)), - ) - msg := tgbotapi.NewMessage(chatID, text) - msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(row) - b.sendMsg(msg) + } + + var rows [][]tgbotapi.InlineKeyboardButton + + // One button per alert on this page. + var alertRow []tgbotapi.InlineKeyboardButton + for i, alert := range pageAlerts { + alertRow = append(alertRow, tgbotapi.NewInlineKeyboardButtonData( + strconv.Itoa(start+i+1), + fmt.Sprintf("alert_select:%s:%d", alert.ID, page), + )) + } + if len(alertRow) > 0 { + rows = append(rows, alertRow) + } + + // Prev / Next navigation. + var navRow []tgbotapi.InlineKeyboardButton + if page > 0 { + navRow = append(navRow, tgbotapi.NewInlineKeyboardButtonData("◀", fmt.Sprintf("alerts_page:%d", page-1))) + } + if end < total { + navRow = append(navRow, tgbotapi.NewInlineKeyboardButtonData("▶", fmt.Sprintf("alerts_page:%d", page+1))) + } + if len(navRow) > 0 { + rows = append(rows, navRow) + } + + return sb.String(), tgbotapi.NewInlineKeyboardMarkup(rows...) +} + +// handleAlertsPage re-fetches alerts and edits the existing message to show the requested page. +func (b *Bot) handleAlertsPage(ctx context.Context, tgID entities.TelegramID, chatID int64, messageID int, page int) { + user, err := b.requireUser(ctx, tgID, chatID) + if err != nil { + return + } + + alerts, err := b.usecase.Alerts(ctx, user.ID, 0, 100) + if err != nil { + b.log.Error("failed to get alerts", "err", err) + return + } + if len(alerts) == 0 { + b.editMsgText(chatID, messageID, "You have no active alerts.") + return + } + + // Clamp page to valid range (e.g. after last alert on a page is removed). + totalPages := (len(alerts) + alertsPageSize - 1) / alertsPageSize + if page >= totalPages { + page = totalPages - 1 + } + + text, kb := buildAlertsPage(alerts, page) + edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, messageID, text, kb) + if _, err := b.api.Send(edit); err != nil { + b.log.Error("failed to edit message", "err", err) + } +} + +// handleAlertSelect edits the message to show the chosen alert with Edit / Remove / Back buttons. +func (b *Bot) handleAlertSelect(ctx context.Context, chatID int64, messageID int, alertID entities.AlertID, page int) { + alert, err := b.usecase.Alert(ctx, alertID) + if err != nil { + b.log.Error("failed to get alert", "alert_id", alertID, "err", err) + return + } + + text := fmt.Sprintf("%s/%s\nCondition: %s %s", + alert.Instrument.BaseCurrency, + alert.Instrument.QuoteCurrency, + alert.Condition, + alert.Price.String(), + ) + + kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Edit price", fmt.Sprintf("edit_alert:%s", alertID)), + tgbotapi.NewInlineKeyboardButtonData("Remove", fmt.Sprintf("remove_alert:%s:%d", alertID, page)), + tgbotapi.NewInlineKeyboardButtonData("◀ Back", fmt.Sprintf("alerts_back:%d", page)), + )) + + edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, messageID, text, kb) + if _, err := b.api.Send(edit); err != nil { + b.log.Error("failed to edit message", "err", err) } } @@ -470,26 +601,27 @@ func (b *Bot) handleEditAlertPrice(ctx context.Context, tgID entities.TelegramID b.sendMenu(chatID, fmt.Sprintf("Alert price updated to %s.", price.String())) } -// handleRemoveAlertConfirm asks the user to confirm deletion. -func (b *Bot) handleRemoveAlertConfirm(chatID int64, alertID entities.AlertID) { - row := tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Yes, remove", fmt.Sprintf("confirm_remove:%s", alertID)), - tgbotapi.NewInlineKeyboardButtonData("Cancel", "cancel_remove"), - ) - msg := tgbotapi.NewMessage(chatID, "Are you sure you want to remove this alert?") - msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(row) - b.sendMsg(msg) +// handleRemoveAlertConfirm edits the message to show a confirmation prompt. +func (b *Bot) handleRemoveAlertConfirm(chatID int64, messageID int, alertID entities.AlertID, page int) { + kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Yes, remove", fmt.Sprintf("confirm_remove:%s:%d", alertID, page)), + tgbotapi.NewInlineKeyboardButtonData("Cancel", fmt.Sprintf("cancel_remove:%s:%d", alertID, page)), + )) + edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, messageID, "Are you sure you want to remove this alert?", kb) + if _, err := b.api.Send(edit); err != nil { + b.log.Error("failed to edit message", "err", err) + } } -// handleRemoveAlertDo deletes the alert after confirmation. -func (b *Bot) handleRemoveAlertDo(ctx context.Context, chatID int64, alertID entities.AlertID) { +// handleRemoveAlertDo deletes the alert and refreshes the list in the same message. +func (b *Bot) handleRemoveAlertDo(ctx context.Context, tgID entities.TelegramID, chatID int64, messageID int, alertID entities.AlertID, page int) { if err := b.usecase.RemoveAlert(ctx, alertID); err != nil { b.log.Error("failed to remove alert", "alert_id", alertID, "err", err) - b.sendMenu(chatID, "Failed to remove alert.") + b.editMsgText(chatID, messageID, "Failed to remove alert.") return } b.alerter.RemoveAlert(alertID) - b.sendMenu(chatID, "Alert removed.") + b.handleAlertsPage(ctx, tgID, chatID, messageID, page) } // --- Helpers --- @@ -557,3 +689,11 @@ func (b *Bot) sendMsg(msg tgbotapi.MessageConfig) { b.log.Error("failed to send message", "chat_id", msg.ChatID, "err", err) } } + +// editMsgText edits only the text of an existing message (removes inline keyboard). +func (b *Bot) editMsgText(chatID int64, messageID int, text string) { + edit := tgbotapi.NewEditMessageText(chatID, messageID, text) + if _, err := b.api.Send(edit); err != nil { + b.log.Error("failed to edit message", "err", err) + } +} diff --git a/internal/provider/bybit/request.go b/internal/provider/bybit/request.go index b03437d..761da1a 100644 --- a/internal/provider/bybit/request.go +++ b/internal/provider/bybit/request.go @@ -79,8 +79,6 @@ func (b *Bybit) getRequest(ctx context.Context, endPoint string, params any) ([] queryString = query.Encode() } - fmt.Println("req:", b.cfg.BaseURL+endPoint+"?"+queryString) - // make request request, err := http.NewRequest("GET", b.cfg.BaseURL+endPoint+"?"+queryString, nil) if err != nil { diff --git a/internal/repository/postgresql/instrument.go b/internal/repository/postgresql/instrument.go index cf5ab10..ccd4a04 100644 --- a/internal/repository/postgresql/instrument.go +++ b/internal/repository/postgresql/instrument.go @@ -12,7 +12,7 @@ select i.id, c_base.symbol, c_quote.symbol from instrument i join currency c_base on c_base.id = i.base_currency_id join currency c_quote on c_quote.id = i.quoted_currency_id -order by i.id +order by i.id desc offset $1 limit $2` func (p *Postgresql) InstrumentList(ctx context.Context, offset, limit int) ([]entities.Instrument, error) {