implement telegram bot

This commit is contained in:
yash 2026-02-26 00:36:31 +03:00
parent 608561ab38
commit bec3b7de5b
5 changed files with 613 additions and 6 deletions

View file

@ -3,25 +3,66 @@ package main
import (
"context"
"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/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/service/alerter"
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/usecase"
)
func main() {
ctx := context.Background()
// read config
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// config
cfg := config.MustLoad()
// init logger
// logger
log := logger.NewAppLogger(&cfg.Logger)
log.Info("app started")
// init storage
// storage
storage, err := postgresql.New(ctx, log, &cfg.Postgresql)
if err != nil {
log.Error("failed to connect to postgresql", "err", err)
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
View file

@ -18,6 +18,7 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect

2
go.sum
View file

@ -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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
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/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=

View file

@ -1 +1,559 @@
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)
}
}

View file

@ -10,11 +10,16 @@ import (
type Config struct {
Logger Logger `yaml:"logger"`
Postgresql Postgresql `yaml:"postgresql"`
Telegram Telegram `yaml:"telegram"`
Providers struct {
Bybit Bybit `yaml:"bybit"`
} `yaml:"providers"`
}
type Telegram struct {
Token string `yaml:"token" env-required:"true"`
}
type Logger struct {
ServiceName string `yaml:"service_name" env-required:"true"` // service name for printing in logs
Encoding string `yaml:"encoding" env-default:"json"` // console/json