Files
3x-ui/internal/web/service/tgbot/screens_art.go
T
MHSanaei 757444d505 fix(tgbot): let a screen-art reset outlast a probe still in flight
TestScreenArtReprobeWindow failed under -race -shuffle in CI: the probe
slot is process-wide, and a probe an earlier test started against its
own (now closing) server still held it when the next test asked for a
re-probe, so that request was dropped and the stale avatar never
re-probed. The leftover probe could also write its result over the
state the next test had just reset.

Probes now run in a WaitGroup that resetScreenArt waits on, so a reset
between mock servers always starts from an idle probe slot.
2026-11-01 14:56:40 +01:00

183 lines
5.4 KiB
Go

package tgbot
import (
"bytes"
"context"
"image"
"image/color"
"image/draw"
"image/png"
"sync"
"sync/atomic"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// Screen art: the bot's avatar serves every screen's picture, and a panel without
// one gets a generated black tile, so a screen never fails for want of a picture.
var (
screenArtMu sync.RWMutex
screenArtIDs = map[string]string{}
// artOverrides is artwork the panel set for one screen kind. It outranks
// the bot's avatar, so adopting an avatar never silently replaces it.
artOverrides = map[string]string{}
// artFromAvatar records whether the cached ids came from the bot's avatar: a
// tile cached from an upload must never win over an avatar set afterwards.
artFromAvatar bool
artProbedAt time.Time
// artProbeRunning keeps a burst of renders from starting a burst of probes.
artProbeRunning atomic.Bool
// artProbes lets resetScreenArt outlast a probe still running against a previous bot.
artProbes sync.WaitGroup
blackTileOnce sync.Once
blackTileBytes []byte
)
// Probes are cheap but not free: a known avatar is trusted for a while, a missing
// one retried sooner, so an uploaded picture shows without a restart.
const (
artAvatarTTL = 10 * time.Minute
artRetryTTL = time.Minute
)
// resolveScreenArt caches the bot avatar's file_id for every screen kind.
// Telegram serves one photo per bot, so all kinds share it.
func (t *Tgbot) resolveScreenArt() {
// One read: the panel can swap the bot under us (login/logout), and reading
// the global per call is a nil-pointer crash in this background goroutine.
b := liveBot()
if b == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
me, err := b.GetMe(ctx)
if err != nil {
logger.Warning("Failed to read the bot identity for screen art:", err)
return
}
// Use getUserProfilePhotos, not getChat: getChat's ChatPhoto file_id is
// refused in sendPhoto ("can't use file of type ChatPhoto as Photo").
photos, err := b.GetUserProfilePhotos(ctx, &telego.GetUserProfilePhotosParams{
UserID: me.ID, Limit: 1,
})
if err != nil {
logger.Debug("Failed to read the bot's profile photos for screen art:", err)
return
}
screenArtMu.Lock()
defer screenArtMu.Unlock()
artProbedAt = time.Now()
if len(photos.Photos) == 0 || len(photos.Photos[0]) == 0 {
// Remember the miss so the next render does not probe again, but keep
// any tile already cached: an avatar-less bot still shows a picture.
artFromAvatar = false
logger.Debug("Bot has no avatar; screens will use the black fallback")
return
}
// The last entry is the largest size Telegram keeps.
sizes := photos.Photos[0]
fileID := sizes[len(sizes)-1].FileID
screenArtIDs = map[string]string{}
for _, kind := range screenKinds {
screenArtIDs[kind] = fileID
}
artFromAvatar = true
}
// ensureScreenArt refreshes stale cached art off the render path, at most one
// probe in flight, so a just-uploaded picture shows without a restart.
func (t *Tgbot) ensureScreenArt() {
screenArtMu.RLock()
fromAvatar, at := artFromAvatar, artProbedAt
screenArtMu.RUnlock()
ttl := artRetryTTL
if fromAvatar {
ttl = artAvatarTTL
}
if time.Since(at) <= ttl {
return
}
if !artProbeRunning.CompareAndSwap(false, true) {
return
}
artProbes.Go(func() {
defer artProbeRunning.Store(false)
t.resolveScreenArt()
})
}
// artFileID returns the cached file_id for a screen kind. A fallback tile cached
// for one kind must not answer for another, so the per-kind entry is authoritative.
func artFileID(kind string) (string, bool) {
screenArtMu.RLock()
defer screenArtMu.RUnlock()
if id, ok := artOverrides[kind]; ok && id != "" {
return id, true
}
id, ok := screenArtIDs[kind]
return id, ok && id != ""
}
// artUpload is the last-resort picture: the generated black tile.
func artUpload(kind string) (telego.InputFile, bool) {
data := blackTile()
return tu.FileFromBytes(data, "screen.png"), data != nil
}
// blackTile builds the 512x512 black tile once.
func blackTile() []byte {
blackTileOnce.Do(func() {
img := image.NewRGBA(image.Rect(0, 0, 512, 512))
draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{R: 13, G: 15, B: 20, A: 255}}, image.Point{}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
logger.Warning("Failed to encode the fallback screen art:", err)
return
}
blackTileBytes = buf.Bytes()
})
return blackTileBytes
}
// cacheArtFileID remembers the file_id Telegram returned for an uploaded tile,
// so that screen edits in place. It does NOT mark the art as the avatar.
func cacheArtFileID(kind, fileID string) {
if kind == "" || fileID == "" {
return
}
screenArtMu.Lock()
if !artFromAvatar {
screenArtIDs[kind] = fileID
}
screenArtMu.Unlock()
}
// resetScreenArt drops the cached file_ids; tests use it between mock servers.
func resetScreenArt() {
artProbes.Wait()
screenArtMu.Lock()
screenArtIDs = map[string]string{}
artOverrides = map[string]string{}
artFromAvatar = false
artProbedAt = time.Time{}
screenArtMu.Unlock()
}
// screenKinds is the catalogue of screen pictures: every kind resolves to the
// same avatar, but the names keep each screen's intent visible in the code.
var screenKinds = []string{
"main", "status", "inbounds", "clients", "client", "links", "qr",
"wizard", "reports", "backup", "onlines", "deplete", "commands", "broadcast",
}