Files
miti99bot/internal/modules/util/addsticker_command.go
T
tiennm99 28b1740fe4 feat(util): replace per-user sticker packs with one shared, self-creating pack
/addsticker becomes a single stateless command in util, writing to one
env-configured set (STICKER_PACK_NAME, default miti99_by_miti99bot).
AddStickerToSet takes the set owner's user ID rather than the caller's, so
nothing is per-user any more: the sticker module's pack records, slug
reservations, pending deletes, per-user locks and its eight other commands are
removed with it.

The pack creates itself on first use. A positive STICKERSET_INVALID from the
add triggers createNewStickerSet owned by OWNER_ID, seeded with the sticker
that triggered it and titled with the slug half of the name; a name that is
occupied but unwritable is reported instead of taken over. The mandatory
"_by_<bot_username>" suffix is Telegram's own proof of authorship, so a
misconfigured pack name is refused offline before any API call. StickerSet
exposes no owner ID, so ownership is only provable when Telegram refuses.

Video, GIF, animation and video-note sources are transcoded to WEBM/VP9 with
ffmpeg: long edge scaled to exactly 512 in either direction, cut to 3s, capped
at 30fps, audio dropped, retried down a CRF ladder until under 256KB. Animated
and video stickers are copied by file_id with no conversion. Sticker format is
per-sticker since Bot API 7.2, so one pack holds all three.

ffmpeg cannot ship in distroless/static and Go has no VP9 encoder, so the
runtime base becomes alpine with apk add ffmpeg. The image grows from roughly
20MB to 213MB, and the transcode holds the single dispatcher worker — bounded
at 20s per encode and a 45s handler deadline for moving sources, against 10s
for stills.
2026-09-04 10:34:29 +07:00

375 lines
13 KiB
Go

package util
import (
"bytes"
"context"
"strings"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
)
// usageAddSticker is the "you must reply to something" line.
const usageAddSticker = "Reply to a sticker, image, video or GIF with this command."
// imageDocumentMimes is the allowlist for a replied document handled as a still
// image. Anything else is rejected before a single byte is downloaded.
var imageDocumentMimes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/webp": true,
}
// movingDocumentMimes is the allowlist for a replied document handled as video.
//
// image/gif lives here rather than with the images: Go's stdlib would decode
// only its first frame, and a GIF is sent to be animated. Transcoding it to
// WEBM keeps the motion.
var movingDocumentMimes = map[string]bool{
"image/gif": true,
"video/mp4": true,
"video/webm": true,
"video/quicktime": true,
"video/x-matroska": true,
}
// stickerSource is a resolved sticker ready to be added to a set: whatever the
// replied message carried, reduced to a file_id and the format that file is in.
type stickerSource struct {
fileID string // usable directly as InputSticker.Sticker
format string // one of the stickerFormat* constants
emoji []string // inherited from a replied sticker; at most one element
}
// addStickerCommand returns /addsticker — public, and deliberately unprefixed
// so the name matches what @Stickers uses.
//
// Every caller writes to the same shared pack, named by STICKER_PACK_NAME. The
// caller's own identity is not used anywhere: AddStickerToSet takes the *set
// owner's* user ID, so there is nothing per-user to store, key, or lock, and
// no ownership to check. That is what lets this live in util as one stateless
// command rather than as a module.
//
// The resolver is built here and captured by the handler so the bot's username
// is fetched at most once per process rather than once per invocation.
func addStickerCommand() modules.Command {
resolver := &botUsernameResolver{}
return modules.Command{
Name: "addsticker",
Visibility: modules.VisibilityPublic,
Description: "Add the replied sticker, image or video to the shared pack",
Parameters: "[emoji...]",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
return handleAddSticker(ctx, b, update, resolver)
},
}
}
func handleAddSticker(ctx context.Context, b *bot.Bot, update *models.Update, resolver *botUsernameResolver) error {
msg := update.Message
if msg == nil {
return nil
}
// The deadline is chosen from the source type, which the replied message
// states without any API call. A transcode cannot fit the image budget, and
// giving every invocation the video budget would let the still path hold the
// single dispatcher worker far longer than it ever needs.
ctx, cancel := stickerHandlerContext(ctx, hasMovingSource(msg.ReplyToMessage))
defer cancel()
pack, err := loadStickerPack()
if err != nil {
// A misconfiguration, not something the caller can fix: log it and
// answer generically rather than explaining the bot's own env.
return replyErr(ctx, b, msg, "util_addsticker_config", err)
}
// Checked before anything is downloaded or uploaded: a pack this bot cannot
// manage is a dead end, and the create path below would strand the work.
username, err := resolver.resolve(ctx, b)
if err != nil {
return replyErr(ctx, b, msg, "util_addsticker_username", err)
}
title, err := packTitle(pack.Name, username)
if err != nil {
return replyMisconfigured(ctx, b, msg, "util_addsticker_pack_name", err, packNotBotOwnedRefusal)
}
// Every argument is an emoji: there is no pack token to disambiguate, so a
// stray word fails here rather than being silently read as something else.
emoji, err := parseEmoji(strings.Fields(chathelper.ArgAfterCommand(msg.Text)))
if err != nil {
return replyErr(ctx, b, msg, "util_addsticker_emoji", err)
}
source, err := resolveStickerSource(ctx, b, pack.OwnerID, msg)
if err != nil {
return replyErr(ctx, b, msg, "util_addsticker_source", err)
}
// Precedence: explicit args, then the replied sticker's own emoji, then the
// default. Telegram requires at least one.
if len(emoji) == 0 {
emoji = source.emoji
}
if len(emoji) == 0 {
emoji = []string{defaultEmoji}
}
sticker := models.InputSticker{
Sticker: source.fileID,
Format: source.format,
EmojiList: emoji,
}
// Add first, create only when the add proves the set is missing. The
// alternative — probe with getStickerSet, then add — costs an extra call on
// every invocation forever to save one on the single call that creates the
// pack, and getStickerSet returns the set's whole sticker list to do it.
_, err = b.AddStickerToSet(ctx, &bot.AddStickerToSetParams{
UserID: pack.OwnerID, // the set owner, never the caller
Name: pack.Name,
Sticker: sticker,
})
switch {
case err == nil:
// No sticker count: nothing is stored, and reading one back would cost
// a getStickerSet call that returns the whole set on every add.
return chathelper.Reply(ctx, b, msg, "Added to the pack.\n"+stickerShareLink(pack.Name))
case isStickerSetMissing(err):
return createStickerPack(ctx, b, msg, pack, title, sticker)
default:
return replyAPIError(ctx, b, msg, "util_addsticker", err)
}
}
// createStickerPack creates the shared pack with sticker as its first member.
//
// Reached only from a positive "the set does not exist", and a set cannot be
// created empty — so the sticker that triggered the creation is the one that
// seeds it. The owner is the same account every later add is attributed to,
// which is what keeps the set writable afterwards.
func createStickerPack(ctx context.Context, b *bot.Bot, msg *models.Message, pack stickerPack, title string, sticker models.InputSticker) error {
_, err := b.CreateNewStickerSet(ctx, &bot.CreateNewStickerSetParams{
UserID: pack.OwnerID,
Name: pack.Name,
Title: title,
Stickers: []models.InputSticker{sticker},
})
switch {
case err == nil:
return chathelper.Reply(ctx, b, msg, "Created the shared pack with this sticker.\n"+stickerShareLink(pack.Name))
case isPackNameOccupied(err):
// The add said the set does not exist and the create says the name is
// taken. Both are true from this bot's side: a set stands there that it
// cannot write to — created for a different owner, or by another bot.
return replyMisconfigured(ctx, b, msg, "util_addsticker_name_taken", err, packNameTakenRefusal)
default:
return replyAPIError(ctx, b, msg, "util_addsticker_create", err)
}
}
// resolveStickerSource turns the replied message into a sticker source.
//
// ownerID is the *pack owner*, needed only by the photo branch: UploadStickerFile
// associates the uploaded file with a user, and it must be the same account
// that owns the set the file is about to join.
func resolveStickerSource(ctx context.Context, b *bot.Bot, ownerID int64, msg *models.Message) (stickerSource, error) {
replied := msg.ReplyToMessage
if replied == nil {
return stickerSource{}, refuse(usageAddSticker)
}
if st := replied.Sticker; st != nil {
format, err := stickerFormatOf(st)
if err != nil {
return stickerSource{}, err
}
src := stickerSource{fileID: st.FileID, format: format}
if st.Emoji != "" {
// models.Sticker.Emoji is a single string, so a replied sticker
// contributes at most one emoji.
src.emoji = []string{st.Emoji}
}
return src, nil
}
if hasMovingSource(replied) {
return resolveVideoSource(ctx, b, ownerID, replied)
}
return resolvePhotoSource(ctx, b, ownerID, replied)
}
// hasMovingSource reports whether the replied message carries footage that must
// be transcoded rather than resampled.
//
// Read from the message alone, with no API call, so the handler can pick its
// deadline before committing to either path.
func hasMovingSource(replied *models.Message) bool {
if replied == nil {
return false
}
if replied.Animation != nil || replied.Video != nil || replied.VideoNote != nil {
return true
}
return replied.Document != nil && movingDocumentMimes[replied.Document.MimeType]
}
// resolveVideoSource turns a replied video, GIF or animation into an uploaded
// video sticker.
//
// Same shape as resolvePhotoSource — download, convert, upload — but the
// conversion shells out to ffmpeg, and the upload declares the video format so
// Telegram validates it as a WEBM rather than a still.
func resolveVideoSource(ctx context.Context, b *bot.Bot, ownerID int64, replied *models.Message) (stickerSource, error) {
fileID, err := videoFileID(replied)
if err != nil {
return stickerSource{}, err
}
mediaCtx, cancelMedia := mediaContext(ctx)
defer cancelMedia()
raw, err := downloadFile(mediaCtx, b, fileID, maxVideoSourceBytes)
if err != nil {
return stickerSource{}, err
}
webm, err := toStickerWEBM(mediaCtx, raw)
if err != nil {
return stickerSource{}, err
}
uploaded, err := b.UploadStickerFile(mediaCtx, &bot.UploadStickerFileParams{
UserID: ownerID,
Sticker: &models.InputFileUpload{Filename: "sticker.webm", Data: bytes.NewReader(webm)},
StickerFormat: stickerFormatVideo,
})
if err != nil {
return stickerSource{}, err
}
return stickerSource{fileID: uploaded.FileID, format: stickerFormatVideo}, nil
}
// videoFileID picks the moving file to convert, refusing an oversized one
// before any byte is downloaded.
func videoFileID(replied *models.Message) (string, error) {
var fileID string
var size int64
switch {
case replied.Animation != nil:
fileID, size = replied.Animation.FileID, replied.Animation.FileSize
case replied.Video != nil:
fileID, size = replied.Video.FileID, replied.Video.FileSize
case replied.VideoNote != nil:
fileID, size = replied.VideoNote.FileID, int64(replied.VideoNote.FileSize)
case replied.Document != nil:
fileID, size = replied.Document.FileID, replied.Document.FileSize
default:
return "", refuse(usageAddSticker)
}
if size > maxVideoSourceBytes {
return "", refuse(tooLargeRefusal(maxVideoSourceBytes))
}
return fileID, nil
}
// stickerFormatOf reports which InputSticker.Format a replied sticker's file is
// already in, so it can be copied by file_id without conversion.
//
// A sticker that reached a user came from a set Telegram itself accepted, so it
// already satisfies every dimension, duration and size rule for its format —
// which is exactly why animated and video stickers need no ffmpeg here, while
// an ordinary video or GIF file does.
//
// Type is the half that is easy to miss: a mask sticker and a custom-emoji
// sticker can be any format, and both are invalid in a regular set, so
// switching on the format booleans alone would let them through to fail at the
// API with an opaque error.
func stickerFormatOf(st *models.Sticker) (string, error) {
if st.Type != "" && st.Type != "regular" {
return "", refuse("That is a mask or custom-emoji sticker, which cannot go in a regular pack.")
}
switch {
case st.IsVideo:
return stickerFormatVideo, nil
case st.IsAnimated:
return stickerFormatAnimated, nil
default:
return stickerFormatStatic, nil
}
}
// resolvePhotoSource turns a replied photo or image document into an uploaded
// sticker file.
//
// Raw bytes cannot ride along on AddStickerToSet: the form builder honours
// attach:// only for []models.InputSticker, and the single InputSticker in
// AddStickerToSetParams falls through to a default that drops the attachment
// silently. So the image is uploaded first and the returned file_id is used.
func resolvePhotoSource(ctx context.Context, b *bot.Bot, ownerID int64, replied *models.Message) (stickerSource, error) {
fileID, err := photoFileID(replied)
if err != nil {
return stickerSource{}, err
}
// Reserve the caller's reply tail: everything from here to the upload is
// the slow leg. See mediaContext.
mediaCtx, cancelMedia := mediaContext(ctx)
defer cancelMedia()
raw, err := downloadFile(mediaCtx, b, fileID, maxSourceBytes)
if err != nil {
return stickerSource{}, err
}
png, err := toStickerPNG(raw)
if err != nil {
return stickerSource{}, err
}
uploaded, err := b.UploadStickerFile(mediaCtx, &bot.UploadStickerFileParams{
UserID: ownerID,
Sticker: &models.InputFileUpload{Filename: "sticker.png", Data: bytes.NewReader(png)},
StickerFormat: stickerFormatStatic,
})
if err != nil {
return stickerSource{}, err
}
// Consumed immediately, so the file_id's undocumented validity window never
// matters. Do not restructure this into upload-now-use-later.
return stickerSource{fileID: uploaded.FileID, format: stickerFormatStatic}, nil
}
// photoFileID picks the file to convert from a replied message.
func photoFileID(replied *models.Message) (string, error) {
if len(replied.Photo) > 0 {
// Pick the largest by size rather than trusting the array's order.
best := replied.Photo[0]
for _, size := range replied.Photo[1:] {
if size.FileSize > best.FileSize {
best = size
}
}
if best.FileSize > maxSourceBytes {
return "", refuse(tooLargeRefusal(maxSourceBytes))
}
return best.FileID, nil
}
if doc := replied.Document; doc != nil {
if !imageDocumentMimes[doc.MimeType] {
return "", refuse("That file is not a supported image. Send a PNG, JPEG or WEBP.")
}
if doc.FileSize > maxSourceBytes {
return "", refuse(tooLargeRefusal(maxSourceBytes))
}
return doc.FileID, nil
}
return "", refuse(usageAddSticker)
}