From bec3b7de5b2bf05fd3cdb78ebbb5eb4046fce12f Mon Sep 17 00:00:00 2001 From: yash Date: Thu, 26 Feb 2026 00:36:31 +0300 Subject: [PATCH] implement telegram bot --- cmd/app/main.go | 53 ++- go.mod | 1 + go.sum | 2 + internal/bot/telegram/telegram.go | 558 ++++++++++++++++++++++++++++++ internal/config/config.go | 5 + 5 files changed, 613 insertions(+), 6 deletions(-) diff --git a/cmd/app/main.go b/cmd/app/main.go index 29f3076..d62d027 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -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") } diff --git a/go.mod b/go.mod index e1e8900..e0cd704 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 5aea3d9..1157c18 100644 --- a/go.sum +++ b/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/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= diff --git a/internal/bot/telegram/telegram.go b/internal/bot/telegram/telegram.go index 2f93d21..4a399a9 100644 --- a/internal/bot/telegram/telegram.go +++ b/internal/bot/telegram/telegram.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c3c4fba..dbe1584 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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