mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-29 01:04:59 +03:00
15fee2a035
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.
180 lines
5.3 KiB
Go
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",
|
|
}
|