package telegram import ( "context" "fmt" "log/slog" "strings" "sync" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/entities" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/shopspring/decimal" ) // 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, offset, limit int) ([]entities.Instrument, 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. type PriceProvider interface { Price(ctx context.Context, instrument entities.Instrument) (*entities.Price, error) } // Reply keyboard button labels. const ( btnAddAlert = "Add Alert" btnMyAlerts = "My Alerts" btnInstruments = "Instruments" ) type flowStep string const ( stepAddAlertPrice flowStep = "add_alert_price" stepEditAlertPrice flowStep = "edit_alert_price" ) type userState struct { step flowStep instrument entities.Instrument // set during add_alert flow currentPrice *entities.Price // fetched when instrument is selected alertID entities.AlertID // set during edit flow } // 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\nCurrent price: %s", alert.Instrument.BaseCurrency, alert.Instrument.QuoteCurrency, alert.Condition, alert.Price.String(), currentPrice.String(), ) msg := tgbotapi.NewMessage(int64(user.TelegramID), text) kb := menuKeyboard() kb.ResizeKeyboard = true msg.ReplyMarkup = kb _, err = b.api.Send(msg) return err } // --- 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 } // 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 stepEditAlertPrice: b.handleEditAlertPrice(ctx, tgID, chatID, msg.Text, state) 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 "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/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 // 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, "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) 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.") } } // --- 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, _ entities.TelegramID, chatID int64) { instruments, err := b.usecase.InstrumentList(ctx, 0, 50) 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 } var sb strings.Builder sb.WriteString("Available trading pairs:\n\n") for _, instr := range instruments { fmt.Fprintf(&sb, "- %s/%s\n", instr.BaseCurrency, instr.QuoteCurrency) } b.sendMenu(chatID, sb.String()) } func (b *Bot) cmdAddAlert(ctx context.Context, tgID entities.TelegramID, chatID int64) { if _, err := b.requireUser(ctx, tgID, chatID); err != nil { return } instruments, err := b.usecase.InstrumentList(ctx, 0, 50) 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 } var rows [][]tgbotapi.InlineKeyboardButton for _, instr := range instruments { btn := tgbotapi.NewInlineKeyboardButtonData( fmt.Sprintf("%s/%s", instr.BaseCurrency, instr.QuoteCurrency), fmt.Sprintf("instrument:%s", instr.ID), ) rows = append(rows, tgbotapi.NewInlineKeyboardRow(btn)) } msg := tgbotapi.NewMessage(chatID, "Select a trading pair:") msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(rows...) 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, 20) 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 } b.sendMenu(chatID, fmt.Sprintf("Your active alerts (%d):", len(alerts))) for _, alert := range alerts { text := fmt.Sprintf("%s/%s — %s %s", 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) } } func (b *Bot) cmdCancel(tgID entities.TelegramID, chatID int64) { b.setState(tgID, &userState{}) b.sendMenu(chatID, "Operation cancelled.") } // --- 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) { instruments, err := b.usecase.InstrumentList(ctx, 0, 50) 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, auto-determines the condition from the current // market price (target >= ask → above, target < ask → below), and creates the alert. 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 } // Use the price already fetched at instrument-selection time; re-fetch only if missing. currentPrice := state.currentPrice if currentPrice == nil { 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.currentPrice = currentPrice b.setState(tgID, state) } // Determine condition automatically: above ask or below ask. var condition entities.AlertCondition if target.GreaterThanOrEqual(currentPrice.Ask) { condition = entities.AlertConditionAbove } else { condition = entities.AlertConditionBelow } user, err := b.requireUser(ctx, tgID, chatID) if err != nil { return } alert := &entities.Alert{ UserID: user.ID, Price: target, 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 reaches your target.", state.instrument.BaseCurrency, state.instrument.QuoteCurrency, condition, target.String(), )) } // 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 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) } // handleRemoveAlertDo deletes the alert after confirmation. func (b *Bot) handleRemoveAlertDo(ctx context.Context, chatID int64, alertID entities.AlertID) { 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.") return } b.alerter.RemoveAlert(alertID) b.sendMenu(chatID, "Alert removed.") } // --- 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), ), ) } // 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) } }