Files
tiennm99 7008a57cf5 refactor!: rebrand miti99bot to tiennm99bot
BREAKING CHANGE: the Go module path is now github.com/tiennm99/tiennm99bot,
the default sticker pack is stickers_by_<bot username>, and the health body,
deploy DM, User-Agents and image names say tiennm99bot.
2026-10-07 15:14:02 +07:00

325 lines
14 KiB
Go

package sticker
import (
"context"
"errors"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper"
)
const (
// stickerPackNameEnv overrides which set /addsticker writes to. The name
// must end in "_by_<bot_username>", the only thing that makes a set
// bot-manageable; packTitle checks it before any upload. A set that does
// not exist yet is created by the first successful /addsticker.
stickerPackNameEnv = "STICKER_PACK_NAME"
// defaultStickerPackSlug names the shared pack used when the env is unset.
// The full name is this slug plus "_by_<bot_username>", so the default
// follows whichever bot runs the code.
defaultStickerPackSlug = "stickers"
// stickerPackOwnerEnv reuses the bot-wide owner setting rather than
// introducing a second variable: AddStickerToSet needs the *set owner's*
// user ID, and the default pack above belongs to the bot owner. A pack
// owned by any other account needs this env pointed at that account.
stickerPackOwnerEnv = "OWNER_ID"
// InputSticker.Format values. Since Bot API 7.2 the format is a property of
// each sticker rather than of the set — createNewStickerSet lost its
// sticker_format parameter and StickerSet lost is_animated/is_video — so
// one pack holds all three side by side and no migration is needed to
// start adding a new one.
stickerFormatStatic = "static" // .WEBP or .PNG
stickerFormatAnimated = "animated" // .TGS
stickerFormatVideo = "video" // .WEBM
// maxStickersPerPack is Telegram's documented ceiling for a regular set.
// Not enforced locally — the server is the authority and a local copy would
// go stale — but quoted back to the user when the server refuses.
maxStickersPerPack = 120
// maxSetNameLen is Telegram's cap on a sticker set's short name.
maxSetNameLen = 64
// setNameSuffix prefixes the mandatory "_by_<bot_username>" tail.
setNameSuffix = "_by_"
// stickerHandlerTimeout bounds the /addsticker handler for a still source.
//
// Nothing else does. The bot registers handlers with
// bot.WithNotAsyncHandlers() and one worker, so updates run inline on the
// polling goroutine, and the handler context is rootCtx, which carries no
// deadline — the only remaining ceiling is the library's shared 60s HTTP
// client, per call. The photo path makes three sequential API calls and
// could otherwise freeze the bot for every user for minutes.
stickerHandlerTimeout = 10 * time.Second
// stickerVideoHandlerTimeout is the same bound for a source that has to be
// transcoded: the download can be ten times larger and an ffmpeg encode
// sits between it and the upload.
//
// The still budget is kept separate rather than raising both, because this
// number is time the whole bot is unresponsive and only one of the two
// paths needs it.
stickerVideoHandlerTimeout = 45 * time.Second
)
// stickerPack is the resolved target of /addsticker.
type stickerPack struct {
Name string // Telegram set name, e.g. "stickers_by_examplebot"; empty until defaulted
OwnerID int64 // the account the set belongs to; AddStickerToSet demands it
}
// errNoPackOwner means the owner ID is unset, so no sticker can be added.
// Internal, not user-facing: nothing the caller does fixes a misconfiguration.
var errNoPackOwner = errors.New("util: sticker pack owner ID unset")
// loadStickerPack resolves the shared pack from the environment. Name is left
// empty when STICKER_PACK_NAME is unset: the default depends on the bot's
// username, which the caller fills in with defaultPackName once resolved.
//
// Read per invocation rather than captured at startup, matching how gold and
// lol read their credentials: it keeps the command testable with t.Setenv and
// costs nothing next to the API calls that follow.
func loadStickerPack() (stickerPack, error) {
name := strings.TrimSpace(os.Getenv(stickerPackNameEnv))
ownerID, err := strconv.ParseInt(strings.TrimSpace(os.Getenv(stickerPackOwnerEnv)), 10, 64)
if err != nil || ownerID == 0 {
return stickerPack{}, errNoPackOwner
}
return stickerPack{Name: name, OwnerID: ownerID}, nil
}
// defaultPackName is the shared pack's name for a bot with this username.
func defaultPackName(botUsername string) string {
return defaultStickerPackSlug + setNameSuffix + botUsername
}
// stickerShareLink is the public URL of a sticker set.
func stickerShareLink(setName string) string {
return "https://t.me/addstickers/" + setName
}
// errNoUsername means the bot's own username is unavailable, so the configured
// pack cannot be checked against it. Internal and transient, not user-facing.
var errNoUsername = errors.New("util: bot has no username")
// errPackNotBotOwned means the configured name cannot belong to a set this bot
// created. Internal: the reply is a fixed operator-facing sentence.
var errPackNotBotOwned = errors.New("util: configured pack is not manageable by this bot")
// packNotBotOwnedRefusal answers a configured name this bot provably cannot
// manage. It names no environment variable — the detail goes to the log.
const packNotBotOwnedRefusal = "The shared pack is not one this bot can manage. Ask the bot owner to check its configuration."
// packNameTakenRefusal answers a name that is occupied by a set this bot cannot
// write to: created by another bot, or by this bot for a different owner.
const packNameTakenRefusal = "A sticker set with that name already exists and this bot cannot manage it. Ask the bot owner to check it."
// packTitle validates that name could only be a set this bot created, and
// returns the slug half of it for use as the title at creation time.
//
// The "_by_<bot_username>" suffix is Telegram's own proof of authorship:
// createNewStickerSet *requires* the calling bot's username there, so a name
// without it cannot have come from this bot, and a name with it cannot have
// come from another. That makes the check purely local — no API call, and no
// need for a field the API does not expose.
//
// It cannot prove the *owner*, only the creator. StickerSet carries no owner
// ID, so "created by this bot but for a different user" is indistinguishable
// here and only surfaces when Telegram refuses the write.
func packTitle(name, botUsername string) (string, error) {
if botUsername == "" {
return "", errNoUsername
}
if len(name) > maxSetNameLen {
return "", errPackNotBotOwned
}
suffix := setNameSuffix + botUsername
// Strictly longer, not equal: the slug half must carry at least one
// character, and Telegram requires a name to begin with a letter.
if len(name) <= len(suffix) || !strings.EqualFold(name[len(name)-len(suffix):], suffix) {
return "", errPackNotBotOwned
}
return name[:len(name)-len(suffix)], nil
}
// botUsernameResolver caches the bot's username.
//
// main seeds it from BOT_USERNAME or a startup getMe. The bot starts with
// bot.WithSkipGetMe(), so when neither produced a username nothing populates
// one until this asks. Failures are never cached: a transient GetMe error must
// not disable /addsticker for the process's lifetime.
type botUsernameResolver struct {
mu sync.Mutex
username string
}
// resolve returns the bot's username, calling GetMe at most once per success.
// It takes the handler's *bot.Bot rather than Deps.Bot, which is documented
// nil-safe and is nil under BuildOptions{}.
func (r *botUsernameResolver) resolve(ctx context.Context, b *bot.Bot) (string, error) {
r.mu.Lock()
cached := r.username
r.mu.Unlock()
if cached != "" {
return cached, nil
}
me, err := b.GetMe(ctx)
if err != nil {
return "", err
}
if me == nil || me.Username == "" {
return "", errNoUsername
}
r.mu.Lock()
r.username = me.Username
r.mu.Unlock()
return me.Username, nil
}
// isStickerSetMissing reports whether err positively says the set does not
// exist on Telegram's side.
//
// Classification is positive-only, and deliberately so: this is the signal
// that authorises *creating* a set. A network blip, a 429, or a context
// cancelled by SIGTERM must never be read as "the pack is gone", or a routine
// failure would turn into an attempt to create a set that already exists.
func isStickerSetMissing(err error) bool {
return errors.Is(err, bot.ErrorBadRequest) &&
strings.Contains(err.Error(), "STICKERSET_INVALID")
}
// isPackNameOccupied reports whether err says the short name is already taken.
//
// Reaching this after isStickerSetMissing is the one proof available that a set
// exists under the configured name which this bot cannot write to. Kept as its
// own predicate rather than folded into apiRefusal, because that function's job
// is wording and this one's is a control-flow decision.
func isPackNameOccupied(err error) bool {
return err != nil && contains(err.Error(), "PACK_SHORT_NAME_OCCUPIED", "already occupied")
}
// userError carries text meant to be shown to the user verbatim.
//
// There are two kinds of failure here and they must never be confused: a
// refusal the user can act on ("that image is too large"), and an internal
// failure that must not reach a reply at all — a transport error's text can
// embed the bot token. Wrapping the first kind in a distinct type makes "is
// this safe to echo?" a type question instead of a judgement call per site.
type userError struct{ msg string }
func (e userError) Error() string { return e.msg }
// refuse builds a userError. Its text is replied verbatim, so write it as a
// sentence addressed to the user.
func refuse(msg string) error { return userError{msg: msg} }
const genericFailure = "Something went wrong. Try again in a moment."
// stickerHandlerContext applies the /addsticker deadline for the path the
// replied source will take.
func stickerHandlerContext(ctx context.Context, transcoding bool) (context.Context, context.CancelFunc) {
if transcoding {
return context.WithTimeout(ctx, stickerVideoHandlerTimeout)
}
return context.WithTimeout(ctx, stickerHandlerTimeout)
}
// mediaContext bounds the download-and-upload leg, reserving the tail of the
// parent's budget for the reply that follows.
//
// The photo path spends most of its deadline before it has anything to say.
// Run on the bare handler context, a slow link exhausted the whole 10s inside
// the media leg, and the reply — including the error reply explaining what went
// wrong — was then sent on a dead context, so the user saw nothing at all.
func mediaContext(ctx context.Context) (context.Context, context.CancelFunc) {
return chathelper.FetchContext(ctx)
}
// replyErr turns a handler error into a reply.
//
// A userError is shown verbatim — it was written for the user. Anything else is
// logged and replaced with a generic line: internal errors can carry a download
// URL with the bot token in it, and that must never be echoed.
func replyErr(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error) error {
var ue userError
if errors.As(err, &ue) {
return chathelper.Reply(ctx, b, msg, ue.msg)
}
log.Error(op, "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
}
// replyMisconfigured answers a bad pack configuration.
//
// Distinct from replyErr because these failures need both halves: the operator
// needs the precise cause in the log, and the user needs a sentence that tells
// them to go tell the owner. A userError would give the second without the
// first; a plain error would give the first and a useless generic reply.
func replyMisconfigured(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error, text string) error {
log.Error(op, "err", err)
return chathelper.Reply(ctx, b, msg, text)
}
// apiRefusal maps a Telegram API error to user-facing text, or returns ok=false
// when the error has no specific meaning and should be treated as a failure.
//
// Matching is on MTProto code substrings rather than prose. The Bot API server
// rewrites only some of these into English (STICKER_EMOJI_INVALID); the rest
// arrive as "Bad Request: <CODE>", and the prose could change without notice.
// Both forms are matched where they differ.
func apiRefusal(err error) (string, bool) {
if err == nil {
return "", false
}
text := err.Error()
switch {
case contains(text, "PACK_SHORT_NAME_INVALID", "invalid sticker set name"):
return "Telegram rejected the shared pack's name. Ask the bot owner to check its configuration.", true
case contains(text, "PACK_TITLE_INVALID"):
return "Telegram rejected the shared pack's title. Ask the bot owner to check its configuration.", true
case contains(text, "STICKERS_TOO_MUCH"):
return "The shared pack is full (" + strconv.Itoa(maxStickersPerPack) + " stickers).", true
case contains(text, "STICKER_EMOJI_INVALID", "invalid sticker emojis"):
return "Telegram rejected those emoji. Try different ones.", true
case contains(text, "too many emoji specified"):
return "At most 20 emoji per sticker.", true
case contains(text, "STICKER_PNG_DIMENSIONS", "STICKER_DIMENSIONS_INVALID"):
return "Telegram rejected that image's dimensions.", true
}
return "", false
}
// replyAPIError converts a Telegram API error into a reply. Errors with no
// specific mapping are logged and answered generically — the raw error never
// reaches the user.
func replyAPIError(ctx context.Context, b *bot.Bot, msg *models.Message, op string, err error) error {
if text, ok := apiRefusal(err); ok {
return chathelper.Reply(ctx, b, msg, text)
}
log.Error(op, "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
}
func contains(text string, needles ...string) bool {
for _, n := range needles {
if strings.Contains(text, n) {
return true
}
}
return false
}