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