implement telegram bot
This commit is contained in:
parent
608561ab38
commit
bec3b7de5b
5 changed files with 613 additions and 6 deletions
|
|
@ -3,25 +3,66 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/bot/telegram"
|
||||||
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/config"
|
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/config"
|
||||||
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/logger"
|
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/logger"
|
||||||
|
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/provider/bybit"
|
||||||
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/repository/postgresql"
|
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/repository/postgresql"
|
||||||
|
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/service/alerter"
|
||||||
|
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
// read config
|
defer cancel()
|
||||||
|
|
||||||
|
// config
|
||||||
cfg := config.MustLoad()
|
cfg := config.MustLoad()
|
||||||
// init logger
|
|
||||||
|
// logger
|
||||||
log := logger.NewAppLogger(&cfg.Logger)
|
log := logger.NewAppLogger(&cfg.Logger)
|
||||||
log.Info("app started")
|
log.Info("app started")
|
||||||
// init storage
|
|
||||||
|
// storage
|
||||||
storage, err := postgresql.New(ctx, log, &cfg.Postgresql)
|
storage, err := postgresql.New(ctx, log, &cfg.Postgresql)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("failed to connect to postgresql", "err", err)
|
log.Error("failed to connect to postgresql", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
_ = storage
|
|
||||||
// init telegram bot
|
// usecases
|
||||||
|
uc := usecase.New(log, storage)
|
||||||
|
|
||||||
|
// price provider
|
||||||
|
priceProvider := bybit.New(log, &cfg.Providers.Bybit)
|
||||||
|
|
||||||
|
// telegram bot
|
||||||
|
bot, err := telegram.New(cfg.Telegram.Token, log, uc, priceProvider)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to create telegram bot", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// alerter service — bot acts as the notifier
|
||||||
|
al := alerter.New(log, priceProvider, bot, storage)
|
||||||
|
|
||||||
|
// inject alerter into bot to keep the cache in sync when alerts are created/removed
|
||||||
|
bot.SetAlerter(al)
|
||||||
|
|
||||||
|
// load existing active alerts into the alerter cache
|
||||||
|
if err := al.LoadAlerts(ctx); err != nil {
|
||||||
|
log.Error("failed to load alerts", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start background price-checking loop
|
||||||
|
al.Run(ctx)
|
||||||
|
|
||||||
|
// run bot (blocks until ctx is cancelled)
|
||||||
|
bot.Run(ctx)
|
||||||
|
|
||||||
|
log.Info("app stopped")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -18,6 +18,7 @@ require (
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect
|
||||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -43,6 +43,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
|
|
|
||||||
|
|
@ -1 +1,559 @@
|
||||||
package telegram
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,16 @@ import (
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Logger Logger `yaml:"logger"`
|
Logger Logger `yaml:"logger"`
|
||||||
Postgresql Postgresql `yaml:"postgresql"`
|
Postgresql Postgresql `yaml:"postgresql"`
|
||||||
|
Telegram Telegram `yaml:"telegram"`
|
||||||
Providers struct {
|
Providers struct {
|
||||||
Bybit Bybit `yaml:"bybit"`
|
Bybit Bybit `yaml:"bybit"`
|
||||||
} `yaml:"providers"`
|
} `yaml:"providers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Telegram struct {
|
||||||
|
Token string `yaml:"token" env-required:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
ServiceName string `yaml:"service_name" env-required:"true"` // service name for printing in logs
|
ServiceName string `yaml:"service_name" env-required:"true"` // service name for printing in logs
|
||||||
Encoding string `yaml:"encoding" env-default:"json"` // console/json
|
Encoding string `yaml:"encoding" env-default:"json"` // console/json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue