632 lines
17 KiB
Go
632 lines
17 KiB
Go
package bot
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/NicoNex/echotron/v3"
|
||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||
"github.com/rs/zerolog/log"
|
||
)
|
||
|
||
var emptyKeyboard = echotron.InlineKeyboardMarkup{
|
||
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
|
||
}
|
||
|
||
type Exec struct {
|
||
handled bool
|
||
transitioned bool
|
||
}
|
||
|
||
type Bot struct {
|
||
echotron.API
|
||
mu sync.Mutex
|
||
ChatID int64
|
||
exec *Exec
|
||
CurrentState State
|
||
Session *Session
|
||
LastMessageID int // ID последнего сообщения с inline кнопками
|
||
LastMessageIsMedia bool // True if LastMessageID points to media message
|
||
LastMessageTextHash string // Hash for last text/caption
|
||
LastMessageKBHash string // Hash for last inline keyboard
|
||
commandRouter func(cmd string) State // Роутер для команд (например, /start, /help)
|
||
globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
|
||
Backend *backend.Client
|
||
}
|
||
|
||
func NewBot(chatID int64, token string, commandRouter func(string) State, backendClient *backend.Client, globalCallbackRouter func(string) State) *Bot {
|
||
if commandRouter == nil {
|
||
panic("bot: commandRouter cannot be nil")
|
||
}
|
||
if globalCallbackRouter == nil {
|
||
panic("bot: globalCallbackRouter cannot be nil")
|
||
}
|
||
if commandRouter("/start") == nil {
|
||
panic("bot: commandRouter requires /start state answer")
|
||
}
|
||
if backendClient == nil {
|
||
panic("bot: backendClient cannot be nil")
|
||
}
|
||
|
||
return &Bot{
|
||
ChatID: chatID,
|
||
API: echotron.NewAPI(token),
|
||
Session: &Session{},
|
||
commandRouter: commandRouter,
|
||
Backend: backendClient,
|
||
globalCallbackRouter: globalCallbackRouter,
|
||
}
|
||
}
|
||
|
||
// GetOrCreateJWT получает JWT токен, передавая актуальные данные пользователя из update
|
||
func (b *Bot) GetOrCreateJWT(u *echotron.Update) error {
|
||
// Извлекаем данные пользователя из update
|
||
var user *echotron.User
|
||
if u.Message != nil && u.Message.From != nil {
|
||
user = u.Message.From
|
||
} else if u.CallbackQuery != nil && u.CallbackQuery.From != nil {
|
||
user = u.CallbackQuery.From
|
||
}
|
||
|
||
var username, firstName, lastName *string
|
||
if user != nil {
|
||
if user.Username != "" {
|
||
username = &user.Username
|
||
}
|
||
if user.FirstName != "" {
|
||
firstName = &user.FirstName
|
||
}
|
||
if user.LastName != "" {
|
||
lastName = &user.LastName
|
||
}
|
||
}
|
||
|
||
jwt, err := b.Backend.GetJWTByTelegramUser(context.Background(), b.ChatID, username, firstName, lastName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
b.Session.JWT = jwt
|
||
|
||
if firstName != nil {
|
||
b.Session.FirstName = *firstName
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
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")
|
||
|
||
// Вызываем Exit() у старого состояния для cleanup (например, отмены горутин)
|
||
if b.CurrentState != nil {
|
||
b.CurrentState.Exit()
|
||
}
|
||
|
||
b.CurrentState = s
|
||
|
||
if b.exec != nil {
|
||
b.exec.transitioned = true
|
||
b.exec.handled = true
|
||
}
|
||
|
||
s.Enter(b, mode)
|
||
}
|
||
|
||
func (b *Bot) forwardSystemEvent(u *echotron.Update) {
|
||
ctx := context.Background()
|
||
|
||
switch {
|
||
case u.ChatJoinRequest != nil:
|
||
req := u.ChatJoinRequest
|
||
if !isChannelChat(req.Chat.Type) {
|
||
return
|
||
}
|
||
|
||
inviteLink := ""
|
||
if req.InviteLink != nil {
|
||
inviteLink = req.InviteLink.InviteLink
|
||
}
|
||
if inviteLink == "" {
|
||
log.Debug().Msg("No invite link in chat join request")
|
||
return
|
||
}
|
||
|
||
userID := req.From.ID
|
||
username := stringPtr(req.From.Username)
|
||
firstName := stringPtr(req.From.FirstName)
|
||
lastName := stringPtr(req.From.LastName)
|
||
if userID == 0 && req.UserChatID != 0 {
|
||
userID = req.UserChatID
|
||
}
|
||
if userID == 0 {
|
||
log.Error().Msg("No user id in chat join request")
|
||
return
|
||
}
|
||
|
||
payload := map[string]any{
|
||
"type": "subscription",
|
||
"user_telegram_id": userID,
|
||
"invite_link": inviteLink,
|
||
"username": username,
|
||
"first_name": firstName,
|
||
"last_name": lastName,
|
||
}
|
||
if err := b.Backend.SendEvent(ctx, payload); err != nil {
|
||
log.Error().Err(err).Msg("Failed to send subscription event")
|
||
}
|
||
|
||
case u.ChatMember != nil:
|
||
event := u.ChatMember
|
||
if !isChannelChat(event.Chat.Type) {
|
||
return
|
||
}
|
||
|
||
memberUser := event.NewChatMember.User
|
||
if memberUser == nil {
|
||
log.Error().Msg("No user in chat member update")
|
||
return
|
||
}
|
||
|
||
oldStatus := event.OldChatMember.Status
|
||
newStatus := event.NewChatMember.Status
|
||
|
||
wasNotMember := oldStatus == "left" || oldStatus == "kicked"
|
||
isNowMember := newStatus == "member" || newStatus == "administrator" || newStatus == "creator"
|
||
userJoined := wasNotMember && isNowMember
|
||
|
||
wasMember := oldStatus == "member" || oldStatus == "administrator" || oldStatus == "creator" || oldStatus == "restricted"
|
||
isNowNotMember := newStatus == "left" || newStatus == "kicked"
|
||
userLeft := wasMember && isNowNotMember
|
||
|
||
if userJoined {
|
||
inviteLink := ""
|
||
if event.InviteLink != nil {
|
||
inviteLink = event.InviteLink.InviteLink
|
||
}
|
||
if inviteLink == "" {
|
||
log.Debug().Msg("No invite link in chat member update")
|
||
return
|
||
}
|
||
|
||
username := stringPtr(memberUser.Username)
|
||
firstName := stringPtr(memberUser.FirstName)
|
||
lastName := stringPtr(memberUser.LastName)
|
||
payload := map[string]any{
|
||
"type": "subscription",
|
||
"user_telegram_id": memberUser.ID,
|
||
"invite_link": inviteLink,
|
||
"username": username,
|
||
"first_name": firstName,
|
||
"last_name": lastName,
|
||
}
|
||
if err := b.Backend.SendEvent(ctx, payload); err != nil {
|
||
log.Error().Err(err).Msg("Failed to send subscription event")
|
||
}
|
||
return
|
||
}
|
||
|
||
if userLeft {
|
||
payload := map[string]any{
|
||
"type": "unsubscription",
|
||
"user_telegram_id": memberUser.ID,
|
||
"channel_telegram_id": event.Chat.ID,
|
||
}
|
||
if err := b.Backend.SendEvent(ctx, payload); err != nil {
|
||
log.Error().Err(err).Msg("Failed to send unsubscription event")
|
||
}
|
||
}
|
||
|
||
case u.MyChatMember != nil:
|
||
event := u.MyChatMember
|
||
if !isChannelChat(event.Chat.Type) {
|
||
return
|
||
}
|
||
|
||
actorID := event.From.ID
|
||
if actorID == 0 {
|
||
log.Error().Msg("No user in my chat member update")
|
||
return
|
||
}
|
||
|
||
oldStatus := event.OldChatMember.Status
|
||
newStatus := event.NewChatMember.Status
|
||
|
||
wasMember := oldStatus == "administrator" || oldStatus == "member"
|
||
isNowNotMember := newStatus == "left" || newStatus == "kicked"
|
||
botRemoved := wasMember && isNowNotMember
|
||
|
||
permissionsChanged := oldStatus == "administrator" && newStatus == "administrator"
|
||
|
||
if botRemoved {
|
||
payload := map[string]any{
|
||
"type": "bot_removed",
|
||
"telegram_id": event.Chat.ID,
|
||
"user_telegram_id": actorID,
|
||
}
|
||
if err := b.Backend.SendEvent(ctx, payload); err != nil {
|
||
log.Error().Err(err).Msg("Failed to send bot removed event")
|
||
}
|
||
return
|
||
}
|
||
|
||
if permissionsChanged {
|
||
title := event.Chat.Title
|
||
if title == "" {
|
||
title = fmt.Sprintf("Channel %d", event.Chat.ID)
|
||
}
|
||
payload := map[string]any{
|
||
"type": "bot_permissions",
|
||
"telegram_id": event.Chat.ID,
|
||
"chat_title": title,
|
||
"user_telegram_id": actorID,
|
||
"permissions": map[string]any{
|
||
"is_admin": newStatus == "administrator",
|
||
"can_invite_users": event.NewChatMember.CanInviteUsers,
|
||
"can_restrict_members": event.NewChatMember.CanRestrictMembers,
|
||
},
|
||
}
|
||
if err := b.Backend.SendEvent(ctx, payload); err != nil {
|
||
log.Error().Err(err).Msg("Failed to send bot permissions event")
|
||
}
|
||
return
|
||
}
|
||
|
||
wasNotMember := oldStatus == "left" || oldStatus == "kicked"
|
||
isNowMember := newStatus == "administrator" || newStatus == "member"
|
||
botAdded := wasNotMember && isNowMember
|
||
|
||
if botAdded {
|
||
title := event.Chat.Title
|
||
if title == "" {
|
||
title = fmt.Sprintf("Channel %d", event.Chat.ID)
|
||
}
|
||
payload := map[string]any{
|
||
"type": "bot_added",
|
||
"telegram_id": event.Chat.ID,
|
||
"title": title,
|
||
"username": stringPtr(event.Chat.Username),
|
||
"user_telegram_id": actorID,
|
||
"bot_permissions": map[string]any{
|
||
"is_admin": newStatus == "administrator",
|
||
"can_invite_users": event.NewChatMember.CanInviteUsers,
|
||
"can_restrict_members": event.NewChatMember.CanRestrictMembers,
|
||
},
|
||
}
|
||
if err := b.Backend.SendEvent(ctx, payload); err != nil {
|
||
log.Error().Err(err).Msg("Failed to send bot added event")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func isChannelChat(chatType string) bool {
|
||
return chatType == "channel" || chatType == "supergroup"
|
||
}
|
||
|
||
func stringPtr(value string) *string {
|
||
if value == "" {
|
||
return nil
|
||
}
|
||
return &value
|
||
}
|
||
|
||
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) SetLastMessageIsMedia(isMedia bool) {
|
||
b.LastMessageIsMedia = isMedia
|
||
}
|
||
|
||
func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
|
||
res, err := b.GetFile(fileID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if res.Result == nil || res.Result.FilePath == "" {
|
||
return nil, fmt.Errorf("telegram file not available")
|
||
}
|
||
return b.DownloadFile(res.Result.FilePath)
|
||
}
|
||
|
||
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
|
||
if b.LastMessageID != 0 {
|
||
log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
|
||
b.cleanupKeyboard(b.LastMessageID)
|
||
}
|
||
|
||
res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{
|
||
ReplyMarkup: keyboard,
|
||
ParseMode: echotron.HTML,
|
||
})
|
||
|
||
if b.exec != nil {
|
||
b.exec.handled = true
|
||
}
|
||
|
||
if err != nil {
|
||
log.Error().Err(err).Msg("SendMessage failed")
|
||
return
|
||
}
|
||
|
||
if res.Result != nil {
|
||
b.LastMessageID = res.Result.ID
|
||
b.LastMessageIsMedia = false
|
||
b.LastMessageTextHash = hashString(text)
|
||
b.LastMessageKBHash = keyboardHash(keyboard)
|
||
log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew")
|
||
}
|
||
}
|
||
|
||
func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
|
||
if b.LastMessageID != 0 {
|
||
log.Info().
|
||
Int("last_msg_id", b.LastMessageID).
|
||
Int("keyboard_rows", len(keyboard.InlineKeyboard)).
|
||
Int("text_len", len(text)).
|
||
Msg("EditMessageText request")
|
||
textHash := hashString(text)
|
||
kbHash := keyboardHash(keyboard)
|
||
if textHash == b.LastMessageTextHash && kbHash == b.LastMessageKBHash {
|
||
log.Info().Msg("EditMessageText skipped: message is not modified")
|
||
if b.exec != nil {
|
||
b.exec.handled = true
|
||
}
|
||
return
|
||
}
|
||
var err error
|
||
if b.LastMessageIsMedia {
|
||
_, err = b.EditMessageCaption(
|
||
echotron.NewMessageID(b.ChatID, b.LastMessageID),
|
||
&echotron.MessageCaptionOptions{
|
||
Caption: text,
|
||
ParseMode: echotron.HTML,
|
||
ReplyMarkup: keyboard,
|
||
},
|
||
)
|
||
if err == nil {
|
||
b.LastMessageTextHash = textHash
|
||
b.LastMessageKBHash = kbHash
|
||
}
|
||
} else {
|
||
_, err = b.EditMessageText(
|
||
text,
|
||
echotron.NewMessageID(b.ChatID, b.LastMessageID),
|
||
&echotron.MessageTextOptions{
|
||
ReplyMarkup: keyboard,
|
||
ParseMode: echotron.HTML,
|
||
},
|
||
)
|
||
if err == nil {
|
||
b.LastMessageTextHash = textHash
|
||
b.LastMessageKBHash = kbHash
|
||
}
|
||
}
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "message is not modified") {
|
||
log.Err(err).Msg("EditMessageText ignored: message is not modified")
|
||
if b.exec != nil {
|
||
b.exec.handled = true
|
||
}
|
||
return
|
||
}
|
||
if strings.Contains(err.Error(), "no text in the message to edit") {
|
||
b.LastMessageIsMedia = true
|
||
log.Err(err).Msg("EditMessageText ignored: message has no text")
|
||
return
|
||
}
|
||
log.Error().Err(err).Msg("EditMessageText")
|
||
}
|
||
} else {
|
||
b.SendNew(text, keyboard)
|
||
}
|
||
|
||
if b.exec != nil {
|
||
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")
|
||
}
|
||
|
||
if b.exec != nil {
|
||
b.exec.handled = true
|
||
}
|
||
|
||
if err == nil && res.Result != nil {
|
||
b.LastMessageID = res.Result.ID
|
||
b.LastMessageIsMedia = false
|
||
b.LastMessageTextHash = hashString(text)
|
||
b.LastMessageKBHash = keyboardHash(keyboard)
|
||
}
|
||
}
|
||
|
||
func keyboardHash(kb echotron.InlineKeyboardMarkup) string {
|
||
b, err := json.Marshal(kb)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
sum := sha256.Sum256(b)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func hashString(v string) string {
|
||
sum := sha256.Sum256([]byte(v))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func (b *Bot) Update(u *echotron.Update) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
|
||
log.Info().
|
||
Int64("chat_id", b.ChatID).
|
||
Str("current_state", fmt.Sprintf("%T", b.CurrentState)).
|
||
Bool("has_callback", u.CallbackQuery != nil).
|
||
Bool("has_message", u.Message != nil).
|
||
Bool("has_edited_message", u.EditedMessage != nil).
|
||
Msg("Update received")
|
||
|
||
// 1. Системные события -> отправляем доменные события в backend
|
||
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
|
||
go b.forwardSystemEvent(u)
|
||
return
|
||
}
|
||
|
||
// 2. Игнорируем события не из private чатов
|
||
chatType := ""
|
||
switch {
|
||
case u.Message != nil:
|
||
chatType = u.Message.Chat.Type
|
||
case u.CallbackQuery != nil && u.CallbackQuery.Message != nil:
|
||
chatType = u.CallbackQuery.Message.Chat.Type
|
||
case u.EditedMessage != nil:
|
||
chatType = u.EditedMessage.Chat.Type
|
||
case u.ChannelPost != nil || u.EditedChannelPost != nil:
|
||
return // Каналы игнорируем сразу
|
||
}
|
||
if chatType != "" && chatType != "private" {
|
||
log.Debug().Str("chat_type", chatType).Msg("Ignoring non-private chat")
|
||
return
|
||
}
|
||
|
||
// 3. Игнорируем edited messages
|
||
if u.EditedMessage != nil || u.EditedChannelPost != nil || u.EditedBusinessMessage != nil {
|
||
log.Debug().Msg("Ignoring edited message")
|
||
return
|
||
}
|
||
|
||
err := b.GetOrCreateJWT(u)
|
||
if err != nil || b.Session.JWT == "" {
|
||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", emptyKeyboard)
|
||
return
|
||
}
|
||
|
||
exec := &Exec{}
|
||
b.exec = exec
|
||
defer func() { b.exec = nil }()
|
||
|
||
if b.CurrentState == nil {
|
||
b.CurrentState = b.commandRouter("/start")
|
||
}
|
||
|
||
// === Обработка входящего события ===
|
||
|
||
// 1. Команды (приоритетный маршрут)
|
||
if u.Message != nil {
|
||
switch u.Message.Text {
|
||
case "/start":
|
||
b.SetState(b.commandRouter("/start"), NewMessage)
|
||
return
|
||
case "/start login":
|
||
b.SetState(b.commandRouter("/start login"), NewMessage)
|
||
return
|
||
default:
|
||
b.CurrentState.HandleMessage(b, u)
|
||
if exec.handled || exec.transitioned {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Callback запросы
|
||
if u.CallbackQuery != nil {
|
||
b.cleanupCallbackUI(u)
|
||
|
||
if u.CallbackQuery.Data == "empty" {
|
||
return
|
||
}
|
||
|
||
// Глобальные callbacks (из уведомлений)
|
||
if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil {
|
||
b.SetState(newState, NewMessage)
|
||
return
|
||
}
|
||
|
||
b.CurrentState.HandleCallback(b, u)
|
||
if exec.handled || exec.transitioned {
|
||
return
|
||
}
|
||
}
|
||
|
||
// 3. Универсальный обработчик состояния (если ничего не подошло)
|
||
b.CurrentState.Handle(b, u)
|
||
if exec.handled || exec.transitioned {
|
||
return
|
||
}
|
||
|
||
// 4. Fallback - ререндер текущего экрана, чтобы не терять прогресс
|
||
if b.CurrentState != nil {
|
||
mode := NewMessage
|
||
if u.CallbackQuery != nil {
|
||
mode = EditMessage
|
||
}
|
||
b.CurrentState.Enter(b, mode)
|
||
if exec.handled || exec.transitioned {
|
||
return
|
||
}
|
||
}
|
||
|
||
// 5. Крайний fallback - сброс в начальное состояние
|
||
b.SetState(b.commandRouter("/start"), NewMessage)
|
||
}
|
||
|
||
func (b *Bot) cleanupKeyboard(messageID int) {
|
||
_, 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)
|
||
}
|
||
}
|