diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index 6dff63e..c3d1754 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -2,6 +2,9 @@ package bot import ( "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "strings" "sync" @@ -28,6 +31,9 @@ type Bot struct { 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 @@ -323,6 +329,10 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R } } +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 { @@ -335,7 +345,6 @@ func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) { } 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) @@ -357,6 +366,9 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { 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") } } @@ -368,22 +380,56 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { Int("keyboard_rows", len(keyboard.InlineKeyboard)). Int("text_len", len(text)). Msg("EditMessageText request") - _, err := b.EditMessageText( - text, - echotron.NewMessageID(b.ChatID, b.LastMessageID), - &echotron.MessageTextOptions{ - ReplyMarkup: keyboard, - ParseMode: echotron.HTML, - }, - ) + 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.Info().Msg("EditMessageText ignored: 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 { @@ -420,9 +466,26 @@ func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) { 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() diff --git a/tg_bot/go.mod b/tg_bot/go.mod index 87dbccc..ed39a4b 100644 --- a/tg_bot/go.mod +++ b/tg_bot/go.mod @@ -3,15 +3,16 @@ module github.com/TelegramExchange/tgex-backend/tg_bot go 1.24.4 require ( + github.com/NicoNex/echotron/v3 v3.43.0 github.com/rs/zerolog v1.34.0 - github.com/TelegramExchange/pkg v0.0.0 + golang.org/x/image v0.31.0 ) require ( - github.com/NicoNex/echotron/v3 v3.43.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.5.0 // indirect ) diff --git a/tg_bot/go.sum b/tg_bot/go.sum index 6359c6b..99c3704 100644 --- a/tg_bot/go.sum +++ b/tg_bot/go.sum @@ -13,10 +13,14 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/tg_bot/screens/add_purchase.go b/tg_bot/screens/add_purchase.go index 7283e50..af19784 100644 --- a/tg_bot/screens/add_purchase.go +++ b/tg_bot/screens/add_purchase.go @@ -14,17 +14,31 @@ const addPurchaseCreativesPerPage = 5 const addPurchaseProjectsPerPage = 6 type AddPurchase struct { - WorkspaceID string - ProjectID string - ProjectTitle string - CreativeID string - CreativeTitle string - ProjectPage int - CreativePage int - ActivePicker string // "" | "project_list" | "creative_list" - BackState bot.State - lastProjects map[string]string - lastCreatives map[string]string + WorkspaceID string + ProjectID string + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + CreativeID string + CreativeTitle string + ProjectPage int + CreativePage int + ActivePicker string // "" | "project_list" | "creative_list" + BackState bot.State + lastProjects map[string]projectInfo + lastCreatives map[string]creativeInfo +} + +type projectInfo struct { + Title string + TelegramID int64 + Username string + Status string +} + +type creativeInfo struct { + Title string } func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) { @@ -78,9 +92,18 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin return } - s.lastProjects = make(map[string]string) + s.lastProjects = make(map[string]projectInfo) for _, project := range page.Items { - s.lastProjects[project.ID] = project.Title + username := "" + if project.Username != nil { + username = *project.Username + } + s.lastProjects[project.ID] = projectInfo{ + Title: project.Title, + TelegramID: project.TelegramID, + Username: username, + Status: project.Status, + } } if len(page.Items) == 0 { @@ -143,9 +166,11 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin return } - s.lastCreatives = make(map[string]string) + s.lastCreatives = make(map[string]creativeInfo) for _, creative := range page.Items { - s.lastCreatives[creative.ID] = creative.Name + s.lastCreatives[creative.ID] = creativeInfo{ + Title: creative.Name, + } } if len(page.Items) == 0 { @@ -202,6 +227,11 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin keyboard := Keyboard(buttons...) b.Render(text, keyboard, mode) + + if s.ActivePicker == "" && s.ProjectTelegramID != 0 { + messageID := b.LastMessageID + updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus) + } } func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { @@ -258,13 +288,16 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { } if s.ProjectID != "" && s.CreativeID != "" { b.SetState(&SelectChannelsForPurchase{ - WorkspaceID: s.WorkspaceID, - ProjectID: s.ProjectID, - ProjectTitle: s.ProjectTitle, - CreativeID: s.CreativeID, - CreativeTitle: s.CreativeTitle, - Channels: []PurchaseChannelInput{}, - BackState: s.BackState, + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + CreativeID: s.CreativeID, + CreativeTitle: s.CreativeTitle, + Channels: []PurchaseChannelInput{}, + BackState: s.BackState, }, bot.EditMessage) } @@ -272,7 +305,12 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { if len(data) > 8 && data[:8] == "project:" { projectID := data[8:] if s.lastProjects != nil { - s.ProjectTitle = s.lastProjects[projectID] + if info, ok := s.lastProjects[projectID]; ok { + s.ProjectTitle = info.Title + s.ProjectTelegramID = info.TelegramID + s.ProjectUsername = info.Username + s.ProjectStatus = info.Status + } } s.ProjectID = projectID s.CreativeID = "" @@ -285,7 +323,9 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { if len(data) > 9 && data[:9] == "creative:" { creativeID := data[9:] if s.lastCreatives != nil { - s.CreativeTitle = s.lastCreatives[creativeID] + if info, ok := s.lastCreatives[creativeID]; ok { + s.CreativeTitle = info.Title + } } s.CreativeID = creativeID s.ActivePicker = "" diff --git a/tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf b/tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf differ diff --git a/tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf b/tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/tg_bot/screens/creatives.go b/tg_bot/screens/creatives.go index 2d21816..ef6aded 100644 --- a/tg_bot/screens/creatives.go +++ b/tg_bot/screens/creatives.go @@ -14,10 +14,14 @@ import ( const creativesPerPage = 6 type Creatives struct { - CurrentPage int - ProjectID string - WorkspaceID string - BackState bot.State + CurrentPage int + ProjectID string + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + WorkspaceID string + BackState bot.State } func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) { @@ -118,6 +122,9 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) keyboard := Keyboard(buttons...) b.Render(text, keyboard, mode) + + messageID := b.LastMessageID + updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus) } func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { diff --git a/tg_bot/screens/project_details.go b/tg_bot/screens/project_details.go index 00714d8..0c24753 100644 --- a/tg_bot/screens/project_details.go +++ b/tg_bot/screens/project_details.go @@ -29,6 +29,11 @@ func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) { text := s.formatProjectDetails() keyboard := s.buildKeyboard() b.Render(text, keyboard, mode) + + messageID := b.LastMessageID + username := usernameFromProject(s.Project) + status := statusFromProject(s.Project) + updateProjectHeaderMedia(b, messageID, text, keyboard, s.Project.TelegramID, s.Project.Title, username, status) } func (s *ProjectDetails) formatProjectDetails() string { @@ -123,24 +128,31 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { case data == "back_to_projects", data == "back": s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения if s.BackState != nil { - b.SetState(s.BackState, bot.EditMessage) + s.transitionWithNewMessage(b, s.BackState) } case strings.HasPrefix(data, "creatives:"): s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения b.SetState(&Creatives{ - ProjectID: s.Project.ID, - WorkspaceID: s.WorkspaceID, - BackState: s, + ProjectID: s.Project.ID, + ProjectTitle: s.Project.Title, + ProjectTelegramID: s.Project.TelegramID, + ProjectUsername: usernameFromProject(s.Project), + ProjectStatus: statusFromProject(s.Project), + WorkspaceID: s.WorkspaceID, + BackState: s, }, bot.EditMessage) case strings.HasPrefix(data, "placements:"): s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения b.SetState(&Placements{ - WorkspaceID: s.WorkspaceID, - ProjectID: s.Project.ID, - ProjectTitle: s.Project.Title, - BackState: s, + WorkspaceID: s.WorkspaceID, + ProjectID: s.Project.ID, + ProjectTitle: s.Project.Title, + ProjectTelegramID: s.Project.TelegramID, + ProjectUsername: usernameFromProject(s.Project), + ProjectStatus: statusFromProject(s.Project), + BackState: s, }, bot.EditMessage) case data == "back_to_project": @@ -203,3 +215,28 @@ func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *ProjectDetails) Exit() {} + +func (s *ProjectDetails) transitionWithNewMessage(b *bot.Bot, next bot.State) { + if b.LastMessageID != 0 { + if _, err := b.DeleteMessage(b.ChatID, b.LastMessageID); err != nil { + log.Error().Err(err).Msg("DeleteMessage failed") + } else { + b.LastMessageID = 0 + } + } + b.SetState(next, bot.NewMessage) +} + +func usernameFromProject(p *backend.Project) string { + if p == nil || p.Username == nil { + return "" + } + return *p.Username +} + +func statusFromProject(p *backend.Project) string { + if p == nil { + return "" + } + return p.Status +} diff --git a/tg_bot/screens/project_header.go b/tg_bot/screens/project_header.go new file mode 100644 index 0000000..99c3f08 --- /dev/null +++ b/tg_bot/screens/project_header.go @@ -0,0 +1,442 @@ +package screens + +import ( + "bytes" + _ "embed" + "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" + _ "image/png" + "strings" + "sync" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/rs/zerolog/log" + xdraw "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +//go:embed assets/fonts/JetBrainsMono-Bold.ttf +var jetBrainsMonoBold []byte + +//go:embed assets/fonts/JetBrainsMono-Regular.ttf +var jetBrainsMonoRegular []byte + +const projectHeaderCacheTTL = 30 * time.Minute +const projectHeaderCacheVersion = "img-v1" +const projectHeaderCacheMaxBytes = 32 * 1024 * 1024 + +type projectHeaderCacheEntry struct { + bytes []byte + sizeBytes int + uniqueID string + fetchedAt time.Time +} + +var projectHeaderCache = struct { + mu sync.Mutex + items map[string]projectHeaderCacheEntry + total int +}{ + items: make(map[string]projectHeaderCacheEntry), +} + +var projectHeaderLastMediaKey = struct { + mu sync.Mutex + items map[string]string +}{ + items: make(map[string]string), +} + +func updateProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, chatID int64, title string, username string, status string) { + if messageID == 0 || chatID == 0 || title == "" { + return + } + + cacheKey := projectHeaderCacheKey(chatID, username, status) + if cached, ok := getProjectHeaderFromCache(cacheKey); ok && time.Since(cached.fetchedAt) < projectHeaderCacheTTL { + mediaKey := cacheKey + ":" + cached.uniqueID + if shouldSkipMediaEdit(chatID, messageID, mediaKey) { + return + } + if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil { + setLastMediaKey(chatID, messageID, mediaKey) + b.SetLastMessageIsMedia(true) + } + return + } + + chatInfo, err := b.GetChat(chatID) + if err != nil { + log.Error().Err(err).Int64("chat_id", chatID).Msg("GetChat failed") + return + } + if chatInfo.Result == nil || chatInfo.Result.Photo == nil || chatInfo.Result.Photo.SmallFileID == "" { + return + } + + uniqueID := chatInfo.Result.Photo.SmallFileUniqueID + if cached, ok := getProjectHeaderFromCache(cacheKey); ok && cached.uniqueID == uniqueID && len(cached.bytes) > 0 { + setProjectHeaderCache(cacheKey, cached.bytes, uniqueID) + mediaKey := cacheKey + ":" + uniqueID + if shouldSkipMediaEdit(chatID, messageID, mediaKey) { + return + } + if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil { + setLastMediaKey(chatID, messageID, mediaKey) + b.SetLastMessageIsMedia(true) + } + return + } + + photoBytes, err := b.DownloadFileBytes(chatInfo.Result.Photo.SmallFileID) + if err != nil { + log.Error().Err(err).Int64("chat_id", chatID).Msg("Download chat photo failed") + return + } + + compositeBytes, err := buildProjectHeaderImage(photoBytes, title, username, status) + if err != nil { + log.Error().Err(err).Int64("chat_id", chatID).Msg("Build project header image failed") + return + } + setProjectHeaderCache(cacheKey, compositeBytes, uniqueID) + + mediaKey := cacheKey + ":" + uniqueID + if !shouldSkipMediaEdit(chatID, messageID, mediaKey) { + if err := editProjectHeaderMedia(b, messageID, caption, keyboard, compositeBytes, chatID); err == nil { + setLastMediaKey(chatID, messageID, mediaKey) + b.SetLastMessageIsMedia(true) + } + } +} + +func editProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, compositeBytes []byte, chatID int64) error { + if len(compositeBytes) == 0 { + return nil + } + + media := echotron.InputMediaPhoto{ + Type: echotron.MediaTypePhoto, + Media: echotron.NewInputFileBytes("project_header.jpg", compositeBytes), + Caption: caption, + ParseMode: echotron.HTML, + } + + _, err := b.EditMessageMedia( + echotron.NewMessageID(b.ChatID, messageID), + media, + &echotron.MessageMediaOptions{ + ReplyMarkup: keyboard, + }, + ) + if err != nil { + if strings.Contains(err.Error(), "message is not modified") { + log.Info().Int64("chat_id", chatID).Msg("EditMessageMedia not modified") + return nil + } + log.Error().Err(err).Int64("chat_id", chatID).Msg("EditMessageMedia failed") + return err + } + return nil +} + +func shouldSkipMediaEdit(chatID int64, messageID int, mediaKey string) bool { + key := fmt.Sprintf("%d:%d", chatID, messageID) + projectHeaderLastMediaKey.mu.Lock() + defer projectHeaderLastMediaKey.mu.Unlock() + lastKey, ok := projectHeaderLastMediaKey.items[key] + return ok && lastKey == mediaKey +} + +func setLastMediaKey(chatID int64, messageID int, mediaKey string) { + key := fmt.Sprintf("%d:%d", chatID, messageID) + projectHeaderLastMediaKey.mu.Lock() + defer projectHeaderLastMediaKey.mu.Unlock() + projectHeaderLastMediaKey.items[key] = mediaKey +} + +func projectHeaderCacheKey(chatID int64, username string, status string) string { + return fmt.Sprintf("%s:%d:%s:%s", projectHeaderCacheVersion, chatID, strings.ToUpper(username), strings.ToUpper(status)) +} + +func getProjectHeaderFromCache(key string) (projectHeaderCacheEntry, bool) { + projectHeaderCache.mu.Lock() + defer projectHeaderCache.mu.Unlock() + entry, ok := projectHeaderCache.items[key] + return entry, ok +} + +func setProjectHeaderCache(key string, value []byte, uniqueID string) { + projectHeaderCache.mu.Lock() + defer projectHeaderCache.mu.Unlock() + if existing, ok := projectHeaderCache.items[key]; ok { + projectHeaderCache.total -= existing.sizeBytes + } + projectHeaderCache.items[key] = projectHeaderCacheEntry{ + bytes: value, + sizeBytes: len(value), + uniqueID: uniqueID, + fetchedAt: time.Now(), + } + projectHeaderCache.total += len(value) + projectHeaderCacheEvictIfNeeded() +} + +func projectHeaderCacheEvictIfNeeded() { + for projectHeaderCache.total > projectHeaderCacheMaxBytes && len(projectHeaderCache.items) > 0 { + var oldestKey string + var oldestTime time.Time + first := true + for key, entry := range projectHeaderCache.items { + if first || entry.fetchedAt.Before(oldestTime) { + oldestKey = key + oldestTime = entry.fetchedAt + first = false + } + } + if oldestKey == "" { + return + } + projectHeaderCache.total -= projectHeaderCache.items[oldestKey].sizeBytes + delete(projectHeaderCache.items, oldestKey) + } +} + +func buildProjectHeaderImage(photoBytes []byte, title string, username string, status string) ([]byte, error) { + const ( + canvasW = 720 + canvasH = 260 + ) + + srcImg, _, err := image.Decode(bytes.NewReader(photoBytes)) + if err != nil { + return nil, err + } + + canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH)) + drawGradient(canvas, color.RGBA{R: 6, G: 8, B: 16, A: 255}, color.RGBA{R: 18, G: 10, B: 28, A: 255}) + + avatarSize := 168 + avatarX := 24 + avatarY := (canvasH - avatarSize) / 2 + + cropped := cropCenterSquare(srcImg) + scaled := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize)) + xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), cropped, cropped.Bounds(), xdraw.Over, nil) + + mask := circleMask(avatarSize) + draw.DrawMask( + canvas, + image.Rect(avatarX, avatarY, avatarX+avatarSize, avatarY+avatarSize), + scaled, + image.Point{}, + mask, + image.Point{}, + draw.Over, + ) + + textColor := image.NewUniform(color.RGBA{R: 245, G: 247, B: 250, A: 255}) + usernameColor := image.NewUniform(color.RGBA{R: 84, G: 156, B: 255, A: 255}) + textX := avatarX + avatarSize + 24 + textMaxWidth := canvasW - textX - 24 + + titleSize := fitFontSize(jetBrainsMonoBold, title, textMaxWidth, float64(canvasH)*0.22, 20) + titleFace, err := loadFontFace(jetBrainsMonoBold, titleSize) + if err != nil { + return nil, err + } + defer titleFace.Close() + + usernameText := formatUsername(username) + statusText, statusColor := statusInfo(status) + statusLine := usernameText + if statusText != "" { + if statusLine != "" { + statusLine += " | " + } + statusLine += statusText + } + + statusSize := titleSize * 0.6 + if statusSize < 14 { + statusSize = 14 + } + if statusLine != "" { + statusSize = fitFontSize(jetBrainsMonoRegular, statusLine, textMaxWidth, statusSize, 12) + } + statusFace, err := loadFontFace(jetBrainsMonoRegular, statusSize) + if err != nil { + return nil, err + } + defer statusFace.Close() + + titleMetrics := titleFace.Metrics() + statusMetrics := statusFace.Metrics() + lineGapRatio := 0.03 + lineGap := int(float64(canvasH) * lineGapRatio) + totalHeight := titleMetrics.Height.Ceil() + if statusLine != "" { + totalHeight += lineGap + statusMetrics.Height.Ceil() + } + verticalOffsetRatio := 0.02 + startY := (canvasH-totalHeight)/2 + titleMetrics.Ascent.Ceil() + int(float64(canvasH)*verticalOffsetRatio) + + drawText(canvas, titleFace, textColor, textX, startY, title) + if statusLine != "" { + statusY := startY + titleMetrics.Descent.Ceil() + lineGap + statusMetrics.Ascent.Ceil() + drawStatusLine(canvas, statusFace, textX, statusY, usernameText, statusText, usernameColor, textColor, statusColor) + } + var out bytes.Buffer + if err := jpeg.Encode(&out, canvas, &jpeg.Options{Quality: 85}); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func cropCenterSquare(img image.Image) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + size := w + if h < w { + size = h + } + x0 := b.Min.X + (w-size)/2 + y0 := b.Min.Y + (h-size)/2 + cropRect := image.Rect(x0, y0, x0+size, y0+size) + + if sub, ok := img.(interface { + SubImage(r image.Rectangle) image.Image + }); ok { + return sub.SubImage(cropRect) + } + + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.Draw(dst, dst.Bounds(), img, cropRect.Min, draw.Src) + return dst +} + +func circleMask(diameter int) *image.Alpha { + mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter)) + r := float64(diameter) / 2 + cx := r + cy := r + for y := 0; y < diameter; y++ { + for x := 0; x < diameter; x++ { + dx := float64(x) + 0.5 - cx + dy := float64(y) + 0.5 - cy + if dx*dx+dy*dy <= r*r { + mask.SetAlpha(x, y, color.Alpha{A: 255}) + } + } + } + return mask +} +func drawGradient(img *image.RGBA, top, bottom color.RGBA) { + b := img.Bounds() + h := b.Dy() + w := b.Dx() + for y := 0; y < h; y++ { + t := float64(y) / float64(h-1) + r := uint8(float64(top.R)*(1-t) + float64(bottom.R)*t) + g := uint8(float64(top.G)*(1-t) + float64(bottom.G)*t) + bb := uint8(float64(top.B)*(1-t) + float64(bottom.B)*t) + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: r, G: g, B: bb, A: 255}) + } + } +} + +func loadFontFace(fontData []byte, size float64) (font.Face, error) { + ft, err := opentype.Parse(fontData) + if err != nil { + return nil, err + } + return opentype.NewFace(ft, &opentype.FaceOptions{ + Size: size, + DPI: 72, + Hinting: font.HintingFull, + }) +} + +func fitFontSize(fontData []byte, text string, maxWidth int, startSize float64, minSize float64) float64 { + size := startSize + for size >= minSize { + face, err := loadFontFace(fontData, size) + if err != nil { + return size + } + width := font.MeasureString(face, text).Ceil() + face.Close() + if width <= maxWidth { + return size + } + size -= 2 + } + return minSize +} + +func drawText(dst *image.RGBA, face font.Face, src image.Image, x int, y int, text string) { + d := &font.Drawer{ + Dst: dst, + Src: src, + Face: face, + Dot: fixed.P(x, y), + } + d.DrawString(text) +} + +func formatUsername(username string) string { + username = strings.TrimSpace(username) + if username == "" { + return "" + } + if strings.HasPrefix(username, "@") { + return username + } + return "@" + username +} + +func statusInfo(status string) (string, color.RGBA) { + switch status { + case "active": + return "Активный", color.RGBA{R: 66, G: 211, B: 114, A: 255} + case "inactive": + return "Неактивен", color.RGBA{R: 160, G: 170, B: 180, A: 255} + case "archived": + return "Архивный", color.RGBA{R: 180, G: 180, B: 180, A: 255} + case "paused": + return "Приостановлен", color.RGBA{R: 245, G: 179, B: 66, A: 255} + default: + if strings.TrimSpace(status) == "" { + return "", color.RGBA{} + } + return status, color.RGBA{R: 160, G: 170, B: 180, A: 255} + } +} + +func drawStatusLine(dst *image.RGBA, face font.Face, x int, y int, usernameText string, statusText string, usernameColor image.Image, textColor image.Image, statusColor color.RGBA) { + drawX := x + if usernameText != "" { + drawText(dst, face, usernameColor, drawX, y, usernameText) + drawX += font.MeasureString(face, usernameText).Ceil() + } + if statusText != "" { + separator := " | " + if usernameText != "" { + drawText(dst, face, textColor, drawX, y, separator) + drawX += font.MeasureString(face, separator).Ceil() + } + statusSymbol := "●" + statusColorImg := image.NewUniform(statusColor) + drawText(dst, face, statusColorImg, drawX, y, statusSymbol) + drawX += font.MeasureString(face, statusSymbol).Ceil() + 6 + drawText(dst, face, textColor, drawX, y, statusText) + } +} diff --git a/tg_bot/screens/purchases.go b/tg_bot/screens/purchases.go index 632b9fb..3bb62a1 100644 --- a/tg_bot/screens/purchases.go +++ b/tg_bot/screens/purchases.go @@ -10,10 +10,13 @@ import ( ) type Placements struct { - WorkspaceID string - ProjectID string - ProjectTitle string - BackState bot.State + WorkspaceID string + ProjectID string + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + BackState bot.State } func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) { @@ -91,6 +94,9 @@ func (s *Placements) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string keyboard := Keyboard(buttons...) b.Render(text, keyboard, mode) + + messageID := b.LastMessageID + updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus) } func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) { @@ -108,11 +114,14 @@ func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) { case "add_purchase": b.SetState(&AddPurchase{ - WorkspaceID: s.WorkspaceID, - ProjectID: s.ProjectID, - ProjectTitle: s.ProjectTitle, - ActivePicker: "", - BackState: s, + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + ActivePicker: "", + BackState: s, }, bot.EditMessage) default: diff --git a/tg_bot/screens/select_channels_for_purchase.go b/tg_bot/screens/select_channels_for_purchase.go index 78161bc..38758f4 100644 --- a/tg_bot/screens/select_channels_for_purchase.go +++ b/tg_bot/screens/select_channels_for_purchase.go @@ -20,15 +20,18 @@ type PurchaseChannelInput struct { } type SelectChannelsForPurchase struct { - WorkspaceID string - ProjectID string - ProjectTitle string - CreativeID string - CreativeTitle string - Channels []PurchaseChannelInput - CurrentPage int - InvalidUsernames []string - BackState bot.State + WorkspaceID string + ProjectID string + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + CreativeID string + CreativeTitle string + Channels []PurchaseChannelInput + CurrentPage int + InvalidUsernames []string + BackState bot.State } var channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`) @@ -140,13 +143,16 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat case "back": // Возвращаемся к выбору креатива b.SetState(&AddPurchase{ - WorkspaceID: s.WorkspaceID, - ProjectID: s.ProjectID, - ProjectTitle: s.ProjectTitle, - CreativeID: s.CreativeID, - CreativeTitle: s.CreativeTitle, - ActivePicker: "", - BackState: s.BackState, + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + CreativeID: s.CreativeID, + CreativeTitle: s.CreativeTitle, + ActivePicker: "", + BackState: s.BackState, }, bot.EditMessage) case "next_step":