443 lines
12 KiB
Go
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)
|
|
}
|
|
}
|