This commit is contained in:
Artem Tsyrulnikov
2026-01-20 14:05:38 +03:00
parent 4269b9ae67
commit 7f6527652b
2 changed files with 264 additions and 313 deletions

View File

@@ -18,16 +18,16 @@ var emptyKeyboard = echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{}, InlineKeyboard: [][]echotron.InlineKeyboardButton{},
} }
type Exec struct { type exec struct {
handled bool handled bool // Событие обработано, дальше не идём
transitioned bool transitioned bool // Был SetState
} }
type Bot struct { type Bot struct {
echotron.API echotron.API
mu sync.Mutex mu sync.Mutex
ChatID int64 ChatID int64
exec *Exec exec *exec
CurrentState State CurrentState State
Session *Session Session *Session
LastMessageID int // ID последнего сообщения с inline кнопками LastMessageID int // ID последнего сообщения с inline кнопками
@@ -101,14 +101,8 @@ func (b *Bot) GetOrCreateJWT(u *echotron.Update) error {
} }
func (b *Bot) SetState(s State, mode RenderMode) { func (b *Bot) SetState(s State, mode RenderMode) {
log.Info(). log.Info().Msg(fmt.Sprintf("State transition: %T -> %T", b.CurrentState, s))
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 { if b.CurrentState != nil {
b.CurrentState.Exit() b.CurrentState.Exit()
} }
@@ -123,193 +117,6 @@ func (b *Bot) SetState(s State, mode RenderMode) {
s.Enter(b, mode) 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 { func isChannelChat(chatType string) bool {
return chatType == "channel" || chatType == "supergroup" return chatType == "channel" || chatType == "supergroup"
} }
@@ -322,10 +129,13 @@ func stringPtr(value string) *string {
} }
func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) { func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) {
if mode == EditMessage { switch mode {
case EditMessage:
b.Edit(text, keyboard) b.Edit(text, keyboard)
} else { case NewMessage:
b.SendNew(text, keyboard) b.SendNew(text, keyboard)
default:
panic("unknown RenderMode")
} }
} }
@@ -345,131 +155,82 @@ func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
} }
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
if b.exec != nil {
defer func() { b.exec.handled = true }()
}
if b.LastMessageID != 0 { if b.LastMessageID != 0 {
log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew") log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
b.cleanupKeyboard(b.LastMessageID) b.cleanupKeyboard(b.LastMessageID)
} }
res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ReplyMarkup: keyboard, ParseMode: echotron.HTML})
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
})
if b.exec != nil {
b.exec.handled = true
}
if err != nil { if err != nil {
log.Error().Err(err).Msg("SendMessage failed") log.Error().Err(err).Msg("SendMessage failed")
return return
} }
if res.Result != nil { if res.Result == nil {
b.LastMessageID = res.Result.ID return
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")
} }
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) { func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
if b.LastMessageID != 0 { if b.exec != nil {
log.Info(). defer func() { b.exec.handled = true }()
Int("last_msg_id", b.LastMessageID). }
Int("keyboard_rows", len(keyboard.InlineKeyboard)).
Int("text_len", len(text)). if b.LastMessageID == 0 {
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) b.SendNew(text, keyboard)
return
} }
if b.exec != nil { newTextHash := hashString(text)
b.exec.handled = true newKBHash := keyboardHash(keyboard)
}
}
func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) { textChanged := newTextHash != b.LastMessageTextHash
keyboard := echotron.InlineKeyboardMarkup{ kbChanged := newKBHash != b.LastMessageKBHash
InlineKeyboard: [][]echotron.InlineKeyboardButton{
{ if !textChanged && !kbChanged {
{ log.Info().Msg("Edit skipped: message not modified")
Text: buttonText, return
URL: url,
},
},
},
} }
res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ var err error
ReplyMarkup: keyboard, msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID)
})
if err != nil { if b.LastMessageIsMedia {
log.Error().Err(err).Msg("SendMessageWithURLButton") _, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{Caption: text, ParseMode: echotron.HTML, ReplyMarkup: keyboard})
} else {
_, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{ReplyMarkup: keyboard, ParseMode: echotron.HTML})
} }
if b.exec != nil { if err == nil {
b.exec.handled = true if textChanged {
b.LastMessageTextHash = newTextHash
}
if kbChanged {
b.LastMessageKBHash = newKBHash
}
return
} }
if err == nil && res.Result != nil { switch {
b.LastMessageID = res.Result.ID case strings.Contains(err.Error(), "message is not modified"):
b.LastMessageIsMedia = false log.Err(err).Msg("EditMessageText ignored: message is not modified")
b.LastMessageTextHash = hashString(text) case strings.Contains(err.Error(), "no text in the message to edit"):
b.LastMessageKBHash = keyboardHash(keyboard) b.LastMessageIsMedia = true
log.Err(err).Msg("EditMessageText ignored: message has no text")
default:
log.Error().Err(err).Msg("EditMessageText")
} }
} }
func keyboardHash(kb echotron.InlineKeyboardMarkup) string { func keyboardHash(kb echotron.InlineKeyboardMarkup) string {
@@ -490,13 +251,7 @@ func (b *Bot) Update(u *echotron.Update) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
log.Info(). log.Info().Int64("chat_id", b.ChatID).Msg("Update received")
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 // 1. Системные события -> отправляем доменные события в backend
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
@@ -533,8 +288,8 @@ func (b *Bot) Update(u *echotron.Update) {
return return
} }
exec := &Exec{} e := &exec{}
b.exec = exec b.exec = e
defer func() { b.exec = nil }() defer func() { b.exec = nil }()
if b.CurrentState == nil { if b.CurrentState == nil {
@@ -554,7 +309,7 @@ func (b *Bot) Update(u *echotron.Update) {
return return
default: default:
b.CurrentState.HandleMessage(b, u) b.CurrentState.HandleMessage(b, u)
if exec.handled || exec.transitioned { if e.handled || e.transitioned {
return return
} }
} }
@@ -575,14 +330,14 @@ func (b *Bot) Update(u *echotron.Update) {
} }
b.CurrentState.HandleCallback(b, u) b.CurrentState.HandleCallback(b, u)
if exec.handled || exec.transitioned { if e.handled || e.transitioned {
return return
} }
} }
// 3. Универсальный обработчик состояния (если ничего не подошло) // 3. Универсальный обработчик состояния (если ничего не подошло)
b.CurrentState.Handle(b, u) b.CurrentState.Handle(b, u)
if exec.handled || exec.transitioned { if e.handled || e.transitioned {
return return
} }
@@ -593,7 +348,7 @@ func (b *Bot) Update(u *echotron.Update) {
mode = EditMessage mode = EditMessage
} }
b.CurrentState.Enter(b, mode) b.CurrentState.Enter(b, mode)
if exec.handled || exec.transitioned { if e.handled || e.transitioned {
return return
} }
} }

View File

@@ -0,0 +1,196 @@
package bot
import (
"context"
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
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")
}
}
}
}