package telegram import ( "context" "fmt" "log/slog" "strconv" "strings" "sync" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/entities" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/provider" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/shopspring/decimal" ) const ( alertsPageSize = 5 instrPageSize = 4 ) // Usecase defines the business logic operations required by the bot. type Usecase interface { RegisterNewUser(ctx context.Context, user *entities.User) error UserByID(ctx context.Context, userID entities.UserID) (*entities.User, error) UserByTgID(ctx context.Context, telegramID entities.TelegramID) (*entities.User, error) InstrumentList(ctx context.Context, userID entities.UserID, offset, limit int) ([]entities.Instrument, error) AddUserInstrument(ctx context.Context, userID entities.UserID, base, quote string) (*entities.Instrument, error) RemoveUserInstrument(ctx context.Context, userID entities.UserID, instrumentID entities.InstrumentID) error CreateAlert(ctx context.Context, alert *entities.Alert) (entities.AlertID, error) Alert(ctx context.Context, alertID entities.AlertID) (*entities.Alert, error) Alerts(ctx context.Context, userID entities.UserID, offset, limit int) ([]entities.Alert, error) RemoveAlert(ctx context.Context, alertID entities.AlertID) error UpdateAlertPrice(ctx context.Context, alertID entities.AlertID, price decimal.Decimal) error } // Alerter keeps the in-memory alert cache in sync with created/removed alerts. type Alerter interface { AddAlert(alert *entities.Alert) RemoveAlert(id entities.AlertID) } // PriceProvider fetches the current market price for an instrument and validates pairs. type PriceProvider interface { Price(ctx context.Context, instrument entities.Instrument) (*entities.Price, error) InstrumentExists(ctx context.Context, base, quote string) (bool, error) } // Reply keyboard button labels. const ( btnAddAlert = "Add Alert" btnMyAlerts = "My Alerts" btnInstruments = "Instruments" btnAddPair = "Add Pair" ) type flowStep string const ( stepAddAlertPrice flowStep = "add_alert_price" stepAddAlertAwaitType flowStep = "add_alert_await_type" // price entered, waiting for type callback stepEditAlertPrice flowStep = "edit_alert_price" stepAddPair flowStep = "add_pair" ) type userState struct { step flowStep instrument entities.Instrument // set during add_alert flow currentPrice *entities.Price // fetched when instrument is selected targetPrice decimal.Decimal // set after price is entered, before type selection alertID entities.AlertID // set during edit flow } // alertTimeframe pairs a KlineInterval with its display label. type alertTimeframe struct { interval provider.KlineInterval label string } // offeredTimeframes are the timeframes shown to the user when creating a candle-close alert. var offeredTimeframes = []alertTimeframe{ {provider.Kline1m, "1m"}, {provider.Kline5m, "5m"}, {provider.Kline15m, "15m"}, {provider.Kline1H, "1H"}, {provider.Kline4H, "4H"}, {provider.Kline1D, "1D"}, {provider.Kline1W, "1W"}, } // Bot is the Telegram bot handling all user interactions. type Bot struct { api *tgbotapi.BotAPI log *slog.Logger usecase Usecase alerter Alerter provider PriceProvider states map[entities.TelegramID]*userState mu sync.Mutex } // New creates and initialises a new Bot. Call SetAlerter before Run to enable cache sync. func New(token string, log *slog.Logger, uc Usecase, pp PriceProvider) (*Bot, error) { api, err := tgbotapi.NewBotAPI(token) if err != nil { return nil, fmt.Errorf("failed to create bot: %w", err) } return &Bot{ api: api, log: log, usecase: uc, provider: pp, states: make(map[entities.TelegramID]*userState), }, nil } // SetAlerter injects the alerter after construction, resolving the circular dependency. func (b *Bot) SetAlerter(al Alerter) { b.alerter = al } // Run starts the update loop and blocks until ctx is cancelled. func (b *Bot) Run(ctx context.Context) { u := tgbotapi.NewUpdate(0) u.Timeout = 60 updates := b.api.GetUpdatesChan(u) b.log.Info("bot started", "username", b.api.Self.UserName) for { select { case <-ctx.Done(): b.api.StopReceivingUpdates() return case update, ok := <-updates: if !ok { return } go b.handleUpdate(ctx, update) } } } // NotifyAlert implements alerter.Notifier. Called by the alerter service when a price threshold is hit. func (b *Bot) NotifyAlert(ctx context.Context, userID entities.UserID, alert *entities.Alert, currentPrice decimal.Decimal) error { user, err := b.usecase.UserByID(ctx, userID) if err != nil { return fmt.Errorf("failed to get user: %w", err) } text := fmt.Sprintf( "Alert triggered!\n\n%s/%s %s %s%s\nClose price: %s", alert.Instrument.BaseCurrency, alert.Instrument.QuoteCurrency, formatCondition(alert.Condition), alert.Price.String(), formatTimeframeSuffix(alert), currentPrice.String(), ) msg := tgbotapi.NewMessage(int64(user.TelegramID), text) kb := menuKeyboard() kb.ResizeKeyboard = true msg.ReplyMarkup = kb _, err = b.api.Send(msg) return err } // formatCondition returns a human-readable alert condition label. func formatCondition(c entities.AlertCondition) string { switch c { case entities.AlertConditionAbove: return "above" case entities.AlertConditionBelow: return "below" case entities.AlertConditionCloseAbove: return "close above" case entities.AlertConditionCloseBelow: return "close below" default: return string(c) } } // formatTimeframeSuffix returns " (4H)" for candle-close alerts, empty string otherwise. func formatTimeframeSuffix(alert *entities.Alert) string { if alert.Timeframe == "" { return "" } return fmt.Sprintf(" (%s)", alert.Timeframe) } // --- Routing --- func (b *Bot) handleUpdate(ctx context.Context, update tgbotapi.Update) { switch { case update.Message != nil: b.handleMessage(ctx, update.Message) case update.CallbackQuery != nil: b.handleCallback(ctx, update.CallbackQuery) } } func (b *Bot) handleMessage(ctx context.Context, msg *tgbotapi.Message) { tgID := entities.TelegramID(msg.From.ID) chatID := msg.Chat.ID if msg.IsCommand() { b.handleCommand(ctx, tgID, chatID, msg.Command()) return } // Map persistent reply-keyboard buttons to actions. switch msg.Text { case btnAddAlert: b.cmdAddAlert(ctx, tgID, chatID) return case btnMyAlerts: b.cmdMyAlerts(ctx, tgID, chatID) return case btnInstruments: b.cmdInstruments(ctx, tgID, chatID) return case btnAddPair: b.cmdAddPair(ctx, tgID, chatID) return } // Route plain-text input to the active multi-step flow. state := b.getState(tgID) switch state.step { case stepAddAlertPrice: b.handleAddAlertPrice(ctx, tgID, chatID, msg.Text, state) case stepAddAlertAwaitType: b.send(chatID, "Please select the alert type using the buttons above.") case stepEditAlertPrice: b.handleEditAlertPrice(ctx, tgID, chatID, msg.Text, state) case stepAddPair: b.handleAddPairInput(ctx, tgID, chatID, msg.Text) default: b.sendMenu(chatID, "Use the menu below or /add_alert to set a price alert.") } } func (b *Bot) handleCommand(ctx context.Context, tgID entities.TelegramID, chatID int64, cmd string) { switch cmd { case "start": b.cmdStart(ctx, tgID, chatID) case "instruments": b.cmdInstruments(ctx, tgID, chatID) case "add_alert": b.cmdAddAlert(ctx, tgID, chatID) case "my_alerts": b.cmdMyAlerts(ctx, tgID, chatID) case "add_pair": b.cmdAddPair(ctx, tgID, chatID) case "cancel": b.cmdCancel(tgID, chatID) default: b.sendMenu(chatID, "Unknown command.\n\nAvailable commands:\n/start — register\n/instruments — list trading pairs\n/add_alert — create a price alert\n/my_alerts — view your alerts\n/add_pair — add a custom trading pair\n/cancel — cancel current operation") } } 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 data := cb.Data switch { case strings.HasPrefix(data, "instrument:"): instrID := entities.InstrumentID(strings.TrimPrefix(data, "instrument:")) b.handleInstrumentSelected(ctx, tgID, chatID, instrID) case strings.HasPrefix(data, "add_alert_instr_page:"): page, _ := strconv.Atoi(strings.TrimPrefix(data, "add_alert_instr_page:")) b.handleAddAlertInstrPage(ctx, tgID, chatID, messageID, page) case strings.HasPrefix(data, "instr_page:"): page, _ := strconv.Atoi(strings.TrimPrefix(data, "instr_page:")) b.handleInstrumentsPage(ctx, tgID, chatID, messageID, page) case strings.HasPrefix(data, "instr_select:"): // format: instr_select:: rest := strings.TrimPrefix(data, "instr_select:") idx := strings.LastIndex(rest, ":") instrID := entities.InstrumentID(rest[:idx]) page, _ := strconv.Atoi(rest[idx+1:]) b.handleInstrumentSelect(ctx, tgID, chatID, messageID, instrID, page) case strings.HasPrefix(data, "instr_remove:"): // format: instr_remove:: rest := strings.TrimPrefix(data, "instr_remove:") idx := strings.LastIndex(rest, ":") instrID := entities.InstrumentID(rest[:idx]) page, _ := strconv.Atoi(rest[idx+1:]) b.handleInstrumentRemoveConfirm(chatID, messageID, instrID, page) case strings.HasPrefix(data, "confirm_remove_instr:"): // format: confirm_remove_instr:: rest := strings.TrimPrefix(data, "confirm_remove_instr:") idx := strings.LastIndex(rest, ":") instrID := entities.InstrumentID(rest[:idx]) page, _ := strconv.Atoi(rest[idx+1:]) b.handleInstrumentRemoveDo(ctx, tgID, chatID, messageID, instrID, page) case strings.HasPrefix(data, "cancel_remove_instr:"): // format: cancel_remove_instr:: rest := strings.TrimPrefix(data, "cancel_remove_instr:") idx := strings.LastIndex(rest, ":") instrID := entities.InstrumentID(rest[:idx]) page, _ := strconv.Atoi(rest[idx+1:]) b.handleInstrumentSelect(ctx, tgID, chatID, messageID, instrID, page) 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:"): // 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:"): // 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) case data == "alert_type:crossing": b.handleAlertTypeCrossing(ctx, tgID, chatID) case data == "alert_type:close": b.handleAlertTypeClose(tgID, chatID) case strings.HasPrefix(data, "alert_timeframe:"): tf := provider.KlineInterval(strings.TrimPrefix(data, "alert_timeframe:")) b.handleAlertTimeframe(ctx, tgID, chatID, tf) } } // --- Command handlers --- func (b *Bot) cmdStart(ctx context.Context, tgID entities.TelegramID, chatID int64) { // If already registered, just greet them. if _, err := b.usecase.UserByTgID(ctx, tgID); err == nil { b.sendMenu(chatID, "Welcome back! Use the menu below to manage your alerts.") return } user := &entities.User{TelegramID: tgID} if err := b.usecase.RegisterNewUser(ctx, user); err != nil { b.log.Error("failed to register user", "tg_id", tgID, "err", err) b.sendMenu(chatID, "Registration failed. Please try again.") return } b.sendMenu(chatID, "Welcome! You are now registered.\n\nUse the menu below to get started.") } func (b *Bot) cmdInstruments(ctx context.Context, tgID entities.TelegramID, chatID int64) { user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } instruments, err := b.usecase.InstrumentList(ctx, user.ID, 0, 200) if err != nil { b.log.Error("failed to list instruments", "err", err) b.sendMenu(chatID, "Failed to load instruments.") return } if len(instruments) == 0 { b.sendMenu(chatID, "No instruments available.") return } text, kb := buildInstrumentsPage(instruments, 0) msg := tgbotapi.NewMessage(chatID, text) msg.ReplyMarkup = kb b.sendMsg(msg) } func (b *Bot) cmdAddAlert(ctx context.Context, tgID entities.TelegramID, chatID int64) { user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } instruments, err := b.usecase.InstrumentList(ctx, user.ID, 0, 200) if err != nil { b.log.Error("failed to list instruments", "err", err) b.sendMenu(chatID, "Failed to load instruments.") return } if len(instruments) == 0 { b.sendMenu(chatID, "No instruments available. Use \"Add Pair\" to add one.") return } text, kb := buildAddAlertInstrPage(instruments, 0) msg := tgbotapi.NewMessage(chatID, text) msg.ReplyMarkup = kb b.sendMsg(msg) } func (b *Bot) cmdMyAlerts(ctx context.Context, tgID entities.TelegramID, chatID int64) { 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) b.sendMenu(chatID, "Failed to load alerts.") return } if len(alerts) == 0 { b.sendMenu(chatID, "You have no active alerts. Use \"Add Alert\" to create one.") return } text, kb := buildAlertsPage(alerts, 0) msg := tgbotapi.NewMessage(chatID, text) msg.ReplyMarkup = kb b.sendMsg(msg) } func (b *Bot) cmdAddPair(ctx context.Context, tgID entities.TelegramID, chatID int64) { if _, err := b.requireUser(ctx, tgID, chatID); err != nil { return } b.setState(tgID, &userState{step: stepAddPair}) b.send(chatID, "Enter the trading pair in BASE/QUOTE format (e.g. DOGE/USDT):") } func (b *Bot) cmdCancel(tgID entities.TelegramID, chatID int64) { b.setState(tgID, &userState{}) b.sendMenu(chatID, "Operation cancelled.") } // --- Instruments pagination --- // buildInstrumentsPage constructs a paginated instruments list. // Numbered buttons let users open a detail view; ◀ ▶ navigate pages. func buildInstrumentsPage(instruments []entities.Instrument, page int) (string, tgbotapi.InlineKeyboardMarkup) { total := len(instruments) totalPages := (total + instrPageSize - 1) / instrPageSize start := page * instrPageSize end := start + instrPageSize if end > total { end = total } pageItems := instruments[start:end] var sb strings.Builder fmt.Fprintf(&sb, "Trading pairs (page %d/%d):\n\n", page+1, totalPages) for i, instr := range pageItems { marker := "" if !instr.IsGlobal { marker = " ✦" // marks user-added pairs } fmt.Fprintf(&sb, "%d. %s/%s%s\n", start+i+1, instr.BaseCurrency, instr.QuoteCurrency, marker) } var rows [][]tgbotapi.InlineKeyboardButton // One button per instrument on this page. var itemRow []tgbotapi.InlineKeyboardButton for i, instr := range pageItems { itemRow = append(itemRow, tgbotapi.NewInlineKeyboardButtonData( strconv.Itoa(start+i+1), fmt.Sprintf("instr_select:%s:%d", instr.ID, page), )) } if len(itemRow) > 0 { rows = append(rows, itemRow) } var navRow []tgbotapi.InlineKeyboardButton if page > 0 { navRow = append(navRow, tgbotapi.NewInlineKeyboardButtonData("◀", fmt.Sprintf("instr_page:%d", page-1))) } if end < total { navRow = append(navRow, tgbotapi.NewInlineKeyboardButtonData("▶", fmt.Sprintf("instr_page:%d", page+1))) } if len(navRow) > 0 { rows = append(rows, navRow) } return sb.String(), tgbotapi.NewInlineKeyboardMarkup(rows...) } // handleInstrumentsPage re-fetches instruments and edits the message to show the requested page. func (b *Bot) handleInstrumentsPage(ctx context.Context, tgID entities.TelegramID, chatID int64, messageID int, page int) { user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } instruments, err := b.usecase.InstrumentList(ctx, user.ID, 0, 200) if err != nil { b.log.Error("failed to list instruments", "err", err) return } totalPages := (len(instruments) + instrPageSize - 1) / instrPageSize if page >= totalPages { page = totalPages - 1 } text, kb := buildInstrumentsPage(instruments, 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) } } // handleInstrumentSelect shows instrument detail with a Remove button (for user-added pairs). func (b *Bot) handleInstrumentSelect(ctx context.Context, tgID entities.TelegramID, chatID int64, messageID int, instrID entities.InstrumentID, page int) { user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } instruments, err := b.usecase.InstrumentList(ctx, user.ID, 0, 200) if err != nil { b.log.Error("failed to list instruments", "err", err) return } var selected entities.Instrument for _, instr := range instruments { if instr.ID == instrID { selected = instr break } } if selected.ID == "" { b.editMsgText(chatID, messageID, "Instrument not found.") return } text := fmt.Sprintf("%s/%s", selected.BaseCurrency, selected.QuoteCurrency) if !selected.IsGlobal { text += "\n(user-added pair)" } var btns []tgbotapi.InlineKeyboardButton if !selected.IsGlobal { btns = append(btns, tgbotapi.NewInlineKeyboardButtonData( "Remove", fmt.Sprintf("instr_remove:%s:%d", instrID, page), )) } btns = append(btns, tgbotapi.NewInlineKeyboardButtonData( "◀ Back", fmt.Sprintf("instr_page:%d", page), )) edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, messageID, text, tgbotapi.NewInlineKeyboardMarkup(btns)) if _, err := b.api.Send(edit); err != nil { b.log.Error("failed to edit message", "err", err) } } // handleInstrumentRemoveConfirm asks the user to confirm removal. func (b *Bot) handleInstrumentRemoveConfirm(chatID int64, messageID int, instrID entities.InstrumentID, page int) { kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Yes, remove", fmt.Sprintf("confirm_remove_instr:%s:%d", instrID, page)), tgbotapi.NewInlineKeyboardButtonData("Cancel", fmt.Sprintf("cancel_remove_instr:%s:%d", instrID, page)), )) edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, messageID, "Remove this pair from your list?", kb) if _, err := b.api.Send(edit); err != nil { b.log.Error("failed to edit message", "err", err) } } // handleInstrumentRemoveDo removes the instrument from the user's list and refreshes the page. func (b *Bot) handleInstrumentRemoveDo(ctx context.Context, tgID entities.TelegramID, chatID int64, messageID int, instrID entities.InstrumentID, page int) { user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } if err := b.usecase.RemoveUserInstrument(ctx, user.ID, instrID); err != nil { b.log.Error("failed to remove user instrument", "instrument_id", instrID, "err", err) b.editMsgText(chatID, messageID, "Failed to remove pair.") return } b.handleInstrumentsPage(ctx, tgID, chatID, messageID, page) } // buildAddAlertInstrPage builds the instrument selection keyboard for the add-alert flow. func buildAddAlertInstrPage(instruments []entities.Instrument, page int) (string, tgbotapi.InlineKeyboardMarkup) { total := len(instruments) totalPages := (total + instrPageSize - 1) / instrPageSize start := page * instrPageSize end := start + instrPageSize if end > total { end = total } pageItems := instruments[start:end] text := fmt.Sprintf("Select a trading pair (page %d/%d):", page+1, totalPages) var rows [][]tgbotapi.InlineKeyboardButton for _, instr := range pageItems { btn := tgbotapi.NewInlineKeyboardButtonData( fmt.Sprintf("%s/%s", instr.BaseCurrency, instr.QuoteCurrency), fmt.Sprintf("instrument:%s", instr.ID), ) rows = append(rows, tgbotapi.NewInlineKeyboardRow(btn)) } var navRow []tgbotapi.InlineKeyboardButton if page > 0 { navRow = append(navRow, tgbotapi.NewInlineKeyboardButtonData("◀", fmt.Sprintf("add_alert_instr_page:%d", page-1))) } if end < total { navRow = append(navRow, tgbotapi.NewInlineKeyboardButtonData("▶", fmt.Sprintf("add_alert_instr_page:%d", page+1))) } if len(navRow) > 0 { rows = append(rows, navRow) } return text, tgbotapi.NewInlineKeyboardMarkup(rows...) } // handleAddAlertInstrPage edits the instrument selection message to show a different page. func (b *Bot) handleAddAlertInstrPage(ctx context.Context, tgID entities.TelegramID, chatID int64, messageID int, page int) { user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } instruments, err := b.usecase.InstrumentList(ctx, user.ID, 0, 200) if err != nil { b.log.Error("failed to list instruments", "err", err) return } totalPages := (len(instruments) + instrPageSize - 1) / instrPageSize if page >= totalPages { page = totalPages - 1 } text, kb := buildAddAlertInstrPage(instruments, 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) } } // --- Alerts pagination --- // 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%s\n", start+i+1, alert.Instrument.BaseCurrency, alert.Instrument.QuoteCurrency, formatCondition(alert.Condition), alert.Price.String(), formatTimeframeSuffix(&alert), ) } 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%s", alert.Instrument.BaseCurrency, alert.Instrument.QuoteCurrency, formatCondition(alert.Condition), alert.Price.String(), formatTimeframeSuffix(alert), ) 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) } } // --- Multi-step flow handlers --- // handleInstrumentSelected fetches the current price, stores it in state, and prompts for a target price. func (b *Bot) handleInstrumentSelected(ctx context.Context, tgID entities.TelegramID, chatID int64, instrID entities.InstrumentID) { user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } instruments, err := b.usecase.InstrumentList(ctx, user.ID, 0, 200) if err != nil { b.log.Error("failed to list instruments", "err", err) b.sendMenu(chatID, "Failed to load instruments.") return } var selected entities.Instrument for _, instr := range instruments { if instr.ID == instrID { selected = instr break } } if selected.ID == "" { b.sendMenu(chatID, "Unknown instrument. Please try again.") return } state := &userState{ step: stepAddAlertPrice, instrument: selected, } price, err := b.provider.Price(ctx, selected) if err != nil { b.log.Error("failed to fetch current price", "instrument", selected.ID, "err", err) b.setState(tgID, state) b.send(chatID, fmt.Sprintf("Selected: %s/%s\n\nCould not fetch current price. Enter your target price:", selected.BaseCurrency, selected.QuoteCurrency)) return } state.currentPrice = price b.setState(tgID, state) b.send(chatID, fmt.Sprintf( "Selected: %s/%s\n\nCurrent price:\n Ask: %s\n Bid: %s\n\nEnter your target price:", selected.BaseCurrency, selected.QuoteCurrency, price.Ask.String(), price.Bid.String(), )) } // handleAddAlertPrice parses the target price, stores it in state, then asks for the alert type. func (b *Bot) handleAddAlertPrice(ctx context.Context, tgID entities.TelegramID, chatID int64, text string, state *userState) { target, err := decimal.NewFromString(strings.TrimSpace(text)) if err != nil || !target.IsPositive() { b.send(chatID, "Invalid price. Please enter a positive number (e.g. 50000.5):") return } // Re-fetch price if missing (e.g. failed earlier). if state.currentPrice == nil { state.currentPrice, err = b.provider.Price(ctx, state.instrument) if err != nil { b.log.Error("failed to fetch current price", "instrument", state.instrument.ID, "err", err) b.send(chatID, "Could not fetch current price. Please try again:") return } } state.targetPrice = target state.step = stepAddAlertAwaitType b.setState(tgID, state) kb := tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Price crossing", "alert_type:crossing"), tgbotapi.NewInlineKeyboardButtonData("Candle close", "alert_type:close"), ), ) msg := tgbotapi.NewMessage(chatID, fmt.Sprintf("Target price: %s\n\nSelect alert type:", target.String())) msg.ReplyMarkup = kb b.sendMsg(msg) } // handleAlertTypeCrossing creates a standard crossing alert (High/Low vs target). func (b *Bot) handleAlertTypeCrossing(ctx context.Context, tgID entities.TelegramID, chatID int64) { state := b.getState(tgID) if state.step != stepAddAlertAwaitType { return } condition := b.detectCondition(state) user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } alert := &entities.Alert{ UserID: user.ID, Price: state.targetPrice, Condition: condition, Instrument: state.instrument, } id, err := b.usecase.CreateAlert(ctx, alert) if err != nil { b.log.Error("failed to create alert", "err", err) b.sendMenu(chatID, "Failed to create alert. Please try again.") b.setState(tgID, &userState{}) return } alert.ID = id b.alerter.AddAlert(alert) b.setState(tgID, &userState{}) b.sendMenu(chatID, fmt.Sprintf( "Alert created!\n\n%s/%s %s %s\n\nYou will be notified when the price crosses your target.", state.instrument.BaseCurrency, state.instrument.QuoteCurrency, formatCondition(condition), state.targetPrice.String(), )) } // handleAlertTypeClose shows a timeframe selection keyboard for candle-close alerts. func (b *Bot) handleAlertTypeClose(tgID entities.TelegramID, chatID int64) { state := b.getState(tgID) if state.step != stepAddAlertAwaitType { return } // Build one row of timeframe buttons. var row []tgbotapi.InlineKeyboardButton for _, tf := range offeredTimeframes { row = append(row, tgbotapi.NewInlineKeyboardButtonData( tf.label, fmt.Sprintf("alert_timeframe:%s", tf.interval), )) } msg := tgbotapi.NewMessage(chatID, "Select the candle timeframe:") msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(row) b.sendMsg(msg) } // handleAlertTimeframe creates a candle-close alert for the selected timeframe. func (b *Bot) handleAlertTimeframe(ctx context.Context, tgID entities.TelegramID, chatID int64, tf provider.KlineInterval) { state := b.getState(tgID) if state.step != stepAddAlertAwaitType { return } condition := b.detectCloseCondition(state) user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } alert := &entities.Alert{ UserID: user.ID, Price: state.targetPrice, Condition: condition, Instrument: state.instrument, Timeframe: string(tf), } id, err := b.usecase.CreateAlert(ctx, alert) if err != nil { b.log.Error("failed to create alert", "err", err) b.sendMenu(chatID, "Failed to create alert. Please try again.") b.setState(tgID, &userState{}) return } alert.ID = id b.alerter.AddAlert(alert) b.setState(tgID, &userState{}) b.sendMenu(chatID, fmt.Sprintf( "Alert created!\n\n%s/%s %s %s (%s)\n\nYou will be notified when a %s candle closes %s the target.", state.instrument.BaseCurrency, state.instrument.QuoteCurrency, formatCondition(condition), state.targetPrice.String(), string(tf), string(tf), closeDirectionWord(condition), )) } // handleAddPairInput processes the user-entered trading pair symbol. func (b *Bot) handleAddPairInput(ctx context.Context, tgID entities.TelegramID, chatID int64, text string) { user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } parts := strings.SplitN(strings.TrimSpace(text), "/", 2) if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { b.send(chatID, "Invalid format. Please enter the pair as BASE/QUOTE (e.g. DOGE/USDT):") return } base := strings.ToUpper(strings.TrimSpace(parts[0])) quote := strings.ToUpper(strings.TrimSpace(parts[1])) exists, err := b.provider.InstrumentExists(ctx, base, quote) if err != nil { b.log.Error("failed to check instrument existence", "base", base, "quote", quote, "err", err) b.sendMenu(chatID, "Could not verify the trading pair. Please try again later.") b.setState(tgID, &userState{}) return } if !exists { b.send(chatID, fmt.Sprintf("Pair %s/%s is not available on Bybit. Please enter a valid pair:", base, quote)) return } instr, err := b.usecase.AddUserInstrument(ctx, user.ID, base, quote) if err != nil { b.log.Error("failed to add user instrument", "err", err) b.sendMenu(chatID, "Failed to add the pair. Please try again.") b.setState(tgID, &userState{}) return } b.setState(tgID, &userState{}) b.sendMenu(chatID, fmt.Sprintf("Pair %s/%s has been added to your list!", instr.BaseCurrency, instr.QuoteCurrency)) } // detectCondition returns above/below based on target vs current ask. func (b *Bot) detectCondition(state *userState) entities.AlertCondition { if state.currentPrice != nil && state.targetPrice.GreaterThanOrEqual(state.currentPrice.Ask) { return entities.AlertConditionAbove } return entities.AlertConditionBelow } // detectCloseCondition returns close_above/close_below based on target vs current ask. func (b *Bot) detectCloseCondition(state *userState) entities.AlertCondition { if state.currentPrice != nil && state.targetPrice.GreaterThanOrEqual(state.currentPrice.Ask) { return entities.AlertConditionCloseAbove } return entities.AlertConditionCloseBelow } func closeDirectionWord(c entities.AlertCondition) string { if c == entities.AlertConditionCloseAbove { return "above" } return "below" } // handleEditAlertStart begins the edit flow for a specific alert. func (b *Bot) handleEditAlertStart(tgID entities.TelegramID, chatID int64, alertID entities.AlertID) { b.setState(tgID, &userState{ step: stepEditAlertPrice, alertID: alertID, }) b.send(chatID, "Enter the new target price:") } // handleEditAlertPrice applies the new price entered by the user. func (b *Bot) handleEditAlertPrice(ctx context.Context, tgID entities.TelegramID, chatID int64, text string, state *userState) { price, err := decimal.NewFromString(strings.TrimSpace(text)) if err != nil || !price.IsPositive() { b.send(chatID, "Invalid price. Please enter a positive number:") return } if err := b.usecase.UpdateAlertPrice(ctx, state.alertID, price); err != nil { b.log.Error("failed to update alert price", "alert_id", state.alertID, "err", err) b.sendMenu(chatID, "Failed to update alert.") b.setState(tgID, &userState{}) return } // Refresh the alerter cache: remove stale entry and re-add with updated price. b.alerter.RemoveAlert(state.alertID) if updated, err := b.usecase.Alert(ctx, state.alertID); err == nil { b.alerter.AddAlert(updated) } b.setState(tgID, &userState{}) b.sendMenu(chatID, fmt.Sprintf("Alert price updated to %s.", price.String())) } // 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 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.editMsgText(chatID, messageID, "Failed to remove alert.") return } b.alerter.RemoveAlert(alertID) b.handleAlertsPage(ctx, tgID, chatID, messageID, page) } // --- Helpers --- func (b *Bot) requireUser(ctx context.Context, tgID entities.TelegramID, chatID int64) (*entities.User, error) { user, err := b.usecase.UserByTgID(ctx, tgID) if err != nil { b.sendMenu(chatID, "You are not registered. Please send /start first.") return nil, err } return user, nil } func (b *Bot) getState(tgID entities.TelegramID) *userState { b.mu.Lock() defer b.mu.Unlock() if s, ok := b.states[tgID]; ok { return s } s := &userState{} b.states[tgID] = s return s } func (b *Bot) setState(tgID entities.TelegramID, state *userState) { b.mu.Lock() defer b.mu.Unlock() b.states[tgID] = state } // menuKeyboard returns the persistent reply keyboard shown at the bottom of the chat. func menuKeyboard() tgbotapi.ReplyKeyboardMarkup { return tgbotapi.NewReplyKeyboard( tgbotapi.NewKeyboardButtonRow( tgbotapi.NewKeyboardButton(btnAddAlert), tgbotapi.NewKeyboardButton(btnMyAlerts), ), tgbotapi.NewKeyboardButtonRow( tgbotapi.NewKeyboardButton(btnInstruments), tgbotapi.NewKeyboardButton(btnAddPair), ), ) } // sendMenu sends a text message and (re-)attaches the persistent menu keyboard. func (b *Bot) sendMenu(chatID int64, text string) { msg := tgbotapi.NewMessage(chatID, text) kb := menuKeyboard() kb.ResizeKeyboard = true msg.ReplyMarkup = kb if _, err := b.api.Send(msg); err != nil { b.log.Error("failed to send message", "chat_id", chatID, "err", err) } } // send sends a plain text message without altering the keyboard. func (b *Bot) send(chatID int64, text string) { if _, err := b.api.Send(tgbotapi.NewMessage(chatID, text)); err != nil { b.log.Error("failed to send message", "chat_id", chatID, "err", err) } } // sendMsg sends a pre-built MessageConfig. func (b *Bot) sendMsg(msg tgbotapi.MessageConfig) { if _, err := b.api.Send(msg); err != nil { 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) } }