Files
tgex-backend/tg_bot/screens/project_header.go
2026-01-19 21:47:44 +03:00

443 lines
12 KiB
Go

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