user instruments

This commit is contained in:
yash 2026-04-28 11:42:07 +03:00
parent dd03cae0f3
commit abb2411af7
9 changed files with 494 additions and 48 deletions

View file

@ -14,14 +14,19 @@ import (
"github.com/shopspring/decimal"
)
const alertsPageSize = 5
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, offset, limit int) ([]entities.Instrument, 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)
@ -35,9 +40,10 @@ type Alerter interface {
RemoveAlert(id entities.AlertID)
}
// PriceProvider fetches the current market price for an instrument.
// 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.
@ -45,6 +51,7 @@ const (
btnAddAlert = "Add Alert"
btnMyAlerts = "My Alerts"
btnInstruments = "Instruments"
btnAddPair = "Add Pair"
)
type flowStep string
@ -53,6 +60,7 @@ 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 {
@ -213,6 +221,9 @@ func (b *Bot) handleMessage(ctx context.Context, msg *tgbotapi.Message) {
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.
@ -224,6 +235,8 @@ func (b *Bot) handleMessage(ctx context.Context, msg *tgbotapi.Message) {
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.")
}
@ -239,10 +252,12 @@ func (b *Bot) handleCommand(ctx context.Context, tgID entities.TelegramID, chatI
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/cancel — cancel current operation")
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")
}
}
@ -260,6 +275,46 @@ func (b *Bot) handleCallback(ctx context.Context, cb *tgbotapi.CallbackQuery) {
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)
@ -331,8 +386,13 @@ func (b *Bot) cmdStart(ctx context.Context, tgID entities.TelegramID, chatID int
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)
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.")
@ -343,41 +403,32 @@ func (b *Bot) cmdInstruments(ctx context.Context, _ entities.TelegramID, chatID
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())
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) {
if _, err := b.requireUser(ctx, tgID, chatID); err != nil {
user, err := b.requireUser(ctx, tgID, chatID)
if err != nil {
return
}
instruments, err := b.usecase.InstrumentList(ctx, 0, 50)
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.")
b.sendMenu(chatID, "No instruments available. Use \"Add Pair\" to add one.")
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...)
text, kb := buildAddAlertInstrPage(instruments, 0)
msg := tgbotapi.NewMessage(chatID, text)
msg.ReplyMarkup = kb
b.sendMsg(msg)
}
@ -404,6 +455,236 @@ func (b *Bot) cmdMyAlerts(ctx context.Context, tgID entities.TelegramID, chatID
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)
@ -516,16 +797,16 @@ func (b *Bot) handleAlertSelect(ctx context.Context, chatID int64, messageID int
}
}
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)
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.")
@ -701,6 +982,46 @@ func (b *Bot) handleAlertTimeframe(ctx context.Context, tgID entities.TelegramID
))
}
// 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) {
@ -818,6 +1139,7 @@ func menuKeyboard() tgbotapi.ReplyKeyboardMarkup {
),
tgbotapi.NewKeyboardButtonRow(
tgbotapi.NewKeyboardButton(btnInstruments),
tgbotapi.NewKeyboardButton(btnAddPair),
),
)
}