бот несколько медиа

This commit is contained in:
Artem Tsyrulnikov
2026-01-23 16:04:28 +03:00
parent 912b543f75
commit b5d839ddae
5 changed files with 350 additions and 90 deletions

View File

@@ -4,8 +4,11 @@ import (
"fmt"
"regexp"
"strings"
"sync"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
@@ -17,6 +20,11 @@ type InlineButton struct {
URL string
}
type MediaItem struct {
MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram
}
// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
type CreativeEditorFields struct {
// Данные креатива
@@ -25,8 +33,7 @@ type CreativeEditorFields struct {
Buttons []InlineButton
// Медиа
MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram
MediaItems []MediaItem
MediaChanged bool
// Режим ввода
@@ -39,15 +46,20 @@ type CreativeEditorFields struct {
CreativeMessageID *int
// ID сообщения с панелью управления
ControlPanelMessageID *int
// ID сообщений дополнительного медиа (после первого)
ExtraMediaMessageIDs []int
pendingMediaGroupMu sync.Mutex
pendingMediaGroupID string
pendingMediaGroupTimer *time.Timer
pendingMediaGroupGen int64
// Предыдущее состояние для определения изменения типа сообщения
previousHasMedia bool
previousMediaType string
previousMediaFileID string
previousMediaItems []MediaItem
}
const inviteLinkPlaceholder = "{{invite_link}}"
const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
const inviteLinkPreviewURL = "https://t.me/joinchat/nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
const buttonURLPlaceholder = "https://example.com"
var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`)
@@ -85,6 +97,77 @@ func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.I
return keyboard
}
func copyMediaItems(items []MediaItem) []MediaItem {
if len(items) == 0 {
return nil
}
clone := make([]MediaItem, len(items))
copy(clone, items)
return clone
}
func mediaItemsEqual(a, b []MediaItem) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].MediaType != b[i].MediaType || a[i].MediaFileID != b[i].MediaFileID {
return false
}
}
return true
}
func (e *CreativeEditorFields) primaryMedia() *MediaItem {
if len(e.MediaItems) == 0 {
return nil
}
return &e.MediaItems[0]
}
func (e *CreativeEditorFields) hasMultipleMedia() bool {
return len(e.MediaItems) > 1
}
func (e *CreativeEditorFields) clearExtraMediaMessages(b *bot.Bot) {
for _, msgID := range e.ExtraMediaMessageIDs {
b.DeleteMessage(b.ChatID, msgID)
}
e.ExtraMediaMessageIDs = nil
}
const mediaGroupDebounce = 2 * time.Second
func (e *CreativeEditorFields) scheduleMediaGroupAction(groupID string, action func()) {
e.pendingMediaGroupMu.Lock()
defer e.pendingMediaGroupMu.Unlock()
if groupID == "" {
action()
return
}
if e.pendingMediaGroupTimer != nil {
e.pendingMediaGroupTimer.Stop()
}
e.pendingMediaGroupID = groupID
e.pendingMediaGroupGen++
gen := e.pendingMediaGroupGen
e.pendingMediaGroupTimer = time.AfterFunc(mediaGroupDebounce, func() {
e.pendingMediaGroupMu.Lock()
currentGroup := e.pendingMediaGroupID
currentGen := e.pendingMediaGroupGen
if currentGen == gen && currentGroup == groupID {
e.pendingMediaGroupID = ""
e.pendingMediaGroupTimer = nil
}
e.pendingMediaGroupMu.Unlock()
if currentGen == gen && currentGroup == groupID {
action()
}
})
}
func isTelegramParseError(err error) bool {
if err == nil {
return false
@@ -188,14 +271,66 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
}
// Если есть медиа, отправляем с медиа
if e.MediaFileID != "" {
if len(e.MediaItems) > 1 {
e.clearExtraMediaMessages(b)
group := make([]echotron.GroupableInputMedia, 0, len(e.MediaItems))
for i, item := range e.MediaItems {
caption := ""
parseMode := echotron.ParseMode("")
if i == 0 {
caption = creativeText
parseMode = echotron.HTML
}
switch item.MediaType {
case "photo":
group = append(group, echotron.InputMediaPhoto{
Type: echotron.MediaTypePhoto,
Media: echotron.NewInputFileID(item.MediaFileID),
Caption: caption,
ParseMode: parseMode,
})
case "video":
group = append(group, echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(item.MediaFileID),
Caption: caption,
ParseMode: parseMode,
})
case "animation":
group = append(group, echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(item.MediaFileID),
Caption: caption,
ParseMode: parseMode,
})
default:
log.Warn().Str("media_type", item.MediaType).Msg("Unsupported media type for media group")
}
}
if len(group) > 0 {
res, err := b.SendMediaGroup(b.ChatID, group, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to send media group")
if id, err := sendText(); err == nil {
msgID = id
} else if isTelegramParseError(err) {
if id, err := sendFallback(); err == nil {
msgID = id
}
}
} else if len(res.Result) > 0 {
msgID = res.Result[0].ID
}
}
} else if media := e.primaryMedia(); media != nil {
var res echotron.APIResponseMessage
var err error
switch e.MediaType {
switch media.MediaType {
case "photo":
res, err = b.SendPhoto(
echotron.NewInputFileID(e.MediaFileID),
echotron.NewInputFileID(media.MediaFileID),
b.ChatID,
&echotron.PhotoOptions{
Caption: creativeText,
@@ -205,7 +340,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
)
case "video":
res, err = b.SendVideo(
echotron.NewInputFileID(e.MediaFileID),
echotron.NewInputFileID(media.MediaFileID),
b.ChatID,
&echotron.VideoOptions{
Caption: creativeText,
@@ -215,7 +350,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
)
case "animation":
res, err = b.SendAnimation(
echotron.NewInputFileID(e.MediaFileID),
echotron.NewInputFileID(media.MediaFileID),
b.ChatID,
&echotron.AnimationOptions{
Caption: creativeText,
@@ -226,7 +361,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
}
if err != nil {
log.Error().Err(err).Str("media_type", e.MediaType).Msg("Failed to send media")
log.Error().Err(err).Str("media_type", media.MediaType).Msg("Failed to send media")
// Fallback to text message - используем прямой SendMessage
if id, err := sendText(); err == nil {
msgID = id
@@ -238,6 +373,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
} else if res.Result != nil {
msgID = res.Result.ID
}
} else {
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
// чтобы не затронуть LastMessageID и не затронуть cleanup
@@ -262,9 +398,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
e.CreativeMessageID = &msgID
e.previousHasMedia = e.MediaFileID != ""
e.previousMediaType = e.MediaType
e.previousMediaFileID = e.MediaFileID
e.previousMediaItems = copyMediaItems(e.MediaItems)
}
// UpdateCreativePreview обновляет превью креатива
@@ -276,15 +410,18 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
return
}
currentHasMedia := e.MediaFileID != ""
currentHasMedia := len(e.MediaItems) > 0
previousHasMedia := len(e.previousMediaItems) > 0
mediaItemsChanged := !mediaItemsEqual(e.MediaItems, e.previousMediaItems)
// Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
if currentHasMedia != e.previousHasMedia {
if currentHasMedia != previousHasMedia {
// Удаляем оба сообщения
if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil
}
e.clearExtraMediaMessages(b)
if e.ControlPanelMessageID != nil {
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
e.ControlPanelMessageID = nil
@@ -295,6 +432,16 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
return
}
if mediaItemsChanged && (len(e.MediaItems) > 1 || len(e.previousMediaItems) > 1) {
if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil
}
e.clearExtraMediaMessages(b)
e.SendCreativePreview(b)
return
}
// Тип не изменился - просто редактируем на месте
creativeText := e.GetCreativeText()
creativeButtons := e.GetCreativeButtons()
@@ -308,10 +455,14 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
if len(runes) > 200 {
textPreview = string(runes[:200]) + "..."
}
mediaType := ""
if media := e.primaryMedia(); media != nil {
mediaType = media.MediaType
}
log.Error().
Err(err).
Int("creative_message_id", *e.CreativeMessageID).
Str("media_type", e.MediaType).
Str("media_type", mediaType).
Int("text_len", len([]rune(creativeText))).
Str("text_preview", textPreview).
Msg(msg)
@@ -319,38 +470,42 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
// Если есть медиа - редактируем caption
updated := false
if e.MediaFileID != "" {
mediaChanged := e.MediaFileID != e.previousMediaFileID || e.MediaType != e.previousMediaType
if media := e.primaryMedia(); media != nil {
var prevMedia MediaItem
if len(e.previousMediaItems) > 0 {
prevMedia = e.previousMediaItems[0]
}
mediaChanged := media.MediaFileID != prevMedia.MediaFileID || media.MediaType != prevMedia.MediaType
if mediaChanged {
var media echotron.InputMedia
switch e.MediaType {
var inputMedia echotron.InputMedia
switch media.MediaType {
case "photo":
media = echotron.InputMediaPhoto{
inputMedia = echotron.InputMediaPhoto{
Type: echotron.MediaTypePhoto,
Media: echotron.NewInputFileID(e.MediaFileID),
Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "video":
media = echotron.InputMediaVideo{
inputMedia = echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(e.MediaFileID),
Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "animation":
media = echotron.InputMediaAnimation{
inputMedia = echotron.InputMediaAnimation{
Type: echotron.MediaTypeAnimation,
Media: echotron.NewInputFileID(e.MediaFileID),
Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
}
if media != nil {
if inputMedia != nil {
_, err := b.EditMessageMedia(
msgID,
media,
inputMedia,
&echotron.MessageMediaOptions{
ReplyMarkup: keyboard,
},
@@ -361,7 +516,7 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
updated = true
}
} else {
log.Warn().Str("media_type", e.MediaType).Msg("Unsupported media type for edit")
log.Warn().Str("media_type", media.MediaType).Msg("Unsupported media type for edit")
}
} else {
_, err := b.EditMessageCaption(
@@ -399,9 +554,11 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
}
if updated {
e.previousHasMedia = currentHasMedia
e.previousMediaType = e.MediaType
e.previousMediaFileID = e.MediaFileID
if currentHasMedia {
e.previousMediaItems = copyMediaItems(e.MediaItems)
} else {
e.previousMediaItems = nil
}
}
}
@@ -413,21 +570,23 @@ func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) {
}
func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool {
var item MediaItem
switch {
case message.Photo != nil && len(message.Photo) > 0:
photo := message.Photo[len(message.Photo)-1]
e.MediaType = "photo"
e.MediaFileID = photo.FileID
item.MediaType = "photo"
item.MediaFileID = photo.FileID
case message.Video != nil:
e.MediaType = "video"
e.MediaFileID = message.Video.FileID
item.MediaType = "video"
item.MediaFileID = message.Video.FileID
case message.Animation != nil:
e.MediaType = "animation"
e.MediaFileID = message.Animation.FileID
item.MediaType = "animation"
item.MediaFileID = message.Animation.FileID
default:
return false
}
e.MediaItems = append(e.MediaItems, item)
e.MediaChanged = true
return true
}
@@ -496,13 +655,21 @@ func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback,
Button("✎ Текст", "edit_text"),
Button(" Медиа", "add_media"),
))
buttons = append(buttons, Row(Button(" Добавить кнопку", "add_button")))
addButtonLabel := " Добавить кнопку"
addButtonCallback := "add_button"
if e.hasMultipleMedia() {
addButtonLabel = "Кнопки недоступны для >1 медиа"
addButtonCallback = "empty"
}
buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback)))
// Кнопка удаления медиа (если медиа добавлено)
if e.MediaFileID != "" {
buttons = append(buttons, Row(
Button("⌫ Убрать медиа", "delete_media"),
))
if len(e.MediaItems) > 0 {
label := "⌫ Убрать медиа"
if len(e.MediaItems) > 1 {
label = fmt.Sprintf("⌫ Убрать медиа (%d)", len(e.MediaItems))
}
buttons = append(buttons, Row(Button(label, "delete_media")))
}
// Кнопки удаления (если есть кнопки)
@@ -566,3 +733,25 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons
b.Edit(text, keyboard)
}
}
func (e *CreativeEditorFields) BuildMediaInputs(b *bot.Bot) ([]backend.CreativeMediaInput, error) {
if len(e.MediaItems) == 0 {
return nil, nil
}
mediaItems := make([]backend.CreativeMediaInput, 0, len(e.MediaItems))
for _, item := range e.MediaItems {
if item.MediaFileID == "" {
continue
}
mediaData, err := b.DownloadFileBytes(item.MediaFileID)
if err != nil {
return nil, err
}
mediaItems = append(mediaItems, backend.CreativeMediaInput{
MediaType: item.MediaType,
MediaFileID: item.MediaFileID,
MediaData: mediaData,
})
}
return mediaItems, nil
}