559 lines
17 KiB
Go
559 lines
17 KiB
Go
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)
|
|
}
|
|
}
|