Files
3x-ui/internal/web/service/tgbot/screens_art.go
T
DIMFLIX 15fee2a035 feat(tgbot): rebuild the Telegram bot around editable screens and inline lists (#6760)
The bot answered every action with a new message, so a chat filled with stale
menus whose buttons still acted, and long client or inbound lists were paged
through dozens of buttons. Each chat now has one live screen that is edited in
place: the menu is grouped into Server, Clients, Traffic and Maintenance, lists
open through inline search, the add-client flow starts from a draft card, QR
codes arrive as one labelled sheet, and repeated notices collapse into one with
a counter. A client's /start and /usage show only that client's own links and
usage, groups are refused, and the settings page warns when the bot's inline
mode is off. Admin topics, the indexed Telegram lookup and every locale,
Slovak included, carry over.
2026-10-24 12:39:56 +02:00

180 lines
5.3 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
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
}
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() {
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",
}