изображения 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 (
"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()

View File

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

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/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=

View File

@@ -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 = ""

Binary file not shown.

Binary file not shown.

View File

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

View File

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

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

View File

@@ -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: "",