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