изображения projects!

This commit is contained in:
Artem Tsyrulnikov
2026-01-19 21:47:44 +03:00
parent f7ad52f543
commit f4a876836e
11 changed files with 682 additions and 73 deletions

View File

@@ -2,6 +2,9 @@ package bot
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@@ -28,6 +31,9 @@ type Bot struct {
CurrentState State CurrentState State
Session *Session Session *Session
LastMessageID int // ID последнего сообщения с inline кнопками 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) commandRouter func(cmd string) State // Роутер для команд (например, /start, /help)
globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
Backend *backend.Client 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) { func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
res, err := b.GetFile(fileID) res, err := b.GetFile(fileID)
if err != nil { if err != nil {
@@ -335,7 +345,6 @@ 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.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)
@@ -357,6 +366,9 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
if res.Result != nil { if res.Result != nil {
b.LastMessageID = res.Result.ID 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") log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew")
} }
} }
@@ -368,7 +380,31 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
Int("keyboard_rows", len(keyboard.InlineKeyboard)). Int("keyboard_rows", len(keyboard.InlineKeyboard)).
Int("text_len", len(text)). Int("text_len", len(text)).
Msg("EditMessageText request") Msg("EditMessageText request")
_, err := b.EditMessageText( 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, text,
echotron.NewMessageID(b.ChatID, b.LastMessageID), echotron.NewMessageID(b.ChatID, b.LastMessageID),
&echotron.MessageTextOptions{ &echotron.MessageTextOptions{
@@ -376,14 +412,24 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
ParseMode: echotron.HTML, ParseMode: echotron.HTML,
}, },
) )
if err == nil {
b.LastMessageTextHash = textHash
b.LastMessageKBHash = kbHash
}
}
if err != nil { if err != nil {
if strings.Contains(err.Error(), "message is not modified") { 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 { if b.exec != nil {
b.exec.handled = true b.exec.handled = true
} }
return 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") log.Error().Err(err).Msg("EditMessageText")
} }
} else { } else {
@@ -420,9 +466,26 @@ func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) {
if err == nil && res.Result != nil { if err == nil && res.Result != nil {
b.LastMessageID = res.Result.ID 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) { func (b *Bot) Update(u *echotron.Update) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()

View File

@@ -3,15 +3,16 @@ module github.com/TelegramExchange/tgex-backend/tg_bot
go 1.24.4 go 1.24.4
require ( require (
github.com/NicoNex/echotron/v3 v3.43.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/TelegramExchange/pkg v0.0.0 golang.org/x/image v0.31.0
) )
require ( require (
github.com/NicoNex/echotron/v3 v3.43.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.39.0 // 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 golang.org/x/time v0.5.0 // indirect
) )

View File

@@ -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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=

View File

@@ -17,14 +17,28 @@ type AddPurchase struct {
WorkspaceID string WorkspaceID string
ProjectID string ProjectID string
ProjectTitle string ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
CreativeID string CreativeID string
CreativeTitle string CreativeTitle string
ProjectPage int ProjectPage int
CreativePage int CreativePage int
ActivePicker string // "" | "project_list" | "creative_list" ActivePicker string // "" | "project_list" | "creative_list"
BackState bot.State BackState bot.State
lastProjects map[string]string lastProjects map[string]projectInfo
lastCreatives map[string]string 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) { 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 return
} }
s.lastProjects = make(map[string]string) s.lastProjects = make(map[string]projectInfo)
for _, project := range page.Items { 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 { if len(page.Items) == 0 {
@@ -143,9 +166,11 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin
return return
} }
s.lastCreatives = make(map[string]string) s.lastCreatives = make(map[string]creativeInfo)
for _, creative := range page.Items { for _, creative := range page.Items {
s.lastCreatives[creative.ID] = creative.Name s.lastCreatives[creative.ID] = creativeInfo{
Title: creative.Name,
}
} }
if len(page.Items) == 0 { if len(page.Items) == 0 {
@@ -202,6 +227,11 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt strin
keyboard := Keyboard(buttons...) keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode) 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) { func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
@@ -261,6 +291,9 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle, ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
CreativeID: s.CreativeID, CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle, CreativeTitle: s.CreativeTitle,
Channels: []PurchaseChannelInput{}, Channels: []PurchaseChannelInput{},
@@ -272,7 +305,12 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
if len(data) > 8 && data[:8] == "project:" { if len(data) > 8 && data[:8] == "project:" {
projectID := data[8:] projectID := data[8:]
if s.lastProjects != nil { 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.ProjectID = projectID
s.CreativeID = "" s.CreativeID = ""
@@ -285,7 +323,9 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
if len(data) > 9 && data[:9] == "creative:" { if len(data) > 9 && data[:9] == "creative:" {
creativeID := data[9:] creativeID := data[9:]
if s.lastCreatives != nil { if s.lastCreatives != nil {
s.CreativeTitle = s.lastCreatives[creativeID] if info, ok := s.lastCreatives[creativeID]; ok {
s.CreativeTitle = info.Title
}
} }
s.CreativeID = creativeID s.CreativeID = creativeID
s.ActivePicker = "" s.ActivePicker = ""

Binary file not shown.

Binary file not shown.

View File

@@ -16,6 +16,10 @@ const creativesPerPage = 6
type Creatives struct { type Creatives struct {
CurrentPage int CurrentPage int
ProjectID string ProjectID string
ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
WorkspaceID string WorkspaceID string
BackState bot.State BackState bot.State
} }
@@ -118,6 +122,9 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
keyboard := Keyboard(buttons...) keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode) 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) { func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {

View File

@@ -29,6 +29,11 @@ func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
text := s.formatProjectDetails() text := s.formatProjectDetails()
keyboard := s.buildKeyboard() keyboard := s.buildKeyboard()
b.Render(text, keyboard, mode) 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 { func (s *ProjectDetails) formatProjectDetails() string {
@@ -123,13 +128,17 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
case data == "back_to_projects", data == "back": case data == "back_to_projects", data == "back":
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
if s.BackState != nil { if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage) s.transitionWithNewMessage(b, s.BackState)
} }
case strings.HasPrefix(data, "creatives:"): case strings.HasPrefix(data, "creatives:"):
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
b.SetState(&Creatives{ b.SetState(&Creatives{
ProjectID: s.Project.ID, ProjectID: s.Project.ID,
ProjectTitle: s.Project.Title,
ProjectTelegramID: s.Project.TelegramID,
ProjectUsername: usernameFromProject(s.Project),
ProjectStatus: statusFromProject(s.Project),
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
BackState: s, BackState: s,
}, bot.EditMessage) }, bot.EditMessage)
@@ -140,6 +149,9 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.Project.ID, ProjectID: s.Project.ID,
ProjectTitle: s.Project.Title, ProjectTitle: s.Project.Title,
ProjectTelegramID: s.Project.TelegramID,
ProjectUsername: usernameFromProject(s.Project),
ProjectStatus: statusFromProject(s.Project),
BackState: s, BackState: s,
}, bot.EditMessage) }, bot.EditMessage)
@@ -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) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *ProjectDetails) Exit() {} 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
}

View File

@@ -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)
}
}

View File

@@ -13,6 +13,9 @@ type Placements struct {
WorkspaceID string WorkspaceID string
ProjectID string ProjectID string
ProjectTitle string ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
BackState bot.State BackState bot.State
} }
@@ -91,6 +94,9 @@ func (s *Placements) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string
keyboard := Keyboard(buttons...) keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode) 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) { func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) {
@@ -111,6 +117,9 @@ func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) {
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle, ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
ActivePicker: "", ActivePicker: "",
BackState: s, BackState: s,
}, bot.EditMessage) }, bot.EditMessage)

View File

@@ -23,6 +23,9 @@ type SelectChannelsForPurchase struct {
WorkspaceID string WorkspaceID string
ProjectID string ProjectID string
ProjectTitle string ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
CreativeID string CreativeID string
CreativeTitle string CreativeTitle string
Channels []PurchaseChannelInput Channels []PurchaseChannelInput
@@ -143,6 +146,9 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle, ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
CreativeID: s.CreativeID, CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle, CreativeTitle: s.CreativeTitle,
ActivePicker: "", ActivePicker: "",