new telegram bot

This commit is contained in:
Artem Tsyrulnikov
2025-12-28 15:50:06 +03:00
parent 69179c64be
commit 2fb8c83041
75 changed files with 3719 additions and 1046 deletions

274
telegram_bot/bot/bot.go Normal file
View File

@@ -0,0 +1,274 @@
package bot
import (
"context"
"fmt"
"example.com/m/adapter/backend"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
func Button(text, data string) echotron.InlineKeyboardButton {
return echotron.InlineKeyboardButton{
Text: text,
CallbackData: data,
}
}
func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons }
func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
return echotron.InlineKeyboardMarkup{
InlineKeyboard: rows,
}
}
type Exec struct {
handled bool
transitioned bool
}
type Bot struct {
echotron.API
ChatID int64
exec *Exec
CurrentState State
Session *Session
LastMessageID int // ID последнего сообщения с inline кнопками
DefaultStateFactory func() State
Backend *backend.Client
GlobalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
}
func NewBot(
chatID int64,
token string,
defaultStateFactory func() State,
backendClient *backend.Client,
globalCallbackRouter func(string) State,
) *Bot {
bot := &Bot{
ChatID: chatID,
API: echotron.NewAPI(token),
Session: &Session{},
DefaultStateFactory: defaultStateFactory,
Backend: backendClient,
GlobalCallbackRouter: globalCallbackRouter,
}
return bot
}
func (b *Bot) SetState(s State, mode RenderMode) {
log.Info().
Int64("chat_id", b.ChatID).
Str("from_state", fmt.Sprintf("%T", b.CurrentState)).
Str("to_state", fmt.Sprintf("%T", s)).
Str("mode", fmt.Sprintf("%v", mode)).
Msg("State transition")
b.CurrentState = s
b.exec.transitioned = true
b.exec.handled = true
s.Enter(b, mode)
}
//func (b *Bot) RemoveLastKeyboard() {
// if b.LastMessageID != 0 {
// emptyKeyboard := Keyboard() // Пустая клавиатура
// _, err := b.EditMessageReplyMarkup(
// echotron.NewMessageID(b.ChatID, b.LastMessageID),
// &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard},
// )
// if err != nil {
// log.Error().Err(err).Msg("RemoveLastKeyboard: b.EditMessageReplyMarkup")
// }
// }
//}
func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) {
if mode == EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
}
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
// Удаляем кнопки у предыдущего сообщения перед отправкой нового
if b.LastMessageID != 0 {
b.cleanupKeyboard(b.LastMessageID)
}
res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
})
b.exec.handled = true
if err != nil {
log.Error().Err(err).Msg("SendMessage failed")
return
}
if res.Result != nil {
b.LastMessageID = res.Result.ID
}
}
func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
if b.LastMessageID != 0 {
_, err := b.EditMessageText(
text,
echotron.NewMessageID(b.ChatID, b.LastMessageID),
&echotron.MessageTextOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
},
)
if err != nil {
log.Error().Err(err).Msg("EditMessageText")
}
} else {
b.SendNew(text, keyboard)
}
b.exec.handled = true
}
func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) {
keyboard := echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{
{
{
Text: buttonText,
URL: url,
},
},
},
}
res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
})
if err != nil {
log.Error().Err(err).Msg("SendMessageWithURLButton")
}
b.exec.handled = true
if err == nil && res.Result != nil {
b.LastMessageID = res.Result.ID
}
}
func (b *Bot) Update(u *echotron.Update) {
log.Info().
Int64("chat_id", b.ChatID).
Str("current_state", fmt.Sprintf("%T", b.CurrentState)).
Msg("Update received")
// Обрабатываем системные события (добавление в канал, подписки и т.д.)
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
go func() {
ctx := context.Background()
err := b.Backend.ForwardTelegramUpdate(ctx, u)
if err != nil {
log.Error().Err(err).Msg("Failed to forward system event to backend")
return
}
log.Info().Msg("System event forwarded to backend")
}()
// Системные события не требуют ответа бота - выходим
return
}
// Игнорируем все события не из личных чатов (кроме уже обработанных системных)
var chatType string
switch {
case u.Message != nil:
chatType = u.Message.Chat.Type
case u.EditedMessage != nil:
chatType = u.EditedMessage.Chat.Type
case u.ChannelPost != nil:
chatType = u.ChannelPost.Chat.Type
case u.EditedChannelPost != nil:
chatType = u.EditedChannelPost.Chat.Type
case u.CallbackQuery != nil && u.CallbackQuery.Message != nil:
chatType = u.CallbackQuery.Message.Chat.Type
}
if chatType != "" && chatType != "private" {
log.Debug().Str("chat_type", chatType).Msg("Ignoring event from non-private chat")
return
}
exec := &Exec{}
b.exec = exec
defer func() { b.exec = nil }()
if b.CurrentState == nil && b.DefaultStateFactory != nil {
b.CurrentState = b.DefaultStateFactory()
}
if u.Message != nil && u.Message.Text == "/start" {
if b.DefaultStateFactory != nil {
b.SetState(b.DefaultStateFactory(), NewMessage)
}
return
}
if u.CallbackQuery != nil {
b.cleanupCallbackUI(u)
if b.GlobalCallbackRouter != nil && u.CallbackQuery.Data != "" {
if newState := b.GlobalCallbackRouter(u.CallbackQuery.Data); newState != nil {
b.SetState(newState, NewMessage)
return
}
}
log.Info().Msg("Handling callback")
b.CurrentState.HandleCallback(b, u)
} else if u.Message != nil {
log.Info().Msg("Handling message")
b.CurrentState.HandleMessage(b, u)
}
if !exec.handled && !exec.transitioned {
log.Info().Msg("Common handler")
b.CurrentState.Handle(b, u)
}
if !exec.handled && !exec.transitioned {
log.Info().Msg("Default feedback")
b.SetState(b.DefaultStateFactory(), NewMessage)
}
}
func (b *Bot) cleanupKeyboard(messageID int) {
emptyKeyboard := Keyboard()
_, err := b.EditMessageReplyMarkup(
echotron.NewMessageID(b.ChatID, messageID),
&echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard},
)
if err != nil {
log.Error().Err(err).Int("message_id", messageID).Msg("Failed to remove keyboard")
}
}
func (b *Bot) cleanupCallbackUI(u *echotron.Update) {
_, err := b.AnswerCallbackQuery(u.CallbackQuery.ID, nil)
if err != nil {
log.Error().Err(err).Msg("b.AnswerCallbackQuery error")
}
if u.CallbackQuery.Message != nil {
callbackMessageID := u.CallbackQuery.Message.ID
if b.LastMessageID == callbackMessageID {
return
}
b.cleanupKeyboard(callbackMessageID)
}
}

22
telegram_bot/bot/state.go Normal file
View File

@@ -0,0 +1,22 @@
package bot
import "github.com/NicoNex/echotron/v3"
type RenderMode int
const (
NewMessage RenderMode = iota
EditMessage
)
type State interface {
Enter(*Bot, RenderMode)
HandleCallback(*Bot, *echotron.Update)
HandleMessage(*Bot, *echotron.Update)
Handle(*Bot, *echotron.Update)
}
type Session struct {
UserName string
JWT string
}