user instruments
This commit is contained in:
parent
dd03cae0f3
commit
abb2411af7
9 changed files with 494 additions and 48 deletions
|
|
@ -14,14 +14,19 @@ import (
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
const alertsPageSize = 5
|
const (
|
||||||
|
alertsPageSize = 5
|
||||||
|
instrPageSize = 4
|
||||||
|
)
|
||||||
|
|
||||||
// Usecase defines the business logic operations required by the bot.
|
// Usecase defines the business logic operations required by the bot.
|
||||||
type Usecase interface {
|
type Usecase interface {
|
||||||
RegisterNewUser(ctx context.Context, user *entities.User) error
|
RegisterNewUser(ctx context.Context, user *entities.User) error
|
||||||
UserByID(ctx context.Context, userID entities.UserID) (*entities.User, error)
|
UserByID(ctx context.Context, userID entities.UserID) (*entities.User, error)
|
||||||
UserByTgID(ctx context.Context, telegramID entities.TelegramID) (*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)
|
CreateAlert(ctx context.Context, alert *entities.Alert) (entities.AlertID, error)
|
||||||
Alert(ctx context.Context, alertID entities.AlertID) (*entities.Alert, error)
|
Alert(ctx context.Context, alertID entities.AlertID) (*entities.Alert, error)
|
||||||
Alerts(ctx context.Context, userID entities.UserID, offset, limit int) ([]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)
|
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 {
|
type PriceProvider interface {
|
||||||
Price(ctx context.Context, instrument entities.Instrument) (*entities.Price, error)
|
Price(ctx context.Context, instrument entities.Instrument) (*entities.Price, error)
|
||||||
|
InstrumentExists(ctx context.Context, base, quote string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reply keyboard button labels.
|
// Reply keyboard button labels.
|
||||||
|
|
@ -45,6 +51,7 @@ const (
|
||||||
btnAddAlert = "Add Alert"
|
btnAddAlert = "Add Alert"
|
||||||
btnMyAlerts = "My Alerts"
|
btnMyAlerts = "My Alerts"
|
||||||
btnInstruments = "Instruments"
|
btnInstruments = "Instruments"
|
||||||
|
btnAddPair = "Add Pair"
|
||||||
)
|
)
|
||||||
|
|
||||||
type flowStep string
|
type flowStep string
|
||||||
|
|
@ -53,6 +60,7 @@ const (
|
||||||
stepAddAlertPrice flowStep = "add_alert_price"
|
stepAddAlertPrice flowStep = "add_alert_price"
|
||||||
stepAddAlertAwaitType flowStep = "add_alert_await_type" // price entered, waiting for type callback
|
stepAddAlertAwaitType flowStep = "add_alert_await_type" // price entered, waiting for type callback
|
||||||
stepEditAlertPrice flowStep = "edit_alert_price"
|
stepEditAlertPrice flowStep = "edit_alert_price"
|
||||||
|
stepAddPair flowStep = "add_pair"
|
||||||
)
|
)
|
||||||
|
|
||||||
type userState struct {
|
type userState struct {
|
||||||
|
|
@ -213,6 +221,9 @@ func (b *Bot) handleMessage(ctx context.Context, msg *tgbotapi.Message) {
|
||||||
case btnInstruments:
|
case btnInstruments:
|
||||||
b.cmdInstruments(ctx, tgID, chatID)
|
b.cmdInstruments(ctx, tgID, chatID)
|
||||||
return
|
return
|
||||||
|
case btnAddPair:
|
||||||
|
b.cmdAddPair(ctx, tgID, chatID)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route plain-text input to the active multi-step flow.
|
// 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.")
|
b.send(chatID, "Please select the alert type using the buttons above.")
|
||||||
case stepEditAlertPrice:
|
case stepEditAlertPrice:
|
||||||
b.handleEditAlertPrice(ctx, tgID, chatID, msg.Text, state)
|
b.handleEditAlertPrice(ctx, tgID, chatID, msg.Text, state)
|
||||||
|
case stepAddPair:
|
||||||
|
b.handleAddPairInput(ctx, tgID, chatID, msg.Text)
|
||||||
default:
|
default:
|
||||||
b.sendMenu(chatID, "Use the menu below or /add_alert to set a price alert.")
|
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)
|
b.cmdAddAlert(ctx, tgID, chatID)
|
||||||
case "my_alerts":
|
case "my_alerts":
|
||||||
b.cmdMyAlerts(ctx, tgID, chatID)
|
b.cmdMyAlerts(ctx, tgID, chatID)
|
||||||
|
case "add_pair":
|
||||||
|
b.cmdAddPair(ctx, tgID, chatID)
|
||||||
case "cancel":
|
case "cancel":
|
||||||
b.cmdCancel(tgID, chatID)
|
b.cmdCancel(tgID, chatID)
|
||||||
default:
|
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:"))
|
instrID := entities.InstrumentID(strings.TrimPrefix(data, "instrument:"))
|
||||||
b.handleInstrumentSelected(ctx, tgID, chatID, instrID)
|
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:"):
|
case strings.HasPrefix(data, "alerts_page:"), strings.HasPrefix(data, "alerts_back:"):
|
||||||
rest := data[strings.Index(data, ":")+1:]
|
rest := data[strings.Index(data, ":")+1:]
|
||||||
page, _ := strconv.Atoi(rest)
|
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.")
|
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) {
|
func (b *Bot) cmdInstruments(ctx context.Context, tgID entities.TelegramID, chatID int64) {
|
||||||
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 {
|
if err != nil {
|
||||||
b.log.Error("failed to list instruments", "err", err)
|
b.log.Error("failed to list instruments", "err", err)
|
||||||
b.sendMenu(chatID, "Failed to load instruments.")
|
b.sendMenu(chatID, "Failed to load instruments.")
|
||||||
|
|
@ -343,41 +403,32 @@ func (b *Bot) cmdInstruments(ctx context.Context, _ entities.TelegramID, chatID
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
text, kb := buildInstrumentsPage(instruments, 0)
|
||||||
sb.WriteString("Available trading pairs:\n\n")
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
for _, instr := range instruments {
|
msg.ReplyMarkup = kb
|
||||||
fmt.Fprintf(&sb, "- %s/%s\n", instr.BaseCurrency, instr.QuoteCurrency)
|
b.sendMsg(msg)
|
||||||
}
|
|
||||||
b.sendMenu(chatID, sb.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) cmdAddAlert(ctx context.Context, tgID entities.TelegramID, chatID int64) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
instruments, err := b.usecase.InstrumentList(ctx, 0, 50)
|
instruments, err := b.usecase.InstrumentList(ctx, user.ID, 0, 200)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Error("failed to list instruments", "err", err)
|
b.log.Error("failed to list instruments", "err", err)
|
||||||
b.sendMenu(chatID, "Failed to load instruments.")
|
b.sendMenu(chatID, "Failed to load instruments.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(instruments) == 0 {
|
if len(instruments) == 0 {
|
||||||
b.sendMenu(chatID, "No instruments available.")
|
b.sendMenu(chatID, "No instruments available. Use \"Add Pair\" to add one.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows [][]tgbotapi.InlineKeyboardButton
|
text, kb := buildAddAlertInstrPage(instruments, 0)
|
||||||
for _, instr := range instruments {
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
btn := tgbotapi.NewInlineKeyboardButtonData(
|
msg.ReplyMarkup = kb
|
||||||
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)
|
b.sendMsg(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -404,6 +455,236 @@ func (b *Bot) cmdMyAlerts(ctx context.Context, tgID entities.TelegramID, chatID
|
||||||
b.sendMsg(msg)
|
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.
|
// buildAlertsPage constructs the message text and inline keyboard for a paginated alerts list.
|
||||||
func buildAlertsPage(alerts []entities.Alert, page int) (string, tgbotapi.InlineKeyboardMarkup) {
|
func buildAlertsPage(alerts []entities.Alert, page int) (string, tgbotapi.InlineKeyboardMarkup) {
|
||||||
total := len(alerts)
|
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 ---
|
// --- Multi-step flow handlers ---
|
||||||
|
|
||||||
// handleInstrumentSelected fetches the current price, stores it in state, and prompts for a target price.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
b.log.Error("failed to list instruments", "err", err)
|
b.log.Error("failed to list instruments", "err", err)
|
||||||
b.sendMenu(chatID, "Failed to load instruments.")
|
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.
|
// detectCondition returns above/below based on target vs current ask.
|
||||||
func (b *Bot) detectCondition(state *userState) entities.AlertCondition {
|
func (b *Bot) detectCondition(state *userState) entities.AlertCondition {
|
||||||
if state.currentPrice != nil && state.targetPrice.GreaterThanOrEqual(state.currentPrice.Ask) {
|
if state.currentPrice != nil && state.targetPrice.GreaterThanOrEqual(state.currentPrice.Ask) {
|
||||||
|
|
@ -818,6 +1139,7 @@ func menuKeyboard() tgbotapi.ReplyKeyboardMarkup {
|
||||||
),
|
),
|
||||||
tgbotapi.NewKeyboardButtonRow(
|
tgbotapi.NewKeyboardButtonRow(
|
||||||
tgbotapi.NewKeyboardButton(btnInstruments),
|
tgbotapi.NewKeyboardButton(btnInstruments),
|
||||||
|
tgbotapi.NewKeyboardButton(btnAddPair),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@ type Instrument struct {
|
||||||
ID InstrumentID
|
ID InstrumentID
|
||||||
BaseCurrency string // base currency of the pair. e.g. BTC.
|
BaseCurrency string // base currency of the pair. e.g. BTC.
|
||||||
QuoteCurrency string // quote currency of the pair. e.g. USDT.
|
QuoteCurrency string // quote currency of the pair. e.g. USDT.
|
||||||
|
IsGlobal bool // true for pre-seeded pairs visible to all users.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package bybit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -100,6 +101,28 @@ func intervalBybit(interval provider.KlineInterval) (string, error) {
|
||||||
return i, nil
|
return i, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InstrumentExists reports whether base/quote is a valid spot pair on Bybit.
|
||||||
|
// A non-zero retCode (e.g. unknown symbol) is treated as "not found" — only
|
||||||
|
// actual transport / parse failures propagate as errors.
|
||||||
|
func (b *Bybit) InstrumentExists(ctx context.Context, base, quote string) (bool, error) {
|
||||||
|
req := marketOrderbookReq{
|
||||||
|
Category: categorySpot,
|
||||||
|
Symbol: fmt.Sprintf("%s%s", base, quote),
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := b.getRequest(ctx, "/v5/market/orderbook", req)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check instrument existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp response
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return false, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.RetCode == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Candles returns OHLC candles for the given interval in the [from, to) range.
|
// Candles returns OHLC candles for the given interval in the [from, to) range.
|
||||||
// It paginates automatically when the range exceeds klineLimit candles per request.
|
// It paginates automatically when the range exceeds klineLimit candles per request.
|
||||||
func (b *Bybit) Candles(ctx context.Context, instrument entities.Instrument, from, to time.Time, interval provider.KlineInterval) ([]entities.Candle, error) {
|
func (b *Bybit) Candles(ctx context.Context, instrument entities.Instrument, from, to time.Time, interval provider.KlineInterval) ([]entities.Candle, error) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ type Provider interface {
|
||||||
// The implementation handles pagination automatically when the range exceeds one
|
// The implementation handles pagination automatically when the range exceeds one
|
||||||
// request's capacity.
|
// request's capacity.
|
||||||
Candles(ctx context.Context, instrument entities.Instrument, from, to time.Time, interval KlineInterval) ([]entities.Candle, error)
|
Candles(ctx context.Context, instrument entities.Instrument, from, to time.Time, interval KlineInterval) ([]entities.Candle, error)
|
||||||
|
|
||||||
|
// InstrumentExists reports whether the trading pair base/quote is listed on this provider.
|
||||||
|
// Returns (false, nil) when the symbol is simply not found (as opposed to a network error).
|
||||||
|
InstrumentExists(ctx context.Context, base, quote string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type KlineInterval string
|
type KlineInterval string
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,20 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const instrumentListQuery = `
|
const instrumentListQuery = `
|
||||||
select i.id, c_base.symbol, c_quote.symbol
|
select i.id, c_base.symbol, c_quote.symbol, i.is_global
|
||||||
from instrument i
|
from instrument i
|
||||||
join currency c_base on c_base.id = i.base_currency_id
|
join currency c_base on c_base.id = i.base_currency_id
|
||||||
join currency c_quote on c_quote.id = i.quoted_currency_id
|
join currency c_quote on c_quote.id = i.quoted_currency_id
|
||||||
order by i.id desc
|
where i.is_global = true
|
||||||
offset $1 limit $2`
|
or exists (
|
||||||
|
select 1 from user_instrument ui
|
||||||
|
where ui.instrument_id = i.id and ui.user_id = $1
|
||||||
|
)
|
||||||
|
order by i.is_global desc, i.id asc
|
||||||
|
offset $2 limit $3`
|
||||||
|
|
||||||
func (p *Postgresql) InstrumentList(ctx context.Context, offset, limit int) ([]entities.Instrument, error) {
|
func (p *Postgresql) InstrumentList(ctx context.Context, userID entities.UserID, offset, limit int) ([]entities.Instrument, error) {
|
||||||
rows, err := p.db.Query(ctx, instrumentListQuery, offset, limit)
|
rows, err := p.db.Query(ctx, instrumentListQuery, userID, offset, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to exec instrumentListQuery: %w", err)
|
return nil, fmt.Errorf("failed to exec instrumentListQuery: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +30,7 @@ func (p *Postgresql) InstrumentList(ctx context.Context, offset, limit int) ([]e
|
||||||
var instruments []entities.Instrument
|
var instruments []entities.Instrument
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var inst entities.Instrument
|
var inst entities.Instrument
|
||||||
if err := rows.Scan(&inst.ID, &inst.BaseCurrency, &inst.QuoteCurrency); err != nil {
|
if err := rows.Scan(&inst.ID, &inst.BaseCurrency, &inst.QuoteCurrency, &inst.IsGlobal); err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan instrument row: %w", err)
|
return nil, fmt.Errorf("failed to scan instrument row: %w", err)
|
||||||
}
|
}
|
||||||
instruments = append(instruments, inst)
|
instruments = append(instruments, inst)
|
||||||
|
|
@ -34,21 +39,62 @@ func (p *Postgresql) InstrumentList(ctx context.Context, offset, limit int) ([]e
|
||||||
return instruments, nil
|
return instruments, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createInstrumentQuery upserts both currency symbols then the instrument itself.
|
||||||
|
// It always returns an ID — the newly inserted row or the existing one on conflict.
|
||||||
const createInstrumentQuery = `
|
const createInstrumentQuery = `
|
||||||
insert into instrument(base_currency_id, quoted_currency_id)
|
with upsert_base as (
|
||||||
values (
|
insert into currency(symbol) values($1)
|
||||||
(select id from currency where symbol = $1),
|
on conflict(symbol) do update set symbol = excluded.symbol
|
||||||
(select id from currency where symbol = $2)
|
returning id
|
||||||
|
),
|
||||||
|
upsert_quote as (
|
||||||
|
insert into currency(symbol) values($2)
|
||||||
|
on conflict(symbol) do update set symbol = excluded.symbol
|
||||||
|
returning id
|
||||||
|
),
|
||||||
|
ins as (
|
||||||
|
insert into instrument(base_currency_id, quoted_currency_id, is_global)
|
||||||
|
select upsert_base.id, upsert_quote.id, false
|
||||||
|
from upsert_base, upsert_quote
|
||||||
|
on conflict (base_currency_id, quoted_currency_id) do nothing
|
||||||
|
returning id
|
||||||
)
|
)
|
||||||
returning id`
|
select coalesce(
|
||||||
|
(select id from ins),
|
||||||
|
(select i.id from instrument i
|
||||||
|
where i.base_currency_id = (select id from upsert_base)
|
||||||
|
and i.quoted_currency_id = (select id from upsert_quote))
|
||||||
|
)`
|
||||||
|
|
||||||
func (p *Postgresql) CreateInstrument(ctx context.Context, instrument *entities.Instrument) (entities.InstrumentID, error) {
|
func (p *Postgresql) CreateInstrument(ctx context.Context, instrument *entities.Instrument) (entities.InstrumentID, error) {
|
||||||
var id entities.InstrumentID
|
var id entities.InstrumentID
|
||||||
|
|
||||||
err := p.db.QueryRow(ctx, createInstrumentQuery, instrument.BaseCurrency, instrument.QuoteCurrency).Scan(&id)
|
err := p.db.QueryRow(ctx, createInstrumentQuery, instrument.BaseCurrency, instrument.QuoteCurrency).Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to exec createInstrumentQuery: %w", err)
|
return "", fmt.Errorf("failed to exec createInstrumentQuery: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addUserInstrumentQuery = `
|
||||||
|
insert into user_instrument(user_id, instrument_id)
|
||||||
|
values ($1, $2)
|
||||||
|
on conflict (user_id, instrument_id) do nothing`
|
||||||
|
|
||||||
|
func (p *Postgresql) AddUserInstrument(ctx context.Context, userID entities.UserID, instrumentID entities.InstrumentID) error {
|
||||||
|
_, err := p.db.Exec(ctx, addUserInstrumentQuery, userID, instrumentID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to exec addUserInstrumentQuery: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUserInstrumentQuery = `
|
||||||
|
delete from user_instrument where user_id = $1 and instrument_id = $2`
|
||||||
|
|
||||||
|
func (p *Postgresql) RemoveUserInstrument(ctx context.Context, userID entities.UserID, instrumentID entities.InstrumentID) error {
|
||||||
|
_, err := p.db.Exec(ctx, removeUserInstrumentQuery, userID, instrumentID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to exec removeUserInstrumentQuery: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
drop table if exists user_instrument;
|
||||||
|
alter table instrument drop column if exists is_global;
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
alter table instrument add column is_global bool not null default false;
|
||||||
|
update instrument set is_global = true;
|
||||||
|
|
||||||
|
create table user_instrument (
|
||||||
|
user_id uuid references users(id) not null,
|
||||||
|
instrument_id uuid references instrument(id) not null,
|
||||||
|
primary key (user_id, instrument_id)
|
||||||
|
);
|
||||||
|
|
@ -13,8 +13,16 @@ type Storage interface {
|
||||||
UserByID(ctx context.Context, id entities.UserID) (*entities.User, error)
|
UserByID(ctx context.Context, id entities.UserID) (*entities.User, error)
|
||||||
UserByTelegramID(ctx context.Context, tgID entities.TelegramID) (*entities.User, error)
|
UserByTelegramID(ctx context.Context, tgID entities.TelegramID) (*entities.User, error)
|
||||||
|
|
||||||
InstrumentList(ctx context.Context, offset, limit int) ([]entities.Instrument, error)
|
// InstrumentList returns instruments visible to userID: global ones plus any
|
||||||
|
// the user has explicitly added.
|
||||||
|
InstrumentList(ctx context.Context, userID entities.UserID, offset, limit int) ([]entities.Instrument, error)
|
||||||
|
// CreateInstrument upserts the instrument (and its currencies) and returns the ID,
|
||||||
|
// whether the row was just created or already existed.
|
||||||
CreateInstrument(ctx context.Context, instrument *entities.Instrument) (entities.InstrumentID, error)
|
CreateInstrument(ctx context.Context, instrument *entities.Instrument) (entities.InstrumentID, error)
|
||||||
|
// AddUserInstrument links an instrument to a user (idempotent).
|
||||||
|
AddUserInstrument(ctx context.Context, userID entities.UserID, instrumentID entities.InstrumentID) error
|
||||||
|
// RemoveUserInstrument removes a user's link to a non-global instrument.
|
||||||
|
RemoveUserInstrument(ctx context.Context, userID entities.UserID, instrumentID entities.InstrumentID) error
|
||||||
|
|
||||||
SaveAlert(ctx context.Context, alert *entities.Alert) (entities.AlertID, error)
|
SaveAlert(ctx context.Context, alert *entities.Alert) (entities.AlertID, error)
|
||||||
AllActiveAlerts(ctx context.Context) ([]entities.Alert, error)
|
AllActiveAlerts(ctx context.Context) ([]entities.Alert, error)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ package usecase
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/entities"
|
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/entities"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (uc *Usecase) InstrumentList(ctx context.Context, offset, limit int) ([]entities.Instrument, error) {
|
func (uc *Usecase) InstrumentList(ctx context.Context, userID entities.UserID, offset, limit int) ([]entities.Instrument, error) {
|
||||||
instruments, err := uc.storage.InstrumentList(ctx, offset, limit)
|
instruments, err := uc.storage.InstrumentList(ctx, userID, offset, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
uc.log.Error("failed to list instruments", "offset", offset, "limit", limit, "err", err)
|
uc.log.Error("failed to list instruments", "offset", offset, "limit", limit, "err", err)
|
||||||
return nil, fmt.Errorf("failed to list instruments: %w", err)
|
return nil, fmt.Errorf("failed to list instruments: %w", err)
|
||||||
|
|
@ -26,3 +27,34 @@ func (uc *Usecase) CreateInstrument(ctx context.Context, instrument *entities.In
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (uc *Usecase) RemoveUserInstrument(ctx context.Context, userID entities.UserID, instrumentID entities.InstrumentID) error {
|
||||||
|
if err := uc.storage.RemoveUserInstrument(ctx, userID, instrumentID); err != nil {
|
||||||
|
uc.log.Error("failed to remove user instrument", "user_id", userID, "instrument_id", instrumentID, "err", err)
|
||||||
|
return fmt.Errorf("failed to remove user instrument: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUserInstrument ensures the instrument exists in the DB (creating it if needed)
|
||||||
|
// and links it to the given user. Returns the instrument with its ID filled in.
|
||||||
|
func (uc *Usecase) AddUserInstrument(ctx context.Context, userID entities.UserID, base, quote string) (*entities.Instrument, error) {
|
||||||
|
base = strings.ToUpper(strings.TrimSpace(base))
|
||||||
|
quote = strings.ToUpper(strings.TrimSpace(quote))
|
||||||
|
|
||||||
|
instr := &entities.Instrument{BaseCurrency: base, QuoteCurrency: quote}
|
||||||
|
|
||||||
|
id, err := uc.storage.CreateInstrument(ctx, instr)
|
||||||
|
if err != nil {
|
||||||
|
uc.log.Error("failed to upsert instrument", "base", base, "quote", quote, "err", err)
|
||||||
|
return nil, fmt.Errorf("failed to upsert instrument: %w", err)
|
||||||
|
}
|
||||||
|
instr.ID = id
|
||||||
|
|
||||||
|
if err := uc.storage.AddUserInstrument(ctx, userID, id); err != nil {
|
||||||
|
uc.log.Error("failed to add user instrument", "user_id", userID, "instrument_id", id, "err", err)
|
||||||
|
return nil, fmt.Errorf("failed to add user instrument: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instr, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue