crypto_alert_bot/internal/bot/telegram/telegram.go
2026-04-28 11:42:52 +03:00

1178 lines
38 KiB
Go

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:<instrID>:<page>
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:<instrID>:<page>
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:<instrID>:<page>
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:<instrID>:<page>
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:<alertID>:<page>
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:<alertID>:<page>
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:<alertID>:<page>
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:<alertID>:<page>
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)
}
}