изображения projects!
This commit is contained in:
@@ -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,7 +380,31 @@ 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(
|
||||
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{
|
||||
@@ -376,14 +412,24 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -17,14 +17,28 @@ type AddPurchase struct {
|
||||
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]string
|
||||
lastCreatives map[string]string
|
||||
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) {
|
||||
@@ -261,6 +291,9 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
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{},
|
||||
@@ -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 = ""
|
||||
|
||||
BIN
tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf
Normal file
BIN
tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf
Normal file
Binary file not shown.
BIN
tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf
Normal file
BIN
tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf
Normal file
Binary file not shown.
@@ -16,6 +16,10 @@ const creativesPerPage = 6
|
||||
type Creatives struct {
|
||||
CurrentPage int
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
ProjectTelegramID int64
|
||||
ProjectUsername string
|
||||
ProjectStatus string
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,13 +128,17 @@ 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,
|
||||
ProjectTitle: s.Project.Title,
|
||||
ProjectTelegramID: s.Project.TelegramID,
|
||||
ProjectUsername: usernameFromProject(s.Project),
|
||||
ProjectStatus: statusFromProject(s.Project),
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
@@ -140,6 +149,9 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
442
tg_bot/screens/project_header.go
Normal file
442
tg_bot/screens/project_header.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ type Placements struct {
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
ProjectTelegramID int64
|
||||
ProjectUsername string
|
||||
ProjectStatus string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -111,6 +117,9 @@ func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ProjectTelegramID: s.ProjectTelegramID,
|
||||
ProjectUsername: s.ProjectUsername,
|
||||
ProjectStatus: s.ProjectStatus,
|
||||
ActivePicker: "",
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
@@ -23,6 +23,9 @@ type SelectChannelsForPurchase struct {
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
ProjectTelegramID int64
|
||||
ProjectUsername string
|
||||
ProjectStatus string
|
||||
CreativeID string
|
||||
CreativeTitle string
|
||||
Channels []PurchaseChannelInput
|
||||
@@ -143,6 +146,9 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
||||
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: "",
|
||||
|
||||
Reference in New Issue
Block a user