Files
tiennm99bot/internal/modules/sticker/errors.go
T
tiennm99 3751010b8e feat(sticker): add sticker pack module
Nine commands mirroring the names @Stickers uses: /newpack, /mypack,
/addsticker, /delsticker, /editsticker, /ordersticker, /setpackicon,
/renamepack and /delpack, plus a confirm callback for the destructive
one. Sources are replied stickers, photos or image documents; photos are
downloaded, resampled to 512px and re-uploaded.

One pack per user, keyed by owner id. Creating a pack is the only
operation here that makes a durable, publicly linkable object on a user's
behalf, so it is built around proving ownership rather than assuming it:

- A name is claimed globally and create-only before Telegram is called.
  A pending record alone proves only that a caller *asked* for a name,
  which is exactly what someone naming a victim's public slug also does.
- Adopting an existing set additionally requires that the claim predates
  this invocation. The claim lives in our store and the pack lives at
  Telegram, so a wiped store would otherwise make every pack adoptable.
- Names are released only on positive evidence that no pack stands behind
  them, never on a generic failure, so a transient error cannot hand a
  live name to the next caller.
- Ownership refusals are byte-identical across failure modes, so they
  cannot be used to probe which sets exist.

Error classification is positive-only throughout: "the set is gone" and
"nothing was created" are each proven from a specific Telegram response,
never inferred from an error. Post-action commits run on a context
detached from the request so a shutdown mid-handler cannot lose the
record of something Telegram already did.

Enabled explicitly via MODULES rather than by default.
2026-08-25 15:54:28 +07:00

124 lines
4.9 KiB
Go

package sticker
import (
"context"
"errors"
"strings"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/log"
)
// userError carries text meant to be shown to the user verbatim.
//
// The module has two kinds of failure and must never confuse them: a refusal
// the user can act on ("that pack name is too long"), and an internal failure
// that must not reach a reply at all — plan rule 5 exists because 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 at each call 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} }
// errNoUsername means the bot's own username is unavailable, so no new set name
// can be built. Internal, not user-facing: nothing the caller does fixes it.
var errNoUsername = errors.New("sticker: bot has no username")
// isStickerSetMissing reports whether err positively says the set does not
// exist on Telegram's side.
//
// Classification here is positive-only, and deliberately so: this is the one
// signal that authorises deleting a user's pack record. A network blip, a 429,
// or a context cancelled by SIGTERM must never be read as "the pack is gone" —
// under one pack per user that would destroy the only record of a live pack and
// block /newpack until the phantom cleared.
func isStickerSetMissing(err error) bool {
return errors.Is(err, bot.ErrorBadRequest) &&
strings.Contains(err.Error(), "STICKERSET_INVALID")
}
// 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 three of these into English (PACK_SHORT_NAME_OCCUPIED,
// PACK_SHORT_NAME_INVALID, STICKER_EMOJI_INVALID); the rest arrive as
// "Bad Request: <CODE>", and the prose for the three 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_OCCUPIED", "already occupied"):
return "That pack name is taken. Pick a different one.", true
case contains(text, "PACK_SHORT_NAME_INVALID", "invalid sticker set name"):
return "Telegram rejected that pack name. Use lowercase letters, digits and single underscores.", true
case contains(text, "PACK_TITLE_INVALID"):
return "Telegram rejected that title. Try a shorter, simpler one.", true
case contains(text, "STICKERSET_INVALID"):
return "Your pack no longer exists on Telegram. Use /newpack to create a new one.", true
case contains(text, "STICKERS_TOO_MUCH"):
return "Your pack is full (120 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 reply(ctx, b, msg, text)
}
log.Error(op, "err", err)
return 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
}
// createRefused reports whether err proves CreateNewStickerSet created nothing.
//
// Deliberately separate from apiRefusal even though today their code lists
// overlap. apiRefusal's job is "map an error to user-facing text"; this one's is
// "prove no set exists", which is what authorises releasing a name reservation
// and dropping a write-ahead intent. Reusing apiRefusal for both would mean the
// next person adding a code there for wording reasons silently converts it into
// a strand-the-slug bug.
//
// Every code here is a request-validation refusal: Telegram rejected the call
// before creating anything.
func createRefused(err error) bool {
if err == nil {
return false
}
return contains(err.Error(),
"PACK_SHORT_NAME_OCCUPIED", "already occupied",
"PACK_SHORT_NAME_INVALID", "invalid sticker set name",
"PACK_TITLE_INVALID",
"STICKER_EMOJI_INVALID", "invalid sticker emojis",
"too many emoji specified",
)
}