mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 19:55:04 +03:00
757444d505
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.
183 lines
5.4 KiB
Go
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",
|
|
}
|