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.
This commit is contained in:
tiennm99 committed 2026-08-25 15:54:28 +07:00
1 parent e1bf7f1ddb
commit 3751010b8e
35 files changed
+5178 -5

No files matched your search

+5 -2
View File
@@ -12,8 +12,11 @@ MONGO_URL=mongodb+srv://botuser:STRONG_UNIQUE_PASSWORD@cluster0.xxxxx.mongodb.ne
MONGO_DATABASE=miti99bot
# ============================ Operational =========================
# Comma-separated module list. Empty = load every module.
MODULES=
# Comma-separated module list. Empty = load every module, including any module
# added later — so list them explicitly when a deployment should only gain a new
# module deliberately. `sticker` creates real, durable Telegram sticker sets on
# behalf of users, which is worth enabling on purpose rather than by default.
MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker
# Telegram user id for owner-only commands (renamed from BOT_OWNER_ID).
OWNER_ID=
# Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS).
+1
View File
@@ -18,6 +18,7 @@ Atlas via long polling and an in-process cron scheduler.
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <username>`, `/stats cmd <command_name>` |
| `monkeyd` | `/monkeyd_crawl <url> [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags <url>` list its tags as hashtags |
| `sticker` | One personal sticker pack per user: `/newpack`, `/mypack`, `/addsticker`, `/delsticker`, `/editsticker`, `/ordersticker`, `/setpackicon`, `/renamepack`, `/delpack`. See [docs/sticker-packs.md](docs/sticker-packs.md) |
Disable modules with the `MODULES` environment variable.
+27
View File
@@ -72,6 +72,15 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"monkeyd_crawl": "<url> [font_size]",
"monkeyd_tags": "<url>",
"random": "<option,...>",
"newpack": "<pack> <title...>",
"mypack": "",
"addsticker": "[emoji...]",
"delsticker": "",
"editsticker": "<emoji...>",
"ordersticker": "<position>",
"setpackicon": "",
"renamepack": "<title...>",
"delpack": "",
"stats": "[users | user <username> | cmd <command_name>]",
"stock_events": "<ticker> [days]",
"stock_info": "<ticker>",
@@ -91,7 +100,11 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
if len(menu) != len(reg.PublicCommands()) {
t.Fatalf("menu commands = %d, public commands = %d", len(menu), len(reg.PublicCommands()))
}
// This map lists the commands whose parameter strings are pinned; commands
// absent from it are not asserted (the lookup yields "" for them).
seen := map[string]bool{}
for _, command := range reg.PublicCommands() {
seen[command.Name] = true
if got := command.Parameters; got != expectedParameters[command.Name] {
t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name])
}
@@ -107,6 +120,20 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
}
}
// Every expectation must correspond to a registered command.
//
// Without this, an entry whose command stopped being registered simply
// stopped being checked: the forward loop only visits commands that exist,
// so removing a whole module from factories() left the suite green. That is
// also what makes the parameterless entries above load-bearing rather than
// decorative — "" == "" asserts nothing on its own, but the command having
// to exist at all does.
for name := range expectedParameters {
if !seen[name] {
t.Errorf("/%s is expected but not registered — a module dropped out of factories()", name)
}
}
help := moduleutil.RenderHelp(reg)
if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest {
t.Fatalf("/help source is %d characters, exceeds conservative Telegram limit %d", utf8.RuneCountInString(help), telegramMessageMaxRunesForTest)
+2
View File
@@ -26,6 +26,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/misc"
"github.com/tiennm99/miti99bot/internal/modules/monkeyd"
"github.com/tiennm99/miti99bot/internal/modules/stats"
"github.com/tiennm99/miti99bot/internal/modules/sticker"
"github.com/tiennm99/miti99bot/internal/modules/stock"
"github.com/tiennm99/miti99bot/internal/modules/util"
"github.com/tiennm99/miti99bot/internal/modules/wordle"
@@ -91,6 +92,7 @@ func factories() map[string]modules.Factory {
"gold": gold.New,
stock.CollectionName: stock.New,
"stats": stats.New,
"sticker": sticker.New,
}
}
+2
View File
@@ -15,6 +15,7 @@ remain responsible for parsing and validation.
|---|---|---|
| Required value | `<name>` | `<ticker>` |
| Required comma-separated values | `<name,...>` | `<option,...>` |
| Required remaining text | `<name...>` | `<title...>` |
| Optional value | `[name]` | `[date]` |
| Optional remaining text | `[name...]` | `[target...]` |
| Alternatives in an optional group | `[literal | literal <name>]` | `[users | user <username>]` |
@@ -40,6 +41,7 @@ language or extra punctuation without a user-facing need.
```text
/stock_buy <quantity> <ticker>
/renamepack <title...>
/lol [date]
/trongtruonghop [target...]
/stats [users | user <username> | cmd <command_name>]
+145
View File
@@ -0,0 +1,145 @@
# Sticker packs
The `sticker` module lets any user create and manage **one** personal Telegram
sticker pack through the bot. The pack is created on behalf of the calling user,
so it appears under their account, and it stays bot-manageable because the bot
created it.
Every command is single-shot: one message carrying its arguments, optionally
replying to a sticker or photo. There is no conversation state and no `/cancel`.
## Commands
| Command | Parameters | Reply to | What it does |
|---|---|---|---|
| `/newpack` | `<pack> <title...>` | sticker or photo | Creates your pack and returns its share link |
| `/mypack` | — | — | Shows your pack: name, title, sticker count, link |
| `/addsticker` | `[emoji...]` | sticker or photo | Adds it to your pack |
| `/delsticker` | — | a sticker in your pack | Removes it |
| `/editsticker` | `<emoji...>` | a sticker in your pack | Replaces that sticker's emoji |
| `/ordersticker` | `<position>` | a sticker in your pack | Moves it; positions start at 0 |
| `/setpackicon` | — | a sticker in your pack | Uses it as the pack icon |
| `/renamepack` | `<title...>` | — | Changes the displayed title |
| `/delpack` | — | — | Deletes the pack, after an inline confirmation |
Only `/newpack` names a pack. Every other command resolves your single pack from
storage, or from the replied sticker's set.
## The pack name is permanent
`/newpack mypack My Pack` creates `t.me/addstickers/mypack_by_<botusername>`.
**Telegram has no method to rename a sticker set's short name.** That link is
fixed for the life of the pack. `/renamepack` changes only the displayed title.
The only way to a different link is `/delpack` followed by `/newpack` under a new
name — and the stickers do not come along. `/delpack`'s confirmation states the
title, the number of stickers it destroys, the exact link being surrendered, and
that both are permanent, because that prompt is the last point at which someone
wanting "a rename" learns what it actually costs.
Pack-name rules: 3–40 characters, lowercase letters, digits and underscores,
starting with a letter, no two underscores in a row, no trailing underscore.
## Images
Static stickers only. Animated, video, mask, and custom-emoji stickers are
rejected.
- A replied **sticker** is added directly.
- A replied **photo** or image **document** (`image/png`, `image/jpeg`,
`image/webp`) is downloaded, resized so its long edge is exactly 512px with
the aspect ratio preserved, and uploaded as a PNG.
- Pack icons are resized to exactly 100×100, padded transparently.
- Sources above 2 MB, or with either side above 4096px, are rejected.
Telegram allows 120 stickers per pack and 1–20 emoji per sticker. There is no
documented file-size limit for static stickers; the module applies its own
client-side ceiling and never presents it as a Telegram rule.
## Who can use it
Public — every user manages their own pack.
**Anonymous group admins are refused.** Telegram substitutes a single global
`GroupAnonymousBot` user for every anonymous admin message, so without this
refusal all anonymous admins across all groups would share one pack. Turn off
anonymous posting for the message and try again.
## Deliberate omissions
- **Usage statistics** (`/stats`, `/top`, `/packstats`, …) — the Bot API does
not expose sticker usage counts, and `/stats` belongs to the `stats` module.
- **Animated, video, emoji, and mask packs** — out of scope; this module is
static-only.
- **More than one pack per user** — a deliberate simplification. It is what lets
every command but `/newpack` drop its pack argument.
- **`/cancel`** — meaningless without conversation state.
- **A `/repack` migration command** — copying a full pack is up to ~121
sequential API calls, which would stall the bot for every user.
## Behaviour worth knowing
**Sticker counts are advisory.** `/mypack` reads the count from storage and
makes no API calls at all. Editing your pack through @Stickers changes the real
count without the bot seeing it; the number re-syncs whenever a command already
has a fresh view of the set.
**Pack names are claimed first-come and held permanently.** The bot records who
claimed each name before it creates anything on Telegram, and only that user can
ever manage a pack under it. This is what stops someone from reading a pack's
name off its public link and taking it over.
`/newpack` therefore reports when a name is taken, which reveals that some user
of this bot holds it. That is accepted: `t.me/addstickers/<name>_by_<bot>` is
publicly probeable without the bot, so the command discloses nothing new. It
never says *who* holds a name. Refusals about *managing* a pack are deliberately
uniform for the opposite reason — see below.
A name is claimed *before* the bot calls Telegram, not after the pack exists.
That ordering is the point: the claim is what proves, on a later re-run, that an
existing set under that name is yours to finish rather than someone else's to
take.
The claim is given up again whenever the bot has positive evidence that no pack
stands behind it — Telegram refusing the creation outright, `/delpack`, or a
later command finding the set already gone. A `/newpack` that never got as far
as claiming, or that is refused before Telegram is contacted, leaves nothing
behind.
Telegram may keep a deleted short name reserved on its own side, so a freed name
is not guaranteed to be usable again by anyone, including its previous owner.
**Ownership refusals are identical by design.** "You don't have a pack" and
"that sticker isn't from your pack" produce the exact same reply. Distinct
wording would let anyone probe which sets exist under this bot.
**An interrupted `/newpack` can be finished.** The bot records your intent
before calling Telegram, so if a deploy or crash lands mid-creation, re-running
the same `/newpack` command completes it instead of reporting the name taken.
`/mypack` marks an unfinished attempt so it is visible rather than mysterious.
This depends on the bot still holding your claim to the name. If its storage has
been wiped since — which is what a restart does when no database is configured —
the claim is gone while the pack at Telegram is not, and `/newpack` reports the
name as taken rather than adopting a set it can no longer prove is yours.
Recovering a pack in that state needs operator help. Run this module against a
real database, not the in-memory backend.
**Deleting the last sticker may delete the pack.** Telegram's behaviour here is
undocumented, so the bot does not guess: it will not remove your pack record on
anything less than a positive "this set no longer exists" from Telegram. If a
command reports the pack is gone, `/delpack` clears the stale record and
`/newpack` works again.
## Operations
The module is enabled by listing `sticker` in `MODULES` (an empty `MODULES`
loads every module). It stores one record per user, keyed by Telegram user ID,
plus at most one pending `/delpack` confirmation per user — running `/delpack`
again supersedes the previous prompt, and a confirmation stops working after 10
minutes.
Every handler runs under a 10-second deadline. The bot processes updates one at
a time, so this bound is what keeps an image conversion from stalling other
users.
+2 -1
View File
@@ -9,6 +9,7 @@ require (
github.com/testcontainers/testcontainers-go/modules/mongodb v0.43.0
github.com/tiennm99/monkeyd-crawler v0.0.0
go.mongodb.org/mongo-driver/v2 v2.7.0
golang.org/x/image v0.45.0
)
require (
@@ -71,7 +72,7 @@ require (
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/text v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+4 -2
View File
@@ -137,6 +137,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -165,8 +167,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -0,0 +1,223 @@
package sticker
import (
"context"
"errors"
"fmt"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/storage"
)
// handleDelPack asks for confirmation before destroying the caller's pack.
//
// /delpack is also the *only* way to change a pack's URL, since Telegram has no
// rename-short-name method. That makes this prompt the last point at which a
// user who came here wanting a new link learns that the stickers do not survive
// the change — so it states the title, the count being lost, the exact link
// being surrendered, and that both are permanent.
func (s *state) handleDelPack(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
pack, found, err := getPack(ctx, s.store, ownerID)
if err != nil {
log.Error("sticker_delpack_load", "err", err)
return reply(ctx, b, msg, genericFailure)
}
if !found {
return reply(ctx, b, msg, noPackYet)
}
id, err := newActionID()
if err != nil {
log.Error("sticker_delpack_id", "err", err)
return reply(ctx, b, msg, genericFailure)
}
now := s.now()
action := PendingDelete{
ID: id,
OwnerID: ownerID,
Slug: pack.Slug,
SetName: pack.Name,
ChatID: msg.Chat.ID,
CreatedAt: now.UnixMilli(),
ExpiresAt: now.Add(pendingDeleteTTL).UnixMilli(),
}
sent, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: msg.Chat.ID,
ReplyParameters: &models.ReplyParameters{MessageID: msg.ID},
Text: fmt.Sprintf(
"Delete %s (%s)?\n\nThis destroys %d sticker(s) and gives up %s permanently. Neither can be recovered, and the link may not be reusable.",
pack.Title, pack.Slug, pack.Count, shareLink(pack.Name)),
ReplyMarkup: &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{{
{Text: "Delete permanently", CallbackData: deleteCallbackData(id)},
}},
},
})
if err != nil {
log.Error("sticker_delpack_prompt", "err", err)
return err
}
// Bind the action to the message carrying the button, so a press from a
// forwarded or replayed copy resolves to nothing.
action.MessageID = sent.ID
commitCtx, cancelCommit := commitContext(ctx)
defer cancelCommit()
if err := s.pending.Put(commitCtx, pendingDeleteKey(ownerID), action); err != nil {
log.Error("sticker_delpack_store", "err", err)
return reply(ctx, b, msg, genericFailure)
}
return nil
}
// handleDelPackCallback consumes a confirm press.
func (s *state) handleDelPackCallback(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
if update == nil || update.CallbackQuery == nil {
return nil
}
query := update.CallbackQuery
id, ok := parseDeleteCallback(query.Data)
if !ok {
return answerCallback(ctx, b, query.ID, "This confirmation is invalid.")
}
// The lookup is keyed by the presser, never by the payload: the payload is
// client-controlled, so using it to choose *whose* action to load would let
// anyone address someone else's confirmation.
if query.From.ID == 0 {
return answerCallback(ctx, b, query.ID, "This confirmation is invalid.")
}
key := pendingDeleteKey(query.From.ID)
action, _, err := s.pending.Get(ctx, key)
if errors.Is(err, storage.ErrNotFound) {
return answerCallback(ctx, b, query.ID, "This confirmation expired or was already used.")
}
if err != nil {
log.Error("sticker_delpack_action_load", "err", err)
return answerCallback(ctx, b, query.ID, "Could not load this confirmation. Try /delpack again.")
}
if query.From.ID != action.OwnerID {
return answerCallback(ctx, b, query.ID, "Only the user who ran /delpack can confirm it.")
}
// CallbackQuery.Message is a MaybeInaccessibleMessage: nil for messages
// Telegram considers inaccessible. The panic barrier is the backstop, not a
// reason to skip the guard — and this must come before any use of msg.
msg := query.Message.Message
if msg == nil {
return answerCallback(ctx, b, query.ID, "This confirmation is no longer valid here.")
}
// Binding first, and with no side effect. This press may be on somebody
// else's prompt: anyone in a group can tap anyone's button, so touching the
// message before proving it is the one this action was written for let a
// bystander strip the button off a live confirmation they had no part in.
//
// It also subsumes the stale-prompt case — an older prompt is a different
// message id, so it fails here.
if msg.Chat.ID != action.ChatID || msg.ID != action.MessageID || action.MessageID == 0 {
return answerCallback(ctx, b, query.ID, "This confirmation is no longer valid here.")
}
// Defence in depth: the binding above already implies this, since a newer
// /delpack writes a new message id. Clearing is safe here only because the
// binding proved this is the caller's own bound message.
if action.ID != id {
clearButton(ctx, b, msg.Chat.ID, msg.ID)
return answerCallback(ctx, b, query.ID, "This confirmation was replaced by a newer /delpack.")
}
if action.ExpiresAt <= s.now().UnixMilli() {
s.dropPendingDelete(ctx, key)
clearButton(ctx, b, action.ChatID, action.MessageID)
return answerCallback(ctx, b, query.ID, "This confirmation expired. Run /delpack again.")
}
defer s.lockUser(action.OwnerID)()
// Consume the action *before* the destructive call, so a double press
// cannot delete twice or race a second confirmation.
if err := s.pending.Delete(ctx, key); err != nil {
if errors.Is(err, storage.ErrNotFound) {
return answerCallback(ctx, b, query.ID, "This confirmation was already used.")
}
log.Error("sticker_delpack_consume", "err", err)
return answerCallback(ctx, b, query.ID, "Could not confirm right now. Try /delpack again.")
}
_, err = b.DeleteStickerSet(ctx, &bot.DeleteStickerSetParams{Name: action.SetName})
switch {
case err == nil, isStickerSetMissing(err):
// Missing counts as success: the set is gone either way, and clearing
// the record is what unblocks /newpack.
//
// But clear it only if it still names *this* set. dropPackRecord deletes
// by owner, and the user's record may have moved on to a different pack
// since this confirmation was written — that is exactly what the
// documented /delpack-then-/newpack URL-change route does. Deleting
// blindly by owner would then erase a live pack's record.
s.dropPackRecordIfSet(ctx, action.OwnerID, action.SetName)
clearButton(ctx, b, action.ChatID, action.MessageID)
// Reply to the prompt rather than sending bare to the chat: in a forum
// supergroup a bare ChatID send lands in General instead of the topic
// the button lives in, leaking the pack name across topics and losing
// the confirmation. chathelper.Reply carries MessageThreadID.
_ = reply(ctx, b, msg, fmt.Sprintf("Deleted %s. You can create a new pack with /newpack.", action.Slug))
return answerCallback(ctx, b, query.ID, "Pack deleted.")
default:
// The record stays: we have no positive signal that the set is gone.
log.Error("sticker_delpack_delete", "err", err)
clearButton(ctx, b, action.ChatID, action.MessageID)
return answerCallback(ctx, b, query.ID, "Telegram refused the delete. Your pack is unchanged.")
}
}
func (s *state) dropPendingDelete(ctx context.Context, key string) {
commitCtx, cancel := commitContext(ctx)
defer cancel()
if err := s.pending.Delete(commitCtx, key); err != nil && !errors.Is(err, storage.ErrNotFound) {
log.Error("sticker_drop_pending_delete", "err", err)
}
}
func answerCallback(ctx context.Context, b *bot.Bot, queryID, text string) error {
_, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{
CallbackQueryID: queryID,
Text: text,
ShowAlert: true,
})
return err
}
// clearButton removes the inline keyboard so a spent prompt cannot be pressed
// again. Best effort: the action is already consumed either way.
func clearButton(ctx context.Context, b *bot.Bot, chatID int64, messageID int) {
_, err := b.EditMessageReplyMarkup(ctx, &bot.EditMessageReplyMarkupParams{
ChatID: chatID,
MessageID: messageID,
ReplyMarkup: &models.InlineKeyboardMarkup{},
})
if err != nil {
log.Error("sticker_clear_button", "err", err)
}
}
@@ -0,0 +1,389 @@
package sticker
import (
"context"
"strings"
"testing"
"time"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/testutil"
)
const promptMessageID = 555
// seedPendingDelete stores a confirm action as /delpack would have.
func seedPendingDelete(t *testing.T, s *state, mutate func(*PendingDelete)) PendingDelete {
t.Helper()
action := PendingDelete{
ID: "abc123",
OwnerID: testUser,
Slug: "mypack",
SetName: testSet,
ChatID: testChat,
MessageID: promptMessageID,
CreatedAt: fixedNow.UnixMilli(),
ExpiresAt: fixedNow.Add(pendingDeleteTTL).UnixMilli(),
}
if mutate != nil {
mutate(&action)
}
if err := s.pending.Put(context.Background(), pendingDeleteKey(action.OwnerID), action); err != nil {
t.Fatalf("seed pending delete: %v", err)
}
return action
}
// confirmPress builds the callback update for pressing the confirm button.
func confirmPress(action PendingDelete, presser int64) *models.Update {
return &models.Update{CallbackQuery: &models.CallbackQuery{
ID: "cbq-1",
From: models.User{ID: presser},
Data: deleteCallbackData(action.ID),
Message: models.MaybeInaccessibleMessage{
Message: &models.Message{
ID: action.MessageID,
Chat: models.Chat{ID: action.ChatID},
},
},
}}
}
// The prompt is the last point at which a user changing their pack's URL learns
// the stickers do not survive it, so it must state all four consequences.
func TestDelPack_PromptStatesConsequences(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 47)
if err := s.handleDelPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil {
t.Fatalf("handleDelPack: %v", err)
}
text := rb.LastSent().Text()
for _, want := range []string{"My Pack", "47", shareLink(testSet), "permanent"} {
if !strings.Contains(text, want) {
t.Errorf("confirm prompt %q missing %q", text, want)
}
}
if countMethod(rb, "deleteStickerSet") != 0 {
t.Error("/delpack deleted without confirmation")
}
}
func TestDelPack_CallbackDataFitsTelegramLimit(t *testing.T) {
id, err := newActionID()
if err != nil {
t.Fatalf("newActionID: %v", err)
}
data := deleteCallbackData(id)
if len(data) > maxCallbackBytes {
t.Errorf("callback data is %d bytes, over the %d-byte limit", len(data), maxCallbackBytes)
}
got, ok := parseDeleteCallback(data)
if !ok || got != id {
t.Errorf("parseDeleteCallback(%q) = (%q, %v), want (%q, true)", data, got, ok, id)
}
}
func TestDelPackCallback_HappyPath(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
action := seedPendingDelete(t, s, nil)
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
t.Fatalf("callback: %v", err)
}
if countMethod(rb, "deleteStickerSet") != 1 {
t.Fatalf("methods = %v, want one deleteStickerSet", methodsSent(rb))
}
if _, found := loadPack(t, s); found {
t.Error("pack record survived a confirmed delete")
}
}
// Identity comes from From.ID, never from the payload — the payload is
// client-controlled.
func TestDelPackCallback_RejectsOtherUser(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
action := seedPendingDelete(t, s, nil)
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser+1)); err != nil {
t.Fatalf("callback: %v", err)
}
if countMethod(rb, "deleteStickerSet") != 0 {
t.Errorf("methods = %v, want no delete", methodsSent(rb))
}
if _, found := loadPack(t, s); !found {
t.Error("another user's press deleted the pack record")
}
}
func TestDelPackCallback_RejectsExpired(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
action := seedPendingDelete(t, s, func(a *PendingDelete) {
a.ExpiresAt = fixedNow.Add(-time.Second).UnixMilli()
})
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
t.Fatalf("callback: %v", err)
}
if countMethod(rb, "deleteStickerSet") != 0 {
t.Errorf("methods = %v, want no delete after expiry", methodsSent(rb))
}
if _, found := loadPack(t, s); !found {
t.Error("an expired press deleted the pack record")
}
}
// The action is bound to the message carrying the button, so a press arriving
// from anywhere else resolves to nothing.
func TestDelPackCallback_RejectsWrongBinding(t *testing.T) {
cases := map[string]func(*models.Update){
"different chat": func(u *models.Update) { u.CallbackQuery.Message.Message.Chat.ID = testChat + 1 },
"different message": func(u *models.Update) { u.CallbackQuery.Message.Message.ID = promptMessageID + 1 },
// MaybeInaccessibleMessage is nil for messages Telegram marks
// inaccessible; the panic barrier is a backstop, not a substitute.
"inaccessible message": func(u *models.Update) { u.CallbackQuery.Message.Message = nil },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
action := seedPendingDelete(t, s, nil)
upd := confirmPress(action, testUser)
mutate(upd)
if err := s.handleDelPackCallback(context.Background(), rb.Bot, upd); err != nil {
t.Fatalf("callback: %v", err)
}
if countMethod(rb, "deleteStickerSet") != 0 {
t.Errorf("methods = %v, want no delete", methodsSent(rb))
}
if _, found := loadPack(t, s); !found {
t.Error("pack record deleted despite a broken binding")
}
})
}
}
// Single use: the action is consumed before the destructive call, so a second
// press finds nothing.
func TestDelPackCallback_SecondPressIsInert(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
action := seedPendingDelete(t, s, nil)
press := func() {
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
t.Fatalf("callback: %v", err)
}
}
press()
press()
if got := countMethod(rb, "deleteStickerSet"); got != 1 {
t.Errorf("deleteStickerSet calls = %d, want exactly 1", got)
}
}
// A set Telegram already lost still clears the record — that is what unblocks
// /newpack after the phantom-record failure mode.
func TestDelPackCallback_MissingSetStillClearsRecord(t *testing.T) {
rb := testutil.NewRecordingBot(t)
rb.FailMethodCode("deleteStickerSet", 400, "Bad Request: STICKERSET_INVALID")
s := newTestState()
seedPack(t, s, 3)
action := seedPendingDelete(t, s, nil)
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
t.Fatalf("callback: %v", err)
}
if _, found := loadPack(t, s); found {
t.Error("record survived; /newpack stays blocked")
}
}
// The mirror image: an unclassifiable failure leaves the pack alone.
func TestDelPackCallback_TransientErrorKeepsRecord(t *testing.T) {
rb := testutil.NewRecordingBot(t)
rb.FailMethod("deleteStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`)
s := newTestState()
seedPack(t, s, 3)
action := seedPendingDelete(t, s, nil)
if err := s.handleDelPackCallback(context.Background(), rb.Bot, confirmPress(action, testUser)); err != nil {
t.Fatalf("callback: %v", err)
}
if _, found := loadPack(t, s); !found {
t.Error("a transient delete failure destroyed the pack record")
}
}
func TestDelPackCallback_IgnoresNonCallbackUpdate(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
if err := s.handleDelPackCallback(context.Background(), rb.Bot, &models.Update{}); err != nil {
t.Fatalf("callback with no query: %v", err)
}
if len(rb.Sent()) != 0 {
t.Errorf("methods = %v, want none", methodsSent(rb))
}
}
// The stale-confirmation regression. /delpack is the documented route to a new
// pack URL (delete, then /newpack under a new name), so a user genuinely can
// have an old prompt in scrollback while holding a *different*, live pack.
//
// Pressing the stale button deleted the old set, got STICKERSET_INVALID, and
// then cleared the record by owner id — erasing the record of the new, live
// pack and orphaning it permanently.
func TestDelPackCallback_StalePressLeavesTheCurrentPackAlone(t *testing.T) {
rb := testutil.NewRecordingBot(t)
// The old set no longer exists: this is the deleted-then-recreated flow.
rb.FailMethodCode("deleteStickerSet", 400, "Bad Request: STICKERSET_INVALID")
s := newTestState()
ctx := context.Background()
// A confirmation written for the *old* pack.
stale := seedPendingDelete(t, s, func(a *PendingDelete) {
a.Slug = "oldslug"
a.SetName = "oldslug_by_testbot"
})
// The user has since created a new pack.
current := Pack{Slug: "newslug", Name: "newslug_by_testbot", Title: "New", OwnerID: testUser, Count: 7}
if err := s.store.Put(ctx, packKey(testUser), current); err != nil {
t.Fatalf("seed current pack: %v", err)
}
if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(stale, testUser)); err != nil {
t.Fatalf("callback: %v", err)
}
pack, found := loadPack(t, s)
if !found {
t.Fatal("the live pack's record was deleted by a stale confirmation")
}
if pack.Name != current.Name || pack.Count != 7 {
t.Errorf("pack = %+v, want the live newslug record untouched", pack)
}
}
// Two /delpack runs must not leave two live capabilities. The second prompt
// supersedes the first, and pressing the first afterwards does nothing.
func TestDelPack_SecondPromptSupersedesTheFirst(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
ctx := context.Background()
run := func() PendingDelete {
if err := s.handleDelPack(ctx, rb.Bot, testutil.NewPrivateMessage(testUser, "/delpack")); err != nil {
t.Fatalf("handleDelPack: %v", err)
}
action, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser))
if err != nil {
t.Fatalf("load action: %v", err)
}
return action
}
first := run()
second := run()
if first.ID == second.ID {
t.Fatal("both prompts share an id; the test cannot distinguish them")
}
// Exactly one action is stored, not two.
keys, err := s.pending.List(ctx, pendingDeletePrefix)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(keys) != 1 {
t.Errorf("stored pending actions = %d, want 1 — a public command must not accumulate documents", len(keys))
}
// The superseded button is inert.
if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(first, testUser)); err != nil {
t.Fatalf("callback: %v", err)
}
if countMethod(rb, "deleteStickerSet") != 0 {
t.Errorf("methods = %v, want the stale button to delete nothing", methodsSent(rb))
}
if _, found := loadPack(t, s); !found {
t.Error("the superseded button deleted the pack")
}
}
// Anyone in a group can tap anyone's inline button. Checking supersession
// before the chat/message binding meant a bystander's press stripped the button
// off a live confirmation they had no part in — no data leak, but the victim's
// prompt was destroyed and they had to start over.
func TestDelPackCallback_BystanderCannotTouchAnotherUsersPrompt(t *testing.T) {
const bystander = int64(2)
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
ctx := context.Background()
victimAction := seedPendingDelete(t, s, nil)
// The bystander has their own pending confirmation, bound to their own
// message — this is what made action.ID differ and triggered the clear.
bystanderAction := PendingDelete{
ID: "bbbb2222", OwnerID: bystander, Slug: "theirs", SetName: "theirs_by_testbot",
ChatID: testChat, MessageID: 777,
CreatedAt: fixedNow.UnixMilli(), ExpiresAt: fixedNow.Add(pendingDeleteTTL).UnixMilli(),
}
if err := s.pending.Put(ctx, pendingDeleteKey(bystander), bystanderAction); err != nil {
t.Fatalf("seed bystander action: %v", err)
}
// The bystander presses the victim's button.
if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(victimAction, bystander)); err != nil {
t.Fatalf("callback: %v", err)
}
if countMethod(rb, "editMessageReplyMarkup") != 0 {
t.Errorf("methods = %v; a bystander cleared the button on someone else's prompt", methodsSent(rb))
}
if countMethod(rb, "deleteStickerSet") != 0 {
t.Errorf("methods = %v, want no delete", methodsSent(rb))
}
// The victim's confirmation is untouched and still usable.
if _, _, err := s.pending.Get(ctx, pendingDeleteKey(testUser)); err != nil {
t.Errorf("victim's pending action was consumed by a bystander's press: %v", err)
}
}
// A deleted pack must give its name back. Holding it forever would shrink the
// global namespace permanently and let a /newpack + /delpack loop burn one name
// per cycle.
func TestDelPackCallback_ReleasesTheName(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
ctx := context.Background()
action := seedPendingDelete(t, s, nil)
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); !held {
t.Fatal("fixture is wrong: the pack should start with its name reserved")
}
if err := s.handleDelPackCallback(ctx, rb.Bot, confirmPress(action, testUser)); err != nil {
t.Fatalf("callback: %v", err)
}
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
t.Error("the name is still reserved after the pack was deleted")
}
}
+106
View File
@@ -0,0 +1,106 @@
package sticker
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/go-telegram/bot"
"github.com/tiennm99/miti99bot/internal/log"
)
const (
// maxSourceBytes bounds what this module will pull over the network.
// Telegram-compressed photo sizes are typically well under 500 KB.
maxSourceBytes = 2 << 20
// downloadTimeout is this module's own ceiling. The library's shared HTTP
// client allows 60s, which is far too long to inherit on a dispatcher where
// one slow handler stalls every other user.
downloadTimeout = 8 * time.Second
)
// errDownloadFailed replaces every error from the download path.
//
// This is a security boundary, not tidiness. FileDownloadLink returns
// "https://api.telegram.org/file/bot<TOKEN>/<path>", and every transport
// failure from http.Client.Do is a *url.Error whose Error() embeds the full
// URL — which the dispatcher then logs verbatim. A mid-transfer timeout, which
// is trivially reachable, would print the bot token to stdout and every log
// shipper downstream.
var errDownloadFailed = errors.New("sticker: download failed")
// downloadClient is separate from the library's so its timeout is ours.
var downloadClient = &http.Client{Timeout: downloadTimeout}
// downloadFile fetches a Telegram file by ID, bounded in bytes and time.
//
// The original error is discarded rather than wrapped: wrapping would keep the
// URL reachable through errors.Unwrap and %v, which defeats the point.
func downloadFile(ctx context.Context, b *bot.Bot, fileID string) ([]byte, error) {
f, err := b.GetFile(ctx, &bot.GetFileParams{FileID: fileID})
if err != nil {
log.Error("sticker_getfile", "file_id", fileID, "reason", classify(err))
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
}
if f.FileSize > maxSourceBytes {
return nil, refuse(fmt.Sprintf("That image is too large — keep it under %d MB.", maxSourceBytes>>20))
}
link := b.FileDownloadLink(f)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
if err != nil {
log.Error("sticker_download_request", "file_id", fileID, "reason", classify(err))
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
}
resp, err := downloadClient.Do(req)
if err != nil {
log.Error("sticker_download", "file_id", fileID, "reason", classify(err))
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
log.Error("sticker_download", "file_id", fileID, "reason", "status", "status", resp.StatusCode)
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
}
// Never trust Content-Length; bound the reader itself. One extra byte is
// read so an oversized body is detected rather than silently truncated.
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSourceBytes+1))
if err != nil {
log.Error("sticker_download_read", "file_id", fileID, "reason", classify(err))
return nil, fmt.Errorf("file_id=%s: %w", fileID, errDownloadFailed)
}
if len(data) > maxSourceBytes {
return nil, refuse(fmt.Sprintf("That image is too large — keep it under %d MB.", maxSourceBytes>>20))
}
return data, nil
}
// classify reduces an error to a coarse label that cannot contain a URL.
//
// It deliberately inspects only the error's *type*, never its text: any path
// that formats the original error risks carrying the token along with it.
func classify(err error) string {
var urlErr *url.Error
switch {
case err == nil:
return "none"
case errors.Is(err, context.DeadlineExceeded):
return "timeout"
case errors.Is(err, context.Canceled):
return "cancelled"
case errors.As(err, &urlErr):
if urlErr.Timeout() {
return "timeout"
}
return "transport"
}
return "unknown"
}
+157
View File
@@ -0,0 +1,157 @@
package sticker
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-telegram/bot"
)
// The download URL is "https://api.telegram.org/file/bot<TOKEN>/<path>", and
// every transport failure from http.Client.Do is a *url.Error whose Error()
// embeds it in full. The dispatcher logs a handler's returned error verbatim,
// so an error that carried the URL would print the bot token to stdout and
// every log shipper downstream.
//
// This asserts the property directly rather than trusting the discipline: a
// forced transport failure must produce an error that contains neither the
// token, nor "bot", nor any part of the URL.
func TestDownloadFile_ErrorNeverLeaksTokenOrURL(t *testing.T) {
const token = "123456:SUPER-SECRET-BOT-TOKEN"
// A server that accepts getFile, then hangs up mid-download.
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/getFile") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/file_1.jpg"}}`))
return
}
// The file fetch: close the connection without a response.
hj, ok := w.(http.Hijacker)
if !ok {
srv.CloseClientConnections()
return
}
conn, _, err := hj.Hijack()
if err == nil {
_ = conn.Close()
}
}))
defer srv.Close()
b, err := bot.New(token, bot.WithSkipGetMe(), bot.WithServerURL(srv.URL))
if err != nil {
t.Fatalf("bot.New: %v", err)
}
_, err = downloadFile(context.Background(), b, "f1")
if err == nil {
t.Fatal("downloadFile succeeded against a hung-up server; want an error")
}
if !errors.Is(err, errDownloadFailed) {
t.Errorf("err = %v, want it to be errDownloadFailed", err)
}
text := err.Error()
for _, forbidden := range []string{token, "SUPER-SECRET", "bot", "http", srv.URL} {
if strings.Contains(text, forbidden) {
t.Errorf("error text %q contains %q — the token or URL can reach the logs", text, forbidden)
}
}
// Unwrapping must not reach the original either: %v on a wrapped *url.Error
// would put the URL back.
if inner := errors.Unwrap(err); inner != nil && strings.Contains(inner.Error(), token) {
t.Errorf("unwrapped error %q still carries the token", inner)
}
}
// classify must reduce an error to a fixed label. It inspects only the error's
// type, never its text, so there is no path by which a URL can ride along.
func TestClassify_ReturnsFixedLabels(t *testing.T) {
allowed := map[string]bool{"none": true, "timeout": true, "cancelled": true, "transport": true, "unknown": true}
cases := []error{
nil,
context.DeadlineExceeded,
context.Canceled,
errors.New("https://api.telegram.org/file/bot123:SECRET/x.jpg refused"),
}
for _, err := range cases {
got := classify(err)
if !allowed[got] {
t.Errorf("classify(%v) = %q, which is not one of the fixed labels", err, got)
}
if strings.Contains(got, "SECRET") || strings.Contains(got, "http") {
t.Errorf("classify leaked error text: %q", got)
}
}
}
// The size guard runs on the metadata getFile returns, so an oversized file
// costs zero bytes of transfer.
func TestDownloadFile_RejectsOversizedBeforeFetching(t *testing.T) {
var fetches int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/getFile") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":99999999,"file_path":"photos/huge.jpg"}}`))
return
}
fetches++
_, _ = w.Write([]byte("should never be fetched"))
}))
defer srv.Close()
b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL))
if err != nil {
t.Fatalf("bot.New: %v", err)
}
if _, err := downloadFile(context.Background(), b, "f1"); err == nil {
t.Fatal("downloadFile accepted an oversized file")
}
if fetches != 0 {
t.Errorf("made %d HTTP fetches for an oversized file, want 0", fetches)
}
}
// Content-Length is attacker-controlled; the reader itself is what bounds the
// transfer.
func TestDownloadFile_BoundsBodyRegardlessOfContentLength(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/getFile") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/lie.jpg"}}`))
return
}
// Claims to be tiny, sends far more.
w.Header().Set("Content-Length", "10")
w.Header().Set("Content-Type", "image/jpeg")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
chunk := make([]byte, 64<<10)
for written := 0; written < maxSourceBytes+(128<<10); written += len(chunk) {
if _, err := w.Write(chunk); err != nil {
return
}
if flusher != nil {
flusher.Flush()
}
}
}))
defer srv.Close()
b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL))
if err != nil {
t.Fatalf("bot.New: %v", err)
}
data, err := downloadFile(context.Background(), b, "f1")
if err == nil {
t.Fatalf("downloadFile accepted %d bytes despite the %d-byte cap", len(data), maxSourceBytes)
}
}
+209
View File
@@ -0,0 +1,209 @@
package sticker
import (
"fmt"
"strings"
"unicode"
)
const (
// defaultEmoji is used when a sticker is added with no emoji given and the
// source carries none. Telegram requires at least one.
defaultEmoji = "⭐"
// maxEmojiPerSticker mirrors the documented emoji_list range of 1-20.
maxEmojiPerSticker = 20
)
// Code points that bind to the cluster before them rather than starting one.
// Written as hex because the literals are invisible in source.
const (
zwj = rune(0x200D) // zero-width joiner: 👩‍👩‍👧 is one emoji
variationSelector16 = rune(0xFE0F) // forces emoji presentation
variationSelector15 = rune(0xFE0E) // forces text presentation
keycapCombining = rune(0x20E3) // the enclosing box of 1️⃣
tagLow = rune(0xE0020) // tag block: 🏴 + tags spells a subdivision flag
tagHigh = rune(0xE007F)
)
// parseEmoji splits an emoji argument run into individual emoji, accepting both
// "😂 🔥" and "😂🔥".
//
// Go has no stdlib grapheme segmentation, and a dependency for this one job is
// disproportionate — so this hand-rolls the subset of UAX #29 that emoji need:
// ZWJ sequences, variation selectors, skin-tone modifiers, regional-indicator
// pairs, and keycaps. A sequence that splits wrongly is a test case to add, not
// a redesign.
//
// Because no sticker command takes a pack argument any more, every argument
// reaching here is meant to be an emoji: a stray word fails loudly rather than
// being silently reinterpreted.
func parseEmoji(args []string) ([]string, error) {
joined := strings.Join(args, "")
joined = strings.TrimSpace(joined)
if joined == "" {
return nil, nil
}
var out []string
for _, cluster := range splitClusters(joined) {
if isSpace(cluster) {
continue
}
if !isEmojiCluster(cluster) {
return nil, refuse(fmt.Sprintf("%q is not an emoji. Give one or more emoji, like 😂🔥.", cluster))
}
out = append(out, cluster)
}
if len(out) > maxEmojiPerSticker {
return nil, refuse(fmt.Sprintf("At most %d emoji per sticker.", maxEmojiPerSticker))
}
return out, nil
}
// splitClusters breaks s into emoji-aware clusters.
func splitClusters(s string) []string {
runes := []rune(s)
var out []string
for i := 0; i < len(runes); {
start := i
i++
// A regional indicator pairs with a following one to form a flag.
if isRegionalIndicator(runes[start]) && i < len(runes) && isRegionalIndicator(runes[i]) {
i++
out = append(out, string(runes[start:i]))
continue
}
// Absorb everything that binds leftward: modifiers, variation
// selectors, combining marks, keycaps, and ZWJ-joined continuations.
for i < len(runes) {
r := runes[i]
switch {
case r == zwj:
// Absorb the joiner and its continuation only when what follows
// can actually continue an emoji sequence. Taking the next rune
// unconditionally let "😀<ZWJ>🇻🇳" swallow the flag's first
// half and emit the orphaned second half as its own cluster —
// two invalid emoji, both of which passed validation.
if i+1 < len(runes) && isEmojiRune(runes[i+1]) && !isRegionalIndicator(runes[i+1]) {
i += 2
continue
}
// A dangling joiner. Absorb it so it cannot start a cluster of
// its own; trimJoiners drops it from the emitted cluster.
i++
goto done
case isBinding(r):
i++
default:
goto done
}
}
done:
// A cluster ending in a joiner is incomplete. "😀<ZWJ>" is not an emoji
// and Telegram rejects it, but it used to pass validation because the
// cluster's first rune looked fine.
if cluster := trimJoiners(string(runes[start:i])); cluster != "" {
out = append(out, cluster)
}
}
return out
}
// trimJoiners strips leading and trailing zero-width joiners from a cluster.
func trimJoiners(cluster string) string {
return strings.Trim(cluster, string(zwj))
}
// isBinding reports whether r attaches to the preceding cluster.
func isBinding(r rune) bool {
switch {
case r == variationSelector16, r == variationSelector15, r == keycapCombining:
return true
case r >= tagLow && r <= tagHigh:
// Subdivision flags (🏴 + "gbeng" + terminator) are one emoji. Without
// this the base flag split from its tags and the tags, being invisible,
// produced a refusal quoting characters the user could not see.
return true
case isSkinTone(r):
return true
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Me, r):
return true
}
return false
}
func isSkinTone(r rune) bool { return r >= 0x1F3FB && r <= 0x1F3FF }
func isRegionalIndicator(r rune) bool { return r >= 0x1F1E6 && r <= 0x1F1FF }
func isSpace(cluster string) bool { return strings.TrimSpace(cluster) == "" }
// isEmojiCluster reports whether a cluster is emoji rather than ordinary text.
//
// The test is on the cluster's first rune, since that is what determines the
// cluster's identity — the rest is bound modifiers. Keycaps are the exception:
// "1️⃣" starts with an ASCII digit, so a cluster carrying the keycap combining
// mark counts regardless of its base.
func isEmojiCluster(cluster string) bool {
runes := []rune(cluster)
if len(runes) == 0 {
return false
}
if strings.ContainsRune(cluster, keycapCombining) {
return true
}
if isRegionalIndicator(runes[0]) {
// A flag is exactly two regional indicators. An odd count leaves a lone
// one at the end, which is not an emoji — Telegram rejects it, and it
// used to pass because isEmojiRune accepts the block.
return len(runes) == 2 && isRegionalIndicator(runes[1])
}
return isEmojiRune(runes[0])
}
// emojiRanges are the Unicode blocks Telegram's emoji actually come from.
// Deliberately ranges rather than a property lookup: Go's unicode package
// exposes no Emoji property, and the blocks are stable.
var emojiRanges = [...]struct{ lo, hi rune }{
{0x1F300, 0x1FAFF}, // pictographs, emoticons, transport, symbols, extended-A
{0x1F000, 0x1F2FF}, // mahjong, dominoes, playing cards, enclosed
{0x2600, 0x27BF}, // misc symbols + dingbats
{0x2B00, 0x2BFF}, // arrows and stars (⭐ lives here)
{0x2190, 0x21FF}, // arrows
{0x2300, 0x23FF}, // misc technical (⌚, ⏰)
{0x25A0, 0x25FF}, // geometric shapes
{0x1F1E6, 0x1F1FF}, // regional indicators; isEmojiCluster requires a pair
}
// emojiSingletons are emoji stranded between the blocks above.
//
// Listed one by one rather than by widening a range, because their neighbours
// are not emoji: Ⓜ sits in enclosed alphanumerics next to circled digits, and ©
// and ® sit in Latin-1 next to ordinary punctuation. Widening to cover them
// would start accepting text as emoji, which Telegram then rejects.
var emojiSingletons = map[rune]bool{
0x203C: true, // ‼
0x2049: true, // ⁉
0x2122: true, // ™
0x2139: true, // ℹ
0x00A9: true, // ©
0x00AE: true, // ®
0x2934: true, // ⤴
0x2935: true, // ⤵
0x24C2: true, // Ⓜ
0x3030: true, // 〰
0x303D: true, // 〽
0x3297: true, // ㊗
0x3299: true, // ㊙
}
func isEmojiRune(r rune) bool {
if emojiSingletons[r] {
return true
}
for _, block := range emojiRanges {
if r >= block.lo && r <= block.hi {
return true
}
}
return false
}
+154
View File
@@ -0,0 +1,154 @@
package sticker
import (
"strings"
"testing"
)
func TestParseEmoji(t *testing.T) {
cases := []struct {
name string
args []string
want []string
}{
{"single", []string{"😂"}, []string{"😂"}},
{"space separated", []string{"😂", "🔥"}, []string{"😂", "🔥"}},
{"joined in one arg", []string{"😂🔥"}, []string{"😂", "🔥"}},
{"zwj family stays one", []string{"👩\u200d👩\u200d👧"}, []string{"👩\u200d👩\u200d👧"}},
{"skin tone binds", []string{"👍🏽"}, []string{"👍🏽"}},
{"flag is one cluster", []string{"🇻🇳"}, []string{"🇻🇳"}},
{"two flags", []string{"🇻🇳🇯🇵"}, []string{"🇻🇳", "🇯🇵"}},
{"keycap stays one", []string{"1️⃣"}, []string{"1️⃣"}},
{"variation selector binds", []string{"❤️"}, []string{"❤️"}},
{"star default", []string{"⭐"}, []string{"⭐"}},
{"empty", nil, nil},
{"blank", []string{" "}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseEmoji(tc.args)
if err != nil {
t.Fatalf("parseEmoji(%q) error: %v", tc.args, err)
}
if len(got) != len(tc.want) {
t.Fatalf("parseEmoji(%q) = %q, want %q", tc.args, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("cluster %d = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
// A stray word must fail loudly. With no pack argument left on /addsticker,
// there is nothing else an argument could have meant.
func TestParseEmoji_RejectsPlainText(t *testing.T) {
for _, arg := range []string{"mypack", "hello 😂", "a"} {
if got, err := parseEmoji([]string{arg}); err == nil {
t.Errorf("parseEmoji(%q) = %q, want an error", arg, got)
}
}
}
func TestParseEmoji_CapsAtTwenty(t *testing.T) {
twenty := strings.Repeat("😂", maxEmojiPerSticker)
if got, err := parseEmoji([]string{twenty}); err != nil {
t.Fatalf("parseEmoji(20 emoji) error: %v (got %d)", err, len(got))
}
if _, err := parseEmoji([]string{twenty + "🔥"}); err == nil {
t.Error("parseEmoji(21 emoji) succeeded, want an error")
}
}
// Refusals are shown to the user verbatim, so they must be userError.
func TestParseEmoji_RefusalIsUserFacing(t *testing.T) {
_, err := parseEmoji([]string{"notanemoji"})
if err == nil {
t.Fatal("want an error")
}
if _, ok := err.(userError); !ok {
t.Errorf("err is %T, want userError so the handler can echo it", err)
}
}
// TestParseEmoji_ClusterEdgeCases pins the emoji-clustering rules that a
// hand-rolled segmenter gets wrong. Every case here failed before the
// clustering fix: the first group was refused outright, the second silently
// produced an emoji_list Telegram rejects.
func TestParseEmoji_ClusterEdgeCases(t *testing.T) {
accepted := []struct {
name string
in string
want []string
}{
// Emoji that fall between the blocks the range table covers.
{"copyright", "©️", []string{"©️"}},
{"registered", "®️", []string{"®️"}},
{"wavy dash", "〰️", []string{"〰️"}},
{"part alternation", "〽️", []string{"〽️"}},
{"japanese congratulations", "㊗️", []string{"㊗️"}},
{"japanese secret", "㊙️", []string{"㊙️"}},
{"circled m", "Ⓜ️", []string{"Ⓜ️"}},
{"arrow curving up", "⤴️", []string{"⤴️"}},
{"arrow curving down", "⤵️", []string{"⤵️"}},
// A subdivision flag is a base flag plus invisible tag characters.
{
"tag sequence flag",
"\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F",
[]string{"\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F"},
},
// A dangling joiner is dropped rather than shipped.
{"trailing joiner", "\U0001F600\u200d", []string{"\U0001F600"}},
// The joiner must not swallow a flag's first half.
{
"joiner before a flag",
"\U0001F600\u200d\U0001F1FB\U0001F1F3",
[]string{"\U0001F600", "\U0001F1FB\U0001F1F3"},
},
// Sequences that already worked, kept here so a fix cannot regress them.
{"family", "\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466", []string{"\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466"}},
{"skin tone", "\U0001F44D\U0001F3FD", []string{"\U0001F44D\U0001F3FD"}},
{"keycap", "1️⃣", []string{"1️⃣"}},
{"flag", "\U0001F1FB\U0001F1F3", []string{"\U0001F1FB\U0001F1F3"}},
}
for _, tc := range accepted {
t.Run(tc.name, func(t *testing.T) {
got, err := parseEmoji([]string{tc.in})
if err != nil {
t.Fatalf("parseEmoji(%+q) refused: %v", tc.in, err)
}
if len(got) != len(tc.want) {
t.Fatalf("parseEmoji(%+q) = %+q, want %+q", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("parseEmoji(%+q) = %+q, want %+q", tc.in, got, tc.want)
}
}
})
}
refused := []struct {
name string
in string
}{
// A lone regional indicator is half a flag, and Telegram rejects it.
{"lone regional indicator", "\U0001F1FB"},
{"odd regional indicator count", "\U0001F1FB\U0001F1F3\U0001F1FA"},
{"plain text", "hello"},
}
for _, tc := range refused {
t.Run(tc.name, func(t *testing.T) {
got, err := parseEmoji([]string{tc.in})
if err == nil {
t.Fatalf("parseEmoji(%+q) = %+q, want refusal", tc.in, got)
}
})
}
}
+123
View File
@@ -0,0 +1,123 @@
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",
)
}
+440
View File
@@ -0,0 +1,440 @@
package sticker
import (
"context"
"strings"
"testing"
"time"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
const (
testUser = int64(42)
testChat = int64(1000)
testSet = "mypack_by_testbot"
otherSet = "someoneelse_by_testbot"
)
var fixedNow = time.UnixMilli(1_700_000_000_000)
// newTestState builds a state over one in-memory collection, matching
// production: both typed views share the collection, with disjoint key spaces.
func newTestState() *state {
coll := storage.NewMemoryProvider().Collection("sticker")
return &state{
store: storage.Typed[Pack](coll),
pending: storage.Typed[PendingDelete](coll),
slugs: storage.Typed[SlugReservation](coll),
nowFn: func() time.Time { return fixedNow },
}
}
func seedPack(t *testing.T, s *state, count int) Pack {
t.Helper()
pack := Pack{
Slug: "mypack", Name: testSet, Title: "My Pack",
OwnerID: testUser, Count: count, CreatedAt: fixedNow.UnixMilli(),
}
if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil {
t.Fatalf("seed pack: %v", err)
}
// A real pack always carries its name reservation; seeding without one
// would let tests pass against a state production cannot reach.
if err := s.slugs.Put(context.Background(), slugKey(pack.Slug),
SlugReservation{Slug: pack.Slug, OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil {
t.Fatalf("seed reservation: %v", err)
}
return pack
}
// stickerReply builds a message replying to a sticker in setName.
func stickerReply(text, setName string) *models.Update {
upd := testutil.NewPrivateMessage(testUser, text)
upd.Message.Chat.ID = testChat
upd.Message.ReplyToMessage = &models.Message{
Sticker: &models.Sticker{
FileID: "file-in-" + setName,
FileUniqueID: "uniq",
Type: "regular",
SetName: setName,
Emoji: "🎉",
},
}
return upd
}
func loadPack(t *testing.T, s *state) (Pack, bool) {
t.Helper()
pack, found, err := getPack(context.Background(), s.store, testUser)
if err != nil {
t.Fatalf("load pack: %v", err)
}
return pack, found
}
// methodsSent lists the API methods a run produced, so a test can assert both
// what was called and that nothing was.
func methodsSent(rb *testutil.RecordingBot) []string {
var out []string
for _, call := range rb.Sent() {
out = append(out, call.Method)
}
return out
}
func countMethod(rb *testutil.RecordingBot, method string) int {
n := 0
for _, call := range rb.Sent() {
if call.Method == method {
n++
}
}
return n
}
func TestAddSticker_HappyPath(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
t.Fatalf("handleAddSticker: %v", err)
}
if countMethod(rb, "addStickerToSet") != 1 {
t.Fatalf("methods = %v, want one addStickerToSet", methodsSent(rb))
}
for _, call := range rb.Sent() {
if call.Method != "addStickerToSet" {
continue
}
if call.Form["name"] != testSet {
t.Errorf("name = %q, want %q", call.Form["name"], testSet)
}
// UserID is always the caller: a non-owner never reaches this call.
if call.Form["user_id"] != "42" {
t.Errorf("user_id = %q, want 42", call.Form["user_id"])
}
if !strings.Contains(call.Form["sticker"], "😂") {
t.Errorf("sticker payload %q missing the explicit emoji", call.Form["sticker"])
}
}
pack, _ := loadPack(t, s)
if pack.Count != 4 {
t.Errorf("Count = %d, want 4", pack.Count)
}
}
// Explicit args beat the replied sticker's emoji, which beats the default.
// addedStickerPayload returns the payload of the one addStickerToSet call the
// handler is expected to have made.
//
// Ranging over rb.Sent() and asserting only inside an `if call.Method == ...`
// makes the assertion vacuous: a handler that returns early and never calls
// Telegram at all satisfies it, because the loop body never runs. Requiring
// exactly one call is what makes these tests fail when the call disappears.
func addedStickerPayload(t *testing.T, rb *testutil.RecordingBot) string {
t.Helper()
var payloads []string
for _, call := range rb.Sent() {
if call.Method == "addStickerToSet" {
payloads = append(payloads, call.Form["sticker"])
}
}
if len(payloads) != 1 {
t.Fatalf("addStickerToSet calls = %d, want exactly 1", len(payloads))
}
return payloads[0]
}
func TestAddSticker_EmojiPrecedence(t *testing.T) {
cases := []struct {
name string
text string
want string
}{
{"explicit wins", "/addsticker 🔥", "🔥"},
{"inherits from replied sticker", "/addsticker", "🎉"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 0)
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply(tc.text, otherSet)); err != nil {
t.Fatalf("handleAddSticker: %v", err)
}
if payload := addedStickerPayload(t, rb); !strings.Contains(payload, tc.want) {
t.Errorf("sticker payload %q, want emoji %q", payload, tc.want)
}
})
}
}
// With no emoji anywhere, the default keeps the call valid — Telegram rejects
// an empty emoji_list.
func TestAddSticker_FallsBackToDefaultEmoji(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 0)
upd := stickerReply("/addsticker", otherSet)
upd.Message.ReplyToMessage.Sticker.Emoji = ""
if err := s.handleAddSticker(context.Background(), rb.Bot, upd); err != nil {
t.Fatalf("handleAddSticker: %v", err)
}
if payload := addedStickerPayload(t, rb); !strings.Contains(payload, defaultEmoji) {
t.Errorf("sticker payload %q, want the default emoji", payload)
}
}
// A stray word is caught by parseEmoji. With no pack argument left, there is
// nothing else it could have been mistaken for.
func TestAddSticker_RejectsNonEmojiArgument(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 0)
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker mypack", otherSet)); err != nil {
t.Fatalf("handleAddSticker: %v", err)
}
if countMethod(rb, "addStickerToSet") != 0 {
t.Errorf("methods = %v, want no API call", methodsSent(rb))
}
if !strings.Contains(rb.LastSent().Text(), "not an emoji") {
t.Errorf("reply = %q, want an emoji usage error", rb.LastSent().Text())
}
}
func TestAddSticker_NoPackYet(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
t.Fatalf("handleAddSticker: %v", err)
}
if countMethod(rb, "addStickerToSet") != 0 {
t.Errorf("methods = %v, want no API call", methodsSent(rb))
}
if !strings.Contains(rb.LastSent().Text(), "/newpack") {
t.Errorf("reply = %q, want it to point at /newpack", rb.LastSent().Text())
}
}
// A pending record means an unfinished /newpack: there is no usable pack yet,
// and the reply has to say how to finish it.
func TestAddSticker_PendingPackIsNotUsable(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
pack := seedPack(t, s, 0)
pack.Pending = true
if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil {
t.Fatalf("seed pending: %v", err)
}
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
t.Fatalf("handleAddSticker: %v", err)
}
if countMethod(rb, "addStickerToSet") != 0 {
t.Errorf("methods = %v, want no API call", methodsSent(rb))
}
if !strings.Contains(rb.LastSent().Text(), "incomplete") {
t.Errorf("reply = %q, want the incomplete-pack hint", rb.LastSent().Text())
}
}
func TestAddSticker_FullPack(t *testing.T) {
rb := testutil.NewRecordingBot(t)
rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERS_TOO_MUCH")
s := newTestState()
seedPack(t, s, maxStickersPerPack)
if err := s.handleAddSticker(context.Background(), rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
t.Fatalf("handleAddSticker: %v", err)
}
if !strings.Contains(rb.LastSent().Text(), "full") {
t.Errorf("reply = %q, want the pack-is-full message", rb.LastSent().Text())
}
// A failed add must not move the count.
pack, _ := loadPack(t, s)
if pack.Count != maxStickersPerPack {
t.Errorf("Count = %d, want it unchanged at %d", pack.Count, maxStickersPerPack)
}
}
func TestDelSticker_HappyPath(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 3)
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
t.Fatalf("handleDelSticker: %v", err)
}
if countMethod(rb, "deleteStickerFromSet") != 1 {
t.Fatalf("methods = %v, want exactly one deleteStickerFromSet", methodsSent(rb))
}
pack, found := loadPack(t, s)
if !found {
t.Fatal("pack record deleted by a successful /delsticker")
}
if pack.Count != 2 {
t.Errorf("Count = %d, want 2", pack.Count)
}
}
// R7: a transient failure must never destroy the record. The probe that would
// have done so was removed for exactly this reason.
func TestDelSticker_TransientErrorKeepsRecord(t *testing.T) {
rb := testutil.NewRecordingBot(t)
rb.FailMethod("deleteStickerFromSet", 500, `{"ok":false,"description":"server exploded"}`)
s := newTestState()
seedPack(t, s, 3)
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
t.Fatalf("handleDelSticker: %v", err)
}
pack, found := loadPack(t, s)
if !found {
t.Fatal("a transient error deleted the pack record")
}
if pack.Count != 3 {
t.Errorf("Count = %d, want it unchanged at 3", pack.Count)
}
}
// The other half of the same rule: a *positive* STICKERSET_INVALID is the one
// signal that authorises dropping the record, and dropping it is what unblocks
// /newpack.
func TestDelSticker_SetGoneDropsRecord(t *testing.T) {
rb := testutil.NewRecordingBot(t)
rb.FailMethodCode("deleteStickerFromSet", 400, "Bad Request: STICKERSET_INVALID")
s := newTestState()
seedPack(t, s, 1)
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
t.Fatalf("handleDelSticker: %v", err)
}
if _, found := loadPack(t, s); found {
t.Error("record survived a positive STICKERSET_INVALID; /newpack stays blocked")
}
}
// Deleting the last sticker may destroy the set Telegram-side, and /mypack
// makes no API calls so it cannot notice. The reply has to name the way out.
func TestDelSticker_EmptyPackNamesRecovery(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 1)
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
t.Fatalf("handleDelSticker: %v", err)
}
text := rb.LastSent().Text()
if !strings.Contains(text, "/delpack") {
t.Errorf("reply = %q, want it to name /delpack", text)
}
pack, _ := loadPack(t, s)
if pack.Count != 0 {
t.Errorf("Count = %d, want 0", pack.Count)
}
}
// Count is floored: a drifted record must not go negative.
func TestDelSticker_CountFlooredAtZero(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 0)
if err := s.handleDelSticker(context.Background(), rb.Bot, stickerReply("/delsticker", testSet)); err != nil {
t.Fatalf("handleDelSticker: %v", err)
}
pack, _ := loadPack(t, s)
if pack.Count != 0 {
t.Errorf("Count = %d, want 0", pack.Count)
}
}
func TestEditSticker_HappyPath(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 1)
if err := s.handleEditSticker(context.Background(), rb.Bot, stickerReply("/editsticker 😂🔥", testSet)); err != nil {
t.Fatalf("handleEditSticker: %v", err)
}
if countMethod(rb, "setStickerEmojiList") != 1 {
t.Fatalf("methods = %v, want one setStickerEmojiList", methodsSent(rb))
}
}
// An empty emoji_list is invalid, so unlike /addsticker this cannot fall back
// to a default — the user has to say what they want.
func TestEditSticker_RequiresEmoji(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 1)
if err := s.handleEditSticker(context.Background(), rb.Bot, stickerReply("/editsticker", testSet)); err != nil {
t.Fatalf("handleEditSticker: %v", err)
}
if countMethod(rb, "setStickerEmojiList") != 0 {
t.Errorf("methods = %v, want no API call", methodsSent(rb))
}
}
func TestOrderSticker(t *testing.T) {
cases := []struct {
name string
text string
wantAPI int
}{
{"zero is valid", "/ordersticker 0", 1},
// Not bounded locally: Telegram validates against the current set size
// and a local copy would go stale.
{"large position reaches the API", "/ordersticker 999", 1},
{"negative rejected locally", "/ordersticker -1", 0},
{"non-numeric rejected locally", "/ordersticker first", 0},
{"missing argument rejected locally", "/ordersticker", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 5)
if err := s.handleOrderSticker(context.Background(), rb.Bot, stickerReply(tc.text, testSet)); err != nil {
t.Fatalf("handleOrderSticker: %v", err)
}
if got := countMethod(rb, "setStickerPositionInSet"); got != tc.wantAPI {
t.Errorf("setStickerPositionInSet calls = %d, want %d (methods %v)", got, tc.wantAPI, methodsSent(rb))
}
})
}
}
// The self-heal path frees the name too: STICKERSET_INVALID is a positive
// "this set is gone", so there is nothing left for the reservation to protect.
func TestSelfHeal_ReleasesTheName(t *testing.T) {
rb := testutil.NewRecordingBot(t)
rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERSET_INVALID")
s := newTestState()
seedPack(t, s, 2)
ctx := context.Background()
if err := s.handleAddSticker(ctx, rb.Bot, stickerReply("/addsticker 😂", otherSet)); err != nil {
t.Fatalf("handleAddSticker: %v", err)
}
if _, found := loadPack(t, s); found {
t.Error("pack record survived a positive STICKERSET_INVALID")
}
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
t.Error("the name is still reserved after the set was confirmed gone")
}
}
+162
View File
@@ -0,0 +1,162 @@
package sticker
import (
"bytes"
"image"
"image/png"
// Registered for their side effect: image.Decode needs the formats
// Telegram actually delivers.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // read-only WEBP decoder
"github.com/tiennm99/miti99bot/internal/log"
)
const (
// stickerEdge is Telegram's requirement: one side exactly 512px, the other
// at most 512px.
stickerEdge = 512
// thumbnailEdge is the pack thumbnail requirement: exactly 100x100.
thumbnailEdge = 100
// maxDecodeDimension bounds peak allocation. Checked via DecodeConfig,
// before any pixel buffer exists.
//
// Note the 2 MB source cap bounds *compressed* bytes and so does not bound
// this: a flat-colour 4096x4096 PNG is a few tens of KB. At this cap a
// single conversion peaks near 90 MB live — the decoded source, the NRGBA
// scale target, and the PNG encode buffer coexist. Handlers run one at a
// time, so this is RSS pressure rather than a multiplier.
maxDecodeDimension = 4096
// softMaxStickerBytes is a *client-side* ceiling, not a documented limit.
// The widely-repeated 512 KB figure appears in no current official page, so
// it must never be stated to users as a rule.
softMaxStickerBytes = 512 << 10
)
// toStickerPNG converts an arbitrary image to a PNG sized for a static sticker:
// long edge exactly 512, aspect ratio preserved.
func toStickerPNG(src []byte) ([]byte, error) {
img, err := decodeBounded(src)
if err != nil {
return nil, err
}
b := img.Bounds()
w, h := scaleToLongEdge(b.Dx(), b.Dy(), stickerEdge)
out := resize(img, w, h)
data, err := encodePNG(out, png.DefaultCompression)
if err != nil {
return nil, err
}
if len(data) <= softMaxStickerBytes {
return data, nil
}
// Try harder before losing resolution.
if data, err = encodePNG(out, png.BestCompression); err == nil && len(data) <= softMaxStickerBytes {
return data, nil
}
// Then step the long edge down. Below 320 the sticker is too small to be
// worth shrinking further; hand back the best effort instead.
//
// Each rung resamples the already-scaled 512px image, not the source. The
// source may be 4096x4096, and resampling it once per rung made the ladder
// cost four full-size resamples instead of one — seconds of uninterruptible
// CPU on a dispatcher that runs handlers one at a time, so a single user
// could stall the bot for everyone. Target dimensions still come from the
// original ratio, so the aspect is identical either way.
for _, edge := range []int{448, 384, 320} {
w, h = scaleToLongEdge(b.Dx(), b.Dy(), edge)
candidate, encErr := encodePNG(resize(out, w, h), png.BestCompression)
if encErr != nil {
return nil, encErr
}
data = candidate
if len(data) <= softMaxStickerBytes {
return data, nil
}
}
log.Error("sticker_image_oversized", "bytes", len(data))
return data, nil
}
// toThumbnailPNG converts an image to a pack thumbnail: exactly 100x100, with
// the short edge padded transparently so the aspect ratio survives.
func toThumbnailPNG(src []byte) ([]byte, error) {
img, err := decodeBounded(src)
if err != nil {
return nil, err
}
b := img.Bounds()
w, h := scaleToLongEdge(b.Dx(), b.Dy(), thumbnailEdge)
scaled := resize(img, w, h)
canvas := image.NewNRGBA(image.Rect(0, 0, thumbnailEdge, thumbnailEdge))
offset := image.Pt((thumbnailEdge-w)/2, (thumbnailEdge-h)/2)
draw.Draw(canvas, scaled.Bounds().Add(offset), scaled, image.Point{}, draw.Src)
return encodePNG(canvas, png.BestCompression)
}
// decodeBounded reads the header first and refuses oversized images before any
// pixel buffer is allocated.
func decodeBounded(src []byte) (image.Image, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(src))
if err != nil {
return nil, refuse("That file is not an image Telegram can use. Send a PNG, JPEG or WEBP.")
}
if cfg.Width > maxDecodeDimension || cfg.Height > maxDecodeDimension {
return nil, refuse("That image is too big to process. Keep both sides under 4096 pixels.")
}
if cfg.Width <= 0 || cfg.Height <= 0 {
// Not "too large" — a header claiming no pixels at all. The user can
// act on this, so it must be a userError rather than surfacing as a
// generic failure with an ERROR log line.
return nil, refuse("That image has no usable dimensions. Send a normal PNG, JPEG or WEBP.")
}
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil {
return nil, refuse("That image could not be read. Send a PNG, JPEG or WEBP.")
}
return img, nil
}
// scaleToLongEdge returns the dimensions that put the long edge exactly at
// edge, keeping the aspect ratio. The short edge is clamped to at least 1 so an
// extreme aspect ratio cannot produce a zero-dimension image.
func scaleToLongEdge(w, h, edge int) (int, int) {
if w <= 0 || h <= 0 {
return edge, edge
}
if w >= h {
short := int(float64(h)*float64(edge)/float64(w) + 0.5)
return edge, max(short, 1)
}
short := int(float64(w)*float64(edge)/float64(h) + 0.5)
return max(short, 1), edge
}
// resize scales into a fresh NRGBA, which preserves alpha.
func resize(img image.Image, w, h int) *image.NRGBA {
out := image.NewNRGBA(image.Rect(0, 0, w, h))
draw.CatmullRom.Scale(out, out.Bounds(), img, img.Bounds(), draw.Over, nil)
return out
}
func encodePNG(img image.Image, level png.CompressionLevel) ([]byte, error) {
var buf bytes.Buffer
enc := png.Encoder{CompressionLevel: level}
if err := enc.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+156
View File
@@ -0,0 +1,156 @@
package sticker
import (
"bytes"
"image"
"image/color"
"image/png"
"testing"
)
// makePNG generates a test image in-process, so no binary fixtures are
// committed and every case is readable from the test itself.
func makePNG(t *testing.T, w, h int, alpha uint8) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: alpha})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode fixture: %v", err)
}
return buf.Bytes()
}
func decodeSize(t *testing.T, data []byte) (int, int) {
t.Helper()
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode result: %v", err)
}
return cfg.Width, cfg.Height
}
// Telegram requires one side to be exactly 512 and the other at most 512.
func TestToStickerPNG_Geometry(t *testing.T) {
cases := []struct {
name string
w, h int
wantW, wantH int
}{
{"landscape", 1024, 512, 512, 256},
{"portrait", 300, 900, 171, 512},
{"square", 512, 512, 512, 512},
{"upscales small input", 64, 32, 512, 256},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := toStickerPNG(makePNG(t, tc.w, tc.h, 255))
if err != nil {
t.Fatalf("toStickerPNG: %v", err)
}
gotW, gotH := decodeSize(t, out)
if gotW != tc.wantW || gotH != tc.wantH {
t.Errorf("%dx%d -> %dx%d, want %dx%d", tc.w, tc.h, gotW, gotH, tc.wantW, tc.wantH)
}
if gotW != stickerEdge && gotH != stickerEdge {
t.Errorf("neither side is exactly %d: %dx%d", stickerEdge, gotW, gotH)
}
})
}
}
// An extreme aspect ratio must not round the short edge down to zero, which
// would produce an invalid image rather than an error.
//
// 1x4000 rather than the plan's 1x5000: 5000 is past maxDecodeDimension, so
// that case never reaches the scaler at all — it is rejected by the guard
// below. The clamp still needs exercising, and this is the most extreme ratio
// that actually gets there (512/4000 rounds to 0 before clamping).
func TestToStickerPNG_ExtremeAspectRatio(t *testing.T) {
out, err := toStickerPNG(makePNG(t, 1, 4000, 255))
if err != nil {
t.Fatalf("toStickerPNG: %v", err)
}
w, h := decodeSize(t, out)
if w < 1 || h < 1 {
t.Fatalf("got a zero-dimension image: %dx%d", w, h)
}
if h != stickerEdge {
t.Errorf("long edge = %d, want %d", h, stickerEdge)
}
}
// The other half: past the cap, the guard refuses rather than the scaler
// coping. The bound exists to cap allocation, so it must win over any
// aspect-ratio handling.
func TestToStickerPNG_ExtremeAspectPastCapIsRejected(t *testing.T) {
if _, err := toStickerPNG(makePNG(t, 1, maxDecodeDimension+904, 255)); err == nil {
t.Fatal("an image past the dimension cap was accepted")
}
}
// Stickers are cut-outs; losing alpha in the resize would put a black box
// behind every one of them.
func TestToStickerPNG_PreservesAlpha(t *testing.T) {
out, err := toStickerPNG(makePNG(t, 256, 256, 0))
if err != nil {
t.Fatalf("toStickerPNG: %v", err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode: %v", err)
}
if _, _, _, a := img.At(128, 128).RGBA(); a != 0 {
t.Errorf("alpha = %d at a fully transparent pixel, want 0", a)
}
}
func TestToThumbnailPNG_IsExactly100Square(t *testing.T) {
// A non-square input still has to come out exactly 100x100, padded.
out, err := toThumbnailPNG(makePNG(t, 800, 400, 255))
if err != nil {
t.Fatalf("toThumbnailPNG: %v", err)
}
w, h := decodeSize(t, out)
if w != thumbnailEdge || h != thumbnailEdge {
t.Errorf("thumbnail = %dx%d, want %dx%d", w, h, thumbnailEdge, thumbnailEdge)
}
}
// The dimension guard runs on the header, before any pixel buffer exists — it
// is what bounds peak allocation for attacker-supplied images.
func TestDecodeBounded_RejectsOversizedDimensions(t *testing.T) {
oversized := makePNG(t, maxDecodeDimension+1, 4, 255)
if _, err := toStickerPNG(oversized); err == nil {
t.Fatal("toStickerPNG accepted an image past the dimension cap")
}
}
func TestDecodeBounded_RejectsNonImage(t *testing.T) {
if _, err := toStickerPNG([]byte("this is not an image")); err == nil {
t.Fatal("toStickerPNG accepted non-image bytes")
}
}
func TestScaleToLongEdge(t *testing.T) {
cases := []struct {
w, h, edge int
wantW, wantH int
}{
{1000, 500, 512, 512, 256},
{500, 1000, 512, 256, 512},
{100, 100, 512, 512, 512},
{5000, 1, 512, 512, 1}, // clamped, never 0
}
for _, tc := range cases {
gotW, gotH := scaleToLongEdge(tc.w, tc.h, tc.edge)
if gotW != tc.wantW || gotH != tc.wantH {
t.Errorf("scaleToLongEdge(%d, %d, %d) = (%d, %d), want (%d, %d)",
tc.w, tc.h, tc.edge, gotW, gotH, tc.wantW, tc.wantH)
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// Package sticker lets any user create and manage one personal Telegram
// sticker pack through the bot. The pack is created on behalf of the calling
// user, named "<slug>_by_<bot_username>", and stays bot-manageable because the
// bot created it.
//
// One pack per user is the central simplification: no command except /newpack
// takes a pack argument, because there is only ever one pack to act on.
package sticker
import (
"context"
"errors"
"strconv"
"github.com/tiennm99/miti99bot/internal/storage"
)
// Pack is the single bot-created sticker set owned by a Telegram user.
//
// The record is keyed by owner ID alone, which makes the lookup itself the
// ownership check: there is no way to read a pack without naming its owner.
type Pack struct {
Slug string `bson:"slug"` // chosen at creation, fixes the permanent URL
Name string `bson:"name"` // Telegram set name, "<slug>_by_<botname>"
Title string `bson:"title"` // display title, mutable via /renamepack
OwnerID int64 `bson:"ownerId"` // Telegram user the set belongs to
Count int `bson:"count"` // stickers in the set; keeps /mypack API-free
Pending bool `bson:"pending"` // write-ahead intent; see newpack's state machine
CreatedAt int64 `bson:"createdAt"` // unix millis
}
// PackStore is the module's view over its collection.
type PackStore = storage.DocStore[Pack]
// SlugReservation records which user claimed a pack name, globally.
//
// Pack records are keyed by owner, so they can only answer "does *this* user
// have a pack" — they cannot answer "who holds this name". Without that second
// question the module cannot tell its own interrupted attempt from a set
// belonging to someone else, because both look identical from the caller's
// side: a pending record naming a set that exists. Adopting on that evidence
// alone let any user take over any pack whose public link they could guess.
//
// The reservation is the missing half. It is claimed with a create-only write
// before Telegram is touched, so the first claimant of a name is the only user
// who can ever adopt a set under it.
type SlugReservation struct {
Slug string `bson:"slug"`
OwnerID int64 `bson:"ownerId"`
CreatedAt int64 `bson:"createdAt"`
}
// SlugStore is the third typed view over the module's collection.
type SlugStore = storage.DocStore[SlugReservation]
// slugKey namespaces reservations away from the owner-keyed Pack records.
// Pack keys are decimal owner IDs, so the prefix cannot collide.
func slugKey(slug string) string { return slugPrefix + slug }
const slugPrefix = "slug:"
// getSlugReservation reads a name's reservation. Missing is not an error — it
// is the normal state for an unclaimed name.
func getSlugReservation(ctx context.Context, store SlugStore, slug string) (SlugReservation, bool, error) {
r, _, err := store.Get(ctx, slugKey(slug))
if errors.Is(err, storage.ErrNotFound) {
return SlugReservation{}, false, nil
}
if err != nil {
return SlugReservation{}, false, err
}
return r, true, nil
}
// packKey is the storage key for a user's pack: the owner ID and nothing else.
//
// One pack per user makes the slug unnecessary as a key component, which is
// what removes the prefix scan the multi-pack design needed. The module calls
// List nowhere.
func packKey(ownerID int64) string { return strconv.FormatInt(ownerID, 10) }
// getPack reads the caller's pack. A missing record is not an error — it is the
// normal state for a user who has never run /newpack — so it reports found
// rather than returning storage.ErrNotFound for every caller to translate.
func getPack(ctx context.Context, store PackStore, ownerID int64) (Pack, bool, error) {
p, _, err := store.Get(ctx, packKey(ownerID))
if errors.Is(err, storage.ErrNotFound) {
return Pack{}, false, nil
}
if err != nil {
return Pack{}, false, err
}
return p, true, nil
}
// shareLink is the public URL of a pack. It is fixed at creation and cannot be
// changed: Telegram exposes no method to rename a set's short name.
func shareLink(setName string) string {
return "https://t.me/addstickers/" + setName
}
+619
View File
@@ -0,0 +1,619 @@
package sticker
import (
"context"
"errors"
"fmt"
"strings"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/storage"
)
const (
newpackUsage = "Reply to a sticker with: /newpack <pack> <title...>\nEg: /newpack mypack My Pack"
// slugTaken answers a name held by anyone other than the caller. It says
// only "taken", never who holds it — the plan accepts that pack names are
// enumerable (share links are public), but there is no reason to confirm
// which are backed by real packs any more precisely than Telegram already
// does.
slugTaken = "That pack name is taken. Pick a different one."
noPackYet = "You don't have a pack yet. Reply to a sticker with /newpack <pack> <title...> to create one."
pendingMarker = "\n\n⚠️ This pack is incomplete — re-run the same /newpack command to finish it."
)
// handleNewPack creates the caller's pack.
//
// The hard part is not the API call, it is surviving an interruption. The slug
// fixes a permanent public URL, so an attempt that dies between "Telegram
// created the set" and "we wrote it down" would strand that slug forever: the
// set exists, nobody's record points at it, and the user cannot recreate it.
//
// The fix is a write-ahead record, claimed before Telegram is called, in two
// parts that answer two different questions:
//
// - a global reservation on the name — "who claimed this name?" (reserveSlug)
// - an owner-keyed pending Pack — "does this user have a pack?" (claimSlug)
//
// Both are needed. The pending record alone proves only that this caller *asked
// for* the name, which a user naming someone else's pack also does; treating it
// as proof of ownership was a pack-takeover hole. The reservation is what makes
// "this set is mine to adopt" a fact rather than an assumption.
func (s *state) handleNewPack(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
args := commandArgs(msg)
if len(args) < 2 {
return reply(ctx, b, msg, newpackUsage)
}
slug := strings.ToLower(args[0])
if err := validateSlug(slug); err != nil {
return replyErr(ctx, b, msg, "sticker_newpack_slug", err)
}
title := strings.TrimSpace(strings.Join(args[1:], " "))
if title == "" || len([]rune(title)) > maxTitleLen {
return reply(ctx, b, msg, fmt.Sprintf("Give a title of 1-%d characters.", maxTitleLen))
}
// The lock covers everything below, including the media leg. It is per-user,
// so a slow download delays only the user who sent it.
defer s.lockUser(ownerID)()
// Refuse a caller who already has a finished pack BEFORE reserving anything,
// and before doing any expensive work.
//
// Reservations are permanent and global, so writing one and *then*
// discovering the caller is not entitled to a pack handed every user an
// unlimited name-burning primitive: each refused /newpack claimed a name for
// everyone else, at the cost of one message and zero API calls. The order of
// this check and reserveSlug is the whole defence.
//
// It also runs before resolveSource, which downloads, resamples and
// re-uploads an image. Answering "you already have a pack" is a single store
// read; making a user who cannot create a pack pay for the full media
// pipeline first was free work for anyone who wanted to spend the bot's CPU.
if existing, found, err := getPack(ctx, s.store, ownerID); err != nil {
log.Error("sticker_newpack_precheck", "err", err)
return reply(ctx, b, msg, genericFailure)
} else if found && !existing.Pending {
return reply(ctx, b, msg, fmt.Sprintf(
"You already have a pack (%s). Use /delpack first if you want a different one.\n%s",
existing.Slug, shareLink(existing.Name)))
}
source, err := s.resolveSource(ctx, b, ownerID, msg)
if err != nil {
return replyErr(ctx, b, msg, "sticker_newpack_source", err)
}
username, err := s.resolver.resolve(ctx, b)
if err != nil {
log.Error("sticker_newpack_username", "err", err)
return reply(ctx, b, msg, genericFailure)
}
setName, err := makeSetName(slug, username)
if err != nil {
return replyErr(ctx, b, msg, "sticker_newpack_setname", err)
}
created, done, err := s.reserveSlug(ctx, b, msg, ownerID, slug)
if err != nil || done {
return err
}
claimed, done, err := s.claimSlug(ctx, b, msg, ownerID, slug, setName, title)
if err != nil || done {
// Nothing downstream is holding this name: release it rather than
// leaving a permanent claim with no pack behind it. Only what this
// invocation created — never a reservation we merely resumed.
if created {
s.releaseSlug(ctx, ownerID, slug)
}
return err
}
return s.createOrAdopt(ctx, b, msg, claimed, source, created)
}
// reserveSlug claims a pack name for ownerID, globally and permanently.
//
// Pack records are keyed by owner, so they answer "does this user have a pack"
// and nothing else. That is not enough to make adoption safe: a user with no
// pack who names someone else's slug produces exactly the same evidence as a
// user resuming their own interrupted attempt — a pending record naming a set
// that exists. Adopting on that evidence let anyone take over any pack whose
// public link they could guess, and every share link is public.
//
// A create-only write on the name itself supplies the missing fact. The first
// claimant is the only user who can ever adopt a set under that name, so by the
// time createOrAdopt sees an existing set, "it is ours" has actually been
// proven rather than assumed.
//
// created reports whether this call wrote the reservation, so a caller that
// bails can release exactly what it made and never a reservation it merely
// resumed. done == true means the caller was already answered and the handler
// must stop.
func (s *state) reserveSlug(ctx context.Context, b *bot.Bot, msg *models.Message, ownerID int64, slug string) (created, done bool, err error) {
err = s.slugs.PutVersioned(ctx, slugKey(slug), 0, SlugReservation{
Slug: slug,
OwnerID: ownerID,
CreatedAt: s.now().UnixMilli(),
})
if err == nil {
return true, false, nil
}
if !errors.Is(err, storage.ErrConflict) {
log.Error("sticker_newpack_reserve", "err", err)
return false, true, reply(ctx, b, msg, genericFailure)
}
held, found, getErr := getSlugReservation(ctx, s.slugs, slug)
if getErr != nil || !found {
// Conflict but unreadable: treat the name as unavailable rather than
// guessing. Guessing the other way is the takeover.
log.Error("sticker_newpack_reserve_read", "err", getErr)
return false, true, reply(ctx, b, msg, slugTaken)
}
if held.OwnerID != ownerID {
return false, true, reply(ctx, b, msg, slugTaken)
}
// Our own reservation from an earlier attempt: carry on and resume it, but
// do not claim we created it — releasing it on a later bail would discard a
// claim that predates this command.
return false, false, nil
}
// releaseSlug frees a reservation whose pack was never created, or was deleted.
//
// Only ever called with a positive signal that the name is not in use — never on
// an unknown error, which would hand the name to whoever asks next while the set
// may still exist.
//
// It verifies ownership itself rather than trusting call sites. A bare
// delete-by-name is a cross-user primitive, and this module has already been
// bitten once by an ownership check that lived in the caller instead of the
// operation.
func (s *state) releaseSlug(ctx context.Context, ownerID int64, slug string) {
// The ownership read runs on the detached context too, not just the delete.
// Splitting them meant a cancelled request (SIGTERM, or the handler
// deadline) failed the read and returned before the delete — leaving a
// reservation with no pack and no set behind it, which no code path can
// ever reach again. A cleanup that only half-survives shutdown is worse
// than one that does not run at all.
commitCtx, cancel := commitContext(ctx)
defer cancel()
held, found, err := getSlugReservation(commitCtx, s.slugs, slug)
if err != nil {
log.Error("sticker_release_slug_read", "slug", slug, "err", err)
return
}
if !found {
return
}
if held.OwnerID != ownerID {
log.Error("sticker_release_slug_refused", "slug", slug, "holder", held.OwnerID, "caller", ownerID)
return
}
if err := s.slugs.Delete(commitCtx, slugKey(slug)); err != nil && !errors.Is(err, storage.ErrNotFound) {
log.Error("sticker_release_slug", "slug", slug, "err", err)
}
}
// claimSlug writes the write-ahead intent record, or interprets the conflict
// when the caller already has one.
//
// done == true means the caller was already answered and the handler must stop.
func (s *state) claimSlug(ctx context.Context, b *bot.Bot, msg *models.Message, ownerID int64, slug, setName, title string) (Pack, bool, error) {
intent := Pack{
Slug: slug,
Name: setName,
Title: title,
OwnerID: ownerID,
Pending: true,
CreatedAt: s.now().UnixMilli(),
}
// PutVersioned with expectedVersion 0 is create-only, and Mongo resolves it
// with a duplicate-key error, so exactly one writer wins. This record *is*
// the one-pack-per-user quota — there is no separate counter to keep in
// sync. Put would silently overwrite and must not be used here.
err := s.store.PutVersioned(ctx, packKey(ownerID), 0, intent)
if err == nil {
return intent, false, nil
}
if !errors.Is(err, storage.ErrConflict) {
log.Error("sticker_newpack_claim", "err", err)
return Pack{}, true, reply(ctx, b, msg, genericFailure)
}
existing, found, getErr := getPack(ctx, s.store, ownerID)
if getErr != nil || !found {
log.Error("sticker_newpack_reread", "err", getErr)
return Pack{}, true, reply(ctx, b, msg, genericFailure)
}
switch {
case !existing.Pending:
return Pack{}, true, reply(ctx, b, msg, fmt.Sprintf(
"You already have a pack (%s). Use /delpack first if you want a different one.\n%s",
existing.Slug, shareLink(existing.Name)))
case existing.Slug == slug:
// Our own interrupted attempt for this exact name: resume it.
return existing, false, nil
default:
// An earlier attempt was interrupted under a *different* name. Probing
// first is what makes this safe: if that set was already created,
// overwriting the record here would orphan it permanently — the set
// would exist, owned by this user, with adoption keyed on a slug the
// record no longer holds.
return s.resolveStaleIntent(ctx, b, msg, existing, intent)
}
}
// resolveStaleIntent decides what to do with a pending record for a slug the
// caller is no longer asking for.
func (s *state) resolveStaleIntent(ctx context.Context, b *bot.Bot, msg *models.Message, existing, intent Pack) (Pack, bool, error) {
// The old name is only adoptable if this caller reserved it too. They
// normally did — the pending record came from their own earlier run through
// reserveSlug — but adoption is the dangerous operation in this module, so
// it is re-proven rather than inferred from the pending record.
held, found, err := getSlugReservation(ctx, s.slugs, existing.Slug)
if err != nil {
log.Error("sticker_newpack_stale_reservation", "err", err)
return Pack{}, true, reply(ctx, b, msg, genericFailure)
}
if !found || held.OwnerID != intent.OwnerID {
// Someone else holds the old name, or it was released. Either way this
// caller cannot adopt under it; drop the dead intent and let them
// proceed with the name they actually asked for.
if putErr := s.store.Put(ctx, packKey(intent.OwnerID), intent); putErr != nil {
log.Error("sticker_newpack_replace_intent", "err", putErr)
return Pack{}, true, reply(ctx, b, msg, genericFailure)
}
return intent, false, nil
}
_, err = b.GetStickerSet(ctx, &bot.GetStickerSetParams{Name: existing.Name})
switch {
case err == nil:
// The old set exists — adopt it rather than stranding it.
adopted := existing
adopted.Pending = false
if adopted.Count == 0 {
adopted.Count = 1
}
if commitErr := s.commitPack(ctx, adopted); commitErr != nil {
log.Error("sticker_newpack_adopt_commit", "err", commitErr)
return Pack{}, true, reply(ctx, b, msg, genericFailure)
}
return Pack{}, true, reply(ctx, b, msg, fmt.Sprintf(
"You already have a pack (%s) from an earlier attempt — it has been restored.\n%s\nUse /delpack first if you want a different name.",
adopted.Slug, shareLink(adopted.Name)))
case isStickerSetMissing(err):
// Nothing was created under the old name; take over the record. A
// positive "no such set" is what makes releasing the old reservation
// safe — otherwise an abandoned name would be held against every other
// user forever, with no set behind it.
if putErr := s.store.Put(ctx, packKey(intent.OwnerID), intent); putErr != nil {
log.Error("sticker_newpack_replace_intent", "err", putErr)
return Pack{}, true, reply(ctx, b, msg, genericFailure)
}
s.releaseSlug(ctx, intent.OwnerID, existing.Slug)
return intent, false, nil
default:
// Unknown failure: change nothing (plan rule 4).
log.Error("sticker_newpack_probe", "err", err)
return Pack{}, true, reply(ctx, b, msg, genericFailure)
}
}
// createOrAdopt performs the Telegram-side creation for a claimed intent, or
// adopts a set an interrupted attempt already created.
//
// freshReservation reports that *this* invocation first claimed the name, which
// is what disqualifies adoption: see the err == nil branch.
func (s *state) createOrAdopt(ctx context.Context, b *bot.Bot, msg *models.Message, pack Pack, source stickerSource, freshReservation bool) error {
_, err := b.GetStickerSet(ctx, &bot.GetStickerSetParams{Name: pack.Name})
switch {
case err == nil:
// A set exists under this name. Adopting it grants full control -
// DeleteStickerSet, DeleteStickerFromSet and SetStickerSetTitle are all
// keyed by set name alone, with no owner scoping - so this branch must
// prove the set is the caller's own interrupted attempt, not merely
// assume it.
//
// The reservation proves it only if it outlived the set. It does not
// always: the reservation lives in our store and the set lives at
// Telegram, and the store can be wiped (a restart on the in-memory
// backend does exactly that) while every pack survives. Then the
// evidence a takeover produces and the evidence a real resume produces
// are once again identical.
//
// freshReservation separates them without any new state. A genuine
// interrupted attempt reserved the name *before* creating the set, so it
// re-enters here having found its own reservation, never having made
// one. Claiming the name for the first time therefore proves the set
// under it is somebody else's.
if freshReservation {
log.Error("sticker_newpack_adopt_refused", "slug", pack.Slug, "owner", pack.OwnerID)
// Leave nothing behind. The intent records an attempt that is not
// going to happen, and the reservation names a set this caller has
// just been refused — holding either would deny the name to the
// set's real owner, who after a store wipe has to re-register it
// exactly as this caller tried to.
s.dropIntent(ctx, pack.OwnerID)
s.releaseSlug(ctx, pack.OwnerID, pack.Slug)
return reply(ctx, b, msg, slugTaken)
}
return s.finishNewPack(ctx, b, msg, pack, true)
case isStickerSetMissing(err):
// Free: create it.
default:
// Unknown. Keep both the intent and the reservation: the set may exist,
// and re-running is how the user recovers. Destroying either here is
// what strands a slug (plan rule 4).
log.Error("sticker_newpack_lookup", "err", err)
return reply(ctx, b, msg, genericFailure)
}
emoji := source.emoji
if len(emoji) == 0 {
emoji = []string{defaultEmoji}
}
_, err = b.CreateNewStickerSet(ctx, &bot.CreateNewStickerSetParams{
UserID: pack.OwnerID,
Name: pack.Name,
Title: pack.Title,
Stickers: []models.InputSticker{{
Sticker: source.fileID,
Format: stickerFormatStatic,
EmojiList: emoji,
}},
})
if err != nil {
// Only a refusal that proves nothing was created lets us undo the
// claim. On anything else the create may have succeeded server-side, so
// both the intent and the reservation stay and a re-run adopts the set.
if createRefused(err) {
s.dropIntent(ctx, pack.OwnerID)
s.releaseSlug(ctx, pack.OwnerID, pack.Slug)
}
return replyAPIError(ctx, b, msg, "sticker_newpack_create", err)
}
return s.finishNewPack(ctx, b, msg, pack, false)
}
// finishNewPack commits the confirmed record and replies with the share link.
func (s *state) finishNewPack(ctx context.Context, b *bot.Bot, msg *models.Message, pack Pack, adopted bool) error {
pack.Pending = false
if pack.Count == 0 {
pack.Count = 1
}
if err := s.commitPack(ctx, pack); err != nil {
log.Error("sticker_newpack_commit", "err", err)
return reply(ctx, b, msg, genericFailure)
}
prefix := "Created"
if adopted {
prefix = "Finished an earlier attempt at"
}
return reply(ctx, b, msg, fmt.Sprintf("%s %s.\n%s\n\nAdd more with /addsticker while replying to a sticker.",
prefix, pack.Title, shareLink(pack.Name)))
}
// dropIntent removes a write-ahead record whose creation never happened, so the
// user is not left holding a slug for a set that does not exist.
func (s *state) dropIntent(ctx context.Context, ownerID int64) {
commitCtx, cancel := commitContext(ctx)
defer cancel()
if err := s.store.Delete(commitCtx, packKey(ownerID)); err != nil && !errors.Is(err, storage.ErrNotFound) {
log.Error("sticker_drop_intent", "user", ownerID, "err", err)
}
}
// adjustCount applies a delta to the caller's sticker count and commits.
//
// It re-reads the record rather than trusting the copy the handler resolved
// earlier: that read happened *before* the per-user lock was taken, so writing
// a count derived from it would clobber any change made in between. Reading and
// writing both inside the lock is what makes the lock mean anything.
//
// Returns the committed record so the reply can quote the new count.
func (s *state) adjustCount(ctx context.Context, ownerID, delta int64) (Pack, error) {
// Detached: the sticker has already been added or removed at Telegram by
// the time this runs, so the read-modify-write that records it must not be
// abandoned because the request context expired mid-flight.
commitCtx, cancel := commitContext(ctx)
defer cancel()
pack, found, err := getPack(commitCtx, s.store, ownerID)
if err != nil {
return Pack{}, err
}
if !found {
// The record vanished under us — nothing to update, and nothing that
// justifies recreating it.
return Pack{}, storage.ErrNotFound
}
pack.Count += int(delta)
if pack.Count < 0 {
// Count is advisory and drifts when a pack is edited through @Stickers.
// It must never go negative.
pack.Count = 0
}
return pack, s.commitPack(commitCtx, pack)
}
// commitPack writes a record that reflects a completed Telegram-side action, on
// a context detached from the request. See commitContext.
func (s *state) commitPack(ctx context.Context, pack Pack) error {
commitCtx, cancel := commitContext(ctx)
defer cancel()
return s.store.Put(commitCtx, packKey(pack.OwnerID), pack)
}
// dropPackRecordIfSet deletes the caller's pack record only when it still names
// setName.
//
// dropPackRecord addresses a record by owner, which is right when the caller is
// acting on the pack they just resolved. It is wrong for a deferred action like
// a /delpack confirmation, which can be pressed after the record has moved on to
// a different pack — deleting by owner then destroys the record of a set that is
// very much alive.
func (s *state) dropPackRecordIfSet(ctx context.Context, ownerID int64, setName string) {
// Detached like the drop it guards: a cancelled read here would skip a
// cleanup that the set's confirmed deletion has already made mandatory.
commitCtx, cancel := commitContext(ctx)
defer cancel()
pack, found, err := getPack(commitCtx, s.store, ownerID)
if err != nil {
log.Error("sticker_drop_record_check", "user", ownerID, "err", err)
return
}
if !found {
return
}
if !ownsSet(pack, setName) {
// The record already points at a different pack; leave it alone.
return
}
s.dropPackRecord(commitCtx, ownerID)
}
// dropPackRecord deletes a pack record and frees the name it held.
//
// Every call site reaches here on a positive "this set is gone" signal — either
// isStickerSetMissing, or a confirmed DeleteStickerSet — so the name genuinely
// has no pack behind it any more and must return to the pool. Keeping it would
// shrink the global namespace permanently and let a /newpack + /delpack loop
// burn one name per cycle.
//
// If Telegram reserves deleted short names on its side (plan R11, unverified),
// releasing here is simply a no-op in practice: the next claimant reserves the
// name locally, then CreateNewStickerSet refuses with PACK_SHORT_NAME_OCCUPIED,
// which createRefused releases again. Either way the user gets a correct answer.
//
// Callers must never reach this on a transient failure.
func (s *state) dropPackRecord(ctx context.Context, ownerID int64) {
// Read before deleting: the record is the only thing that knows which name
// this owner held. The read shares the delete's detached context — on the
// request context it would fail during shutdown while the delete below
// still succeeded, stranding the name permanently.
commitCtx, cancel := commitContext(ctx)
defer cancel()
pack, found, err := getPack(commitCtx, s.store, ownerID)
if err != nil {
log.Error("sticker_drop_record_read", "user", ownerID, "err", err)
// Still drop the record — leaving it would block /newpack — but the
// name cannot be freed without knowing it.
}
if delErr := s.store.Delete(commitCtx, packKey(ownerID)); delErr != nil && !errors.Is(delErr, storage.ErrNotFound) {
log.Error("sticker_drop_record", "user", ownerID, "err", delErr)
return
}
if err == nil && found && pack.Slug != "" {
s.releaseSlug(commitCtx, ownerID, pack.Slug)
}
}
// handleMyPack shows the caller's pack. Makes zero API calls: the count lives
// on the record, which is the whole reason it is stored.
func (s *state) handleMyPack(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
pack, found, err := getPack(ctx, s.store, ownerID)
if err != nil {
log.Error("sticker_mypack", "err", err)
return reply(ctx, b, msg, genericFailure)
}
if !found {
return reply(ctx, b, msg, noPackYet)
}
text := fmt.Sprintf("%s (%s)\n%d sticker(s)\n%s", pack.Title, pack.Slug, pack.Count, shareLink(pack.Name))
if pack.Pending {
// Showing an unfinished attempt beats hiding it: the user is blocked
// from /newpack until it resolves, and re-running the same command is
// what resolves it.
text += pendingMarker
}
return reply(ctx, b, msg, text)
}
// handleRenamePack changes the pack's display title. The share link cannot
// follow — Telegram has no rename-short-name method.
func (s *state) handleRenamePack(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
title := strings.TrimSpace(commandArgText(msg))
if title == "" || len([]rune(title)) > maxTitleLen {
return reply(ctx, b, msg, fmt.Sprintf("Usage: /renamepack <title...>\nGive a title of 1-%d characters.", maxTitleLen))
}
pack, found, err := getPack(ctx, s.store, ownerID)
if err != nil {
log.Error("sticker_renamepack_load", "err", err)
return reply(ctx, b, msg, genericFailure)
}
if !found || pack.Pending {
return reply(ctx, b, msg, noPackYet)
}
defer s.lockUser(ownerID)()
if _, err := b.SetStickerSetTitle(ctx, &bot.SetStickerSetTitleParams{Name: pack.Name, Title: title}); err != nil {
if isStickerSetMissing(err) {
s.dropPackRecord(ctx, ownerID)
}
return replyAPIError(ctx, b, msg, "sticker_renamepack", err)
}
pack.Title = title
if err := s.commitPack(ctx, pack); err != nil {
// The rename already happened on Telegram's side; only our copy of the
// title is stale, and the next successful rename fixes it.
log.Error("sticker_renamepack_commit", "err", err)
}
// Naming the delete-and-recreate route turns a dead end into an answer.
// A user who typed "rename" with only a title is likely expecting the URL
// to follow, and it never can.
return reply(ctx, b, msg, fmt.Sprintf(
"Renamed to %s.\nThe link is unchanged: %s\n\nTo get a different link you have to /delpack and then /newpack under a new name — the stickers do not come along.",
title, shareLink(pack.Name)))
}
@@ -0,0 +1,691 @@
package sticker
import (
"context"
"strings"
"testing"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/testutil"
)
const getMeResult = `{"id":7,"is_bot":true,"first_name":"Test","username":"testbot"}`
// stubBotIdentity makes the username resolver work. The bot starts with
// WithSkipGetMe, so nothing populates a username until the module asks.
func stubBotIdentity(rb *testutil.RecordingBot) { rb.StubMethod("getMe", getMeResult) }
// setMissing makes getStickerSet report the set does not exist — the only
// classification that lets the module treat a slug as free.
func setMissing(rb *testutil.RecordingBot) {
rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")
}
// setExists makes getStickerSet return a real set, which needs a struct result
// the bare harness cannot produce.
func setExists(rb *testutil.RecordingBot) {
rb.StubMethod("getStickerSet", `{"name":"`+testSet+`","title":"My Pack","sticker_type":"regular","stickers":[]}`)
}
func TestNewPack_HappyPath(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setMissing(rb)
s := newTestState()
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if countMethod(rb, "createNewStickerSet") != 1 {
t.Fatalf("methods = %v, want one createNewStickerSet", methodsSent(rb))
}
pack, found := loadPack(t, s)
if !found {
t.Fatal("no pack record after a successful /newpack")
}
if pack.Pending {
t.Error("record is still Pending after success")
}
if pack.Name != testSet || pack.Count != 1 {
t.Errorf("pack = %+v, want name %q and count 1", pack, testSet)
}
if !strings.Contains(rb.LastSent().Text(), shareLink(testSet)) {
t.Errorf("reply = %q, want the share link", rb.LastSent().Text())
}
}
// The quota is the create-only write itself: there is no separate counter, so a
// second /newpack must lose on PutVersioned and never reach the API.
func TestNewPack_SecondPackRefusedWithoutAPICall(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
s := newTestState()
seedPack(t, s, 5)
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack another Another", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if countMethod(rb, "createNewStickerSet") != 0 || countMethod(rb, "getStickerSet") != 0 {
t.Errorf("methods = %v, want no sticker-set API calls", methodsSent(rb))
}
text := rb.LastSent().Text()
if !strings.Contains(text, "mypack") || !strings.Contains(text, "/delpack") {
t.Errorf("reply = %q, want it to name the existing slug and /delpack", text)
}
}
// Re-running the same command after an interruption must complete the pack,
// not report the slug taken. This is what keeps a crash from stranding a
// permanent URL.
func TestNewPack_ResumesInterruptedAttempt(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setExists(rb)
s := newTestState()
seedInterrupted(t, s, "mypack", testSet)
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
// The set already exists, so it is adopted rather than recreated.
if countMethod(rb, "createNewStickerSet") != 0 {
t.Errorf("methods = %v, want no create — the set already existed", methodsSent(rb))
}
pack, _ := loadPack(t, s)
if pack.Pending {
t.Error("record still Pending after the resumed attempt")
}
if strings.Contains(rb.LastSent().Text(), "taken") {
t.Errorf("reply = %q, want a success message", rb.LastSent().Text())
}
}
// A pending record under a *different* slug whose set exists must be adopted,
// not overwritten: overwriting orphans that set permanently, because adoption
// keys on the slug matching.
func TestNewPack_DifferentSlugAdoptsExistingSet(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setExists(rb)
s := newTestState()
seedInterrupted(t, s, "oldslug", testSet)
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack newslug New", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if countMethod(rb, "createNewStickerSet") != 0 {
t.Errorf("methods = %v, want no create", methodsSent(rb))
}
pack, _ := loadPack(t, s)
if pack.Slug != "oldslug" {
t.Errorf("slug = %q, want the adopted oldslug — the old set must not be orphaned", pack.Slug)
}
if pack.Pending {
t.Error("adopted record still Pending")
}
if !strings.Contains(rb.LastSent().Text(), "oldslug") {
t.Errorf("reply = %q, want it to name the adopted pack", rb.LastSent().Text())
}
}
// Same shape, but nothing was created under the old name: the record is free to
// take over.
func TestNewPack_DifferentSlugReplacesDeadIntent(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setMissing(rb)
s := newTestState()
seedInterrupted(t, s, "oldslug", "oldslug_by_testbot")
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if countMethod(rb, "createNewStickerSet") != 1 {
t.Fatalf("methods = %v, want one create", methodsSent(rb))
}
pack, _ := loadPack(t, s)
if pack.Slug != "mypack" || pack.Pending {
t.Errorf("pack = %+v, want a confirmed mypack record", pack)
}
}
// An unclassifiable getStickerSet failure means "unknown". Guessing either way
// is what strands slugs or orphans sets, so the handler must change nothing.
func TestNewPack_UnknownLookupErrorAborts(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`)
s := newTestState()
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if countMethod(rb, "createNewStickerSet") != 0 {
t.Errorf("methods = %v, want no create after an unknown error", methodsSent(rb))
}
// The intent and reservation must SURVIVE. "Unknown" means the set may
// exist; destroying either here is what permanently strands a slug. Keeping
// them is what makes re-running the same command recover.
pack, found := loadPack(t, s)
if !found || !pack.Pending {
t.Errorf("intent = (%+v, found=%v), want it kept and still pending for re-run recovery", pack, found)
}
if _, held, _ := getSlugReservation(context.Background(), s.slugs, "mypack"); !held {
t.Error("reservation dropped on an unknown error; another user could take the name while the set may exist")
}
}
// Telegram itself refusing the name — NOT the "another user of this bot holds
// it" case, which the reservation now settles before any API call (see
// TestNewPack_ForeignReservationRefusedBeforeAnyAPICall).
//
// The reachable path here is a short name Telegram still reserves after a
// delete (plan R11): our reservation is free, GetStickerSet says missing, and
// createNewStickerSet refuses. A classified refusal proves nothing was created,
// so both the intent and the reservation are released for a retry.
func TestNewPack_OccupiedSlugDropsIntent(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setMissing(rb)
rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_OCCUPIED")
s := newTestState()
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if _, found := loadPack(t, s); found {
t.Error("intent survived a rejected create")
}
if _, held, _ := getSlugReservation(context.Background(), s.slugs, "mypack"); held {
t.Error("reservation survived a classified refusal; the name would be held with no set behind it")
}
if !strings.Contains(rb.LastSent().Text(), "taken") {
t.Errorf("reply = %q, want the slug-taken message", rb.LastSent().Text())
}
}
func TestNewPack_ValidatesInput(t *testing.T) {
cases := []struct {
name string
text string
}{
{"no arguments", "/newpack"},
{"slug only", "/newpack mypack"},
{"leading digit", "/newpack 1pack Title"},
{"double underscore", "/newpack my__pack Title"},
{"trailing underscore", "/newpack mypack_ Title"},
{"too short", "/newpack ab Title"},
{"title too long", "/newpack mypack " + strings.Repeat("x", maxTitleLen+1)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setMissing(rb)
s := newTestState()
if err := s.handleNewPack(context.Background(), rb.Bot, stickerReply(tc.text, otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if countMethod(rb, "createNewStickerSet") != 0 {
t.Errorf("methods = %v, want rejection before any create", methodsSent(rb))
}
if _, found := loadPack(t, s); found {
t.Error("a rejected /newpack wrote a record")
}
})
}
}
func TestMyPack_MakesNoAPICalls(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 7)
upd := testutil.NewPrivateMessage(testUser, "/mypack")
if err := s.handleMyPack(context.Background(), rb.Bot, upd); err != nil {
t.Fatalf("handleMyPack: %v", err)
}
for _, call := range rb.Sent() {
if call.Method != "sendMessage" {
t.Errorf("unexpected API call %q; /mypack must read only the store", call.Method)
}
}
text := rb.LastSent().Text()
for _, want := range []string{"My Pack", "mypack", "7", shareLink(testSet)} {
if !strings.Contains(text, want) {
t.Errorf("reply %q missing %q", text, want)
}
}
}
// A stranded attempt is shown, not hidden: it blocks /newpack, and re-running
// the same command is what clears it.
func TestMyPack_ShowsPendingMarker(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
pack := seedPack(t, s, 0)
pack.Pending = true
if err := s.store.Put(context.Background(), packKey(testUser), pack); err != nil {
t.Fatalf("seed pending: %v", err)
}
if err := s.handleMyPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/mypack")); err != nil {
t.Fatalf("handleMyPack: %v", err)
}
if !strings.Contains(rb.LastSent().Text(), "incomplete") {
t.Errorf("reply = %q, want the incomplete marker", rb.LastSent().Text())
}
}
func TestMyPack_NoPack(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
if err := s.handleMyPack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/mypack")); err != nil {
t.Fatalf("handleMyPack: %v", err)
}
if !strings.Contains(rb.LastSent().Text(), "/newpack") {
t.Errorf("reply = %q, want it to point at /newpack", rb.LastSent().Text())
}
}
// The link cannot follow a rename, so the reply has to name the only route to a
// different one — otherwise the user is left at a dead end.
func TestRenamePack_NamesTheURLChangeRoute(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 4)
if err := s.handleRenamePack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/renamepack Better Name")); err != nil {
t.Fatalf("handleRenamePack: %v", err)
}
if countMethod(rb, "setStickerSetTitle") != 1 {
t.Fatalf("methods = %v, want one setStickerSetTitle", methodsSent(rb))
}
text := rb.LastSent().Text()
for _, want := range []string{"Better Name", shareLink(testSet), "/delpack", "/newpack"} {
if !strings.Contains(text, want) {
t.Errorf("reply %q missing %q", text, want)
}
}
pack, _ := loadPack(t, s)
if pack.Title != "Better Name" {
t.Errorf("Title = %q, want the new title committed", pack.Title)
}
}
func TestRenamePack_NoPack(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
if err := s.handleRenamePack(context.Background(), rb.Bot, testutil.NewPrivateMessage(testUser, "/renamepack Whatever")); err != nil {
t.Fatalf("handleRenamePack: %v", err)
}
if countMethod(rb, "setStickerSetTitle") != 0 {
t.Errorf("methods = %v, want no API call", methodsSent(rb))
}
}
// Anonymous senders are refused before any store or API access, on every
// command. Telegram gives every anonymous admin the same From.ID, so without
// this they would all share one pack — and under one-pack-per-user, the first
// one to run /newpack would own it and block the rest.
func TestHandlers_RefuseAnonymousSenders(t *testing.T) {
handlers := map[string]struct {
text string
run func(*state, context.Context, *bot.Bot, *models.Update) error
}{
"newpack": {"/newpack mypack My Pack", (*state).handleNewPack},
"mypack": {"/mypack", (*state).handleMyPack},
"addsticker": {"/addsticker 😂", (*state).handleAddSticker},
"delsticker": {"/delsticker", (*state).handleDelSticker},
"editsticker": {"/editsticker 😂", (*state).handleEditSticker},
"ordersticker": {"/ordersticker 0", (*state).handleOrderSticker},
"renamepack": {"/renamepack Title", (*state).handleRenamePack},
"delpack": {"/delpack", (*state).handleDelPack},
"setpackicon": {"/setpackicon", (*state).handleSetPackIcon},
}
for name, h := range handlers {
t.Run(name, func(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setMissing(rb)
s := newTestState()
seedPack(t, s, 3)
upd := stickerReply(h.text, testSet)
upd.Message.SenderChat = &models.Chat{ID: -100}
if err := h.run(s, context.Background(), rb.Bot, upd); err != nil {
t.Fatalf("%s: %v", name, err)
}
for _, call := range rb.Sent() {
if call.Method != "sendMessage" {
t.Errorf("%s called %q for an anonymous sender", name, call.Method)
}
}
if !strings.Contains(rb.LastSent().Text(), "personal account") {
t.Errorf("%s reply = %q, want the anonymous-sender refusal", name, rb.LastSent().Text())
}
})
}
}
// seedInterrupted recreates the state a crashed /newpack leaves behind: a
// pending pack record AND the global name reservation that always precedes it.
// Seeding the record alone would build a state production cannot reach.
func seedInterrupted(t *testing.T, s *state, slug, setName string) {
t.Helper()
ctx := context.Background()
pending := Pack{Slug: slug, Name: setName, Title: "Old", OwnerID: testUser, Pending: true}
if err := s.store.Put(ctx, packKey(testUser), pending); err != nil {
t.Fatalf("seed pending: %v", err)
}
if err := s.slugs.Put(ctx, slugKey(slug),
SlugReservation{Slug: slug, OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil {
t.Fatalf("seed reservation: %v", err)
}
}
// The pack-takeover regression. Share links are public, so any user can read a
// pack's slug off t.me and try to claim it. Before the global reservation, an
// attacker with no pack of their own reached createOrAdopt, found the victim's
// set existing, and adopted it — after which /delpack destroyed the victim's
// pack.
//
// The attacker must be refused, and must leave no trace: no adoption, no record
// of their own, and the victim's reservation untouched.
func TestNewPack_CannotSeizeAnotherUsersPack(t *testing.T) {
const victim, attacker = int64(1), int64(2)
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setExists(rb) // the victim's set resolves
s := newTestState()
ctx := context.Background()
if err := s.store.Put(ctx, packKey(victim),
Pack{Slug: "victimpack", Name: "victimpack_by_testbot", Title: "Victim", OwnerID: victim, Count: 9}); err != nil {
t.Fatalf("seed victim pack: %v", err)
}
if err := s.slugs.Put(ctx, slugKey("victimpack"),
SlugReservation{Slug: "victimpack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil {
t.Fatalf("seed victim reservation: %v", err)
}
upd := stickerReply("/newpack victimpack Mine Now", otherSet)
upd.Message.From.ID = attacker
if err := s.handleNewPack(ctx, rb.Bot, upd); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if !strings.Contains(rb.LastSent().Text(), "taken") {
t.Errorf("reply = %q, want the name refused as taken", rb.LastSent().Text())
}
if got, found, _ := getPack(ctx, s.store, attacker); found {
t.Errorf("attacker now holds a pack record %+v — takeover succeeded", got)
}
held, _, _ := getSlugReservation(ctx, s.slugs, "victimpack")
if held.OwnerID != victim {
t.Errorf("reservation owner = %d, want the victim (%d)", held.OwnerID, victim)
}
// The victim's own record must be exactly as it was.
pack, found, _ := getPack(ctx, s.store, victim)
if !found || pack.Count != 9 || pack.Title != "Victim" {
t.Errorf("victim pack = (%+v, found=%v), want it untouched", pack, found)
}
}
// The same protection has to hold for the resume path: a pending record whose
// slug is reserved by somebody else must not adopt either.
func TestNewPack_StaleIntentCannotAdoptForeignName(t *testing.T) {
const victim, attacker = int64(1), int64(2)
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setExists(rb)
s := newTestState()
ctx := context.Background()
// The attacker holds a pending record naming the victim's set, but the
// reservation belongs to the victim.
if err := s.store.Put(ctx, packKey(attacker),
Pack{Slug: "victimpack", Name: "victimpack_by_testbot", Title: "Old", OwnerID: attacker, Pending: true}); err != nil {
t.Fatalf("seed attacker intent: %v", err)
}
if err := s.slugs.Put(ctx, slugKey("victimpack"),
SlugReservation{Slug: "victimpack", OwnerID: victim, CreatedAt: fixedNow.UnixMilli()}); err != nil {
t.Fatalf("seed victim reservation: %v", err)
}
upd := stickerReply("/newpack otherslug Other", otherSet)
upd.Message.From.ID = attacker
if err := s.handleNewPack(ctx, rb.Bot, upd); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
pack, _, _ := getPack(ctx, s.store, attacker)
if pack.Name == "victimpack_by_testbot" && !pack.Pending {
t.Errorf("attacker adopted the victim's set via the stale-intent path: %+v", pack)
}
held, _, _ := getSlugReservation(ctx, s.slugs, "victimpack")
if held.OwnerID != victim {
t.Errorf("reservation owner = %d, want the victim (%d)", held.OwnerID, victim)
}
}
// F5's replacement: a name held by another user is now refused by the
// reservation, before any API call. The previous test of this name stubbed a
// combination (set missing + PACK_SHORT_NAME_OCCUPIED) that cannot occur for a
// set another user holds, so it never covered this case.
func TestNewPack_ForeignReservationRefusedBeforeAnyAPICall(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
s := newTestState()
ctx := context.Background()
if err := s.slugs.Put(ctx, slugKey("mypack"),
SlugReservation{Slug: "mypack", OwnerID: 999, CreatedAt: fixedNow.UnixMilli()}); err != nil {
t.Fatalf("seed reservation: %v", err)
}
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack Mine", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
for _, call := range rb.Sent() {
if call.Method != "sendMessage" && call.Method != "getMe" {
t.Errorf("called %q for a name held by another user; want refusal before any sticker API call", call.Method)
}
}
if !strings.Contains(rb.LastSent().Text(), "taken") {
t.Errorf("reply = %q, want the taken refusal", rb.LastSent().Text())
}
}
// The name-burning regression. Reservations are permanent and global, so
// writing one before establishing the caller is even entitled to a pack turned
// every refused /newpack into a free, unlimited denial primitive: no API call,
// no cost, and the name is gone for everyone else forever.
func TestNewPack_RefusedRunsClaimNoNames(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setMissing(rb)
s := newTestState()
ctx := context.Background()
seedPack(t, s, 3) // the caller already has a finished pack
for _, name := range []string{"memes", "funny", "cats"} {
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack "+name+" x", otherSet)); err != nil {
t.Fatalf("handleNewPack(%s): %v", name, err)
}
if !strings.Contains(rb.LastSent().Text(), "already have a pack") {
t.Fatalf("reply = %q, want the already-have-a-pack refusal", rb.LastSent().Text())
}
if _, held, _ := getSlugReservation(ctx, s.slugs, name); held {
t.Errorf("refused /newpack claimed %q — every other user is now permanently denied that name", name)
}
}
// Only the real pack's own name is reserved.
keys, err := s.slugs.List(ctx, slugPrefix)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(keys) != 1 {
t.Errorf("reservations = %d (%v), want exactly the one backing the real pack", len(keys), keys)
}
}
// A name is also not burned when the *set name* is unusable, or when anything
// else makes the command bail after reserving.
func TestNewPack_ReservationReleasedWhenClaimFails(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setMissing(rb)
rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_INVALID")
s := newTestState()
ctx := context.Background()
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
t.Error("reservation survived a refusal that proves nothing was created")
}
if _, found := loadPack(t, s); found {
t.Error("intent survived a refusal that proves nothing was created")
}
}
// A reservation the caller merely resumed must not be released when a later
// step bails — it predates this command.
func TestNewPack_ResumedReservationSurvivesABail(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`)
s := newTestState()
ctx := context.Background()
seedInterrupted(t, s, "mypack", testSet)
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); !held {
t.Error("a resumed reservation was released on an unknown error; the name is now claimable by others while the set may exist")
}
}
// The two tests below are the only coverage of the `created` flag itself.
//
// Both drive a bail *inside* claimSlug, which is the single place handleNewPack
// consults `created`. The other reservation tests bail later — in createOrAdopt
// — where a different mechanism (createRefused) does the releasing, so they
// pass with the `created` guard removed entirely and cannot pin it.
//
// Reaching claimSlug's bail takes a pending record under a *different* slug:
// that sends claimSlug into resolveStaleIntent, which gives up when it cannot
// establish what happened to the old set.
func seedStaleIntentBail(t *testing.T, rb *testutil.RecordingBot, s *state) {
t.Helper()
stubBotIdentity(rb)
// Unknown failure probing the *old* set: resolveStaleIntent refuses to
// guess, so handleNewPack bails holding whatever reserveSlug just did.
rb.FailMethod("getStickerSet", 500, `{"ok":false,"description":"upstream is unhappy"}`)
seedInterrupted(t, s, "oldname", otherSet)
}
// Direction 1: a name this invocation reserved must not survive the bail.
// Without the release, every refused attempt burns a global name for everyone.
func TestNewPack_FreshReservationReleasedWhenClaimBails(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
ctx := context.Background()
seedStaleIntentBail(t, rb, s)
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newname New Pack", testSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if _, held, _ := getSlugReservation(ctx, s.slugs, "newname"); held {
t.Error("a name reserved by this invocation survived its bail — the name is now permanently denied to every other user, with no pack behind it")
}
if _, held, _ := getSlugReservation(ctx, s.slugs, "oldname"); !held {
t.Error("the pre-existing reservation was collateral damage")
}
}
// Direction 2: a name the caller already held must survive the bail. Releasing
// it would hand a live claim to the next user to ask while the set may exist.
func TestNewPack_ResumedReservationNotReleasedWhenClaimBails(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
ctx := context.Background()
seedStaleIntentBail(t, rb, s)
// The caller already holds "newname" from an earlier run, so reserveSlug
// resumes it rather than creating it.
if err := s.slugs.Put(ctx, slugKey("newname"),
SlugReservation{Slug: "newname", OwnerID: testUser, CreatedAt: fixedNow.UnixMilli()}); err != nil {
t.Fatalf("seed prior reservation: %v", err)
}
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack newname New Pack", testSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if _, held, _ := getSlugReservation(ctx, s.slugs, "newname"); !held {
t.Error("a reservation that predates this command was released on its bail — another user can now claim a name whose set may already exist")
}
}
// A reservation is only proof of ownership while it outlives the sets it
// guards, and it does not: reservations live in our store, packs live at
// Telegram, and a restart on the in-memory backend wipes the former while every
// pack survives. This is the takeover of TestNewPack_CannotSeizeAnotherUsersPack
// replayed against an empty store, which is exactly what the attacker gets for
// free after any wipe.
//
// The set existing under a name this invocation has only just claimed proves
// the set is somebody else's: a real interrupted attempt reserved the name
// before creating the set, so it always finds its own reservation waiting.
func TestNewPack_WipedStoreCannotAdoptSurvivingPack(t *testing.T) {
rb := testutil.NewRecordingBot(t)
stubBotIdentity(rb)
setExists(rb) // the victim's pack outlived our store
s := newTestState()
ctx := context.Background()
// Deliberately empty: no reservations, no pack records, nothing.
if err := s.handleNewPack(ctx, rb.Bot, stickerReply("/newpack mypack My Pack", otherSet)); err != nil {
t.Fatalf("handleNewPack: %v", err)
}
if got := rb.LastSent().Text(); !strings.Contains(got, slugTaken) {
t.Errorf("reply = %q, want the name-taken refusal", got)
}
if pack, found := loadPack(t, s); found && !pack.Pending {
t.Errorf("adopted a pack that survived the wipe: %+v — /delpack would now destroy its real owner's set", pack)
}
for _, call := range rb.Sent() {
if call.Method == "createNewStickerSet" || call.Method == "setStickerSetTitle" {
t.Errorf("refused adoption still called %s", call.Method)
}
}
// The refusal must not burn the name either: the set's real owner has to be
// able to re-register it after the same wipe.
if _, held, _ := getSlugReservation(ctx, s.slugs, "mypack"); held {
t.Error("the refused attempt kept the reservation, denying the name to the set's actual owner")
}
}
+71
View File
@@ -0,0 +1,71 @@
package sticker
import (
"context"
"testing"
"github.com/tiennm99/miti99bot/internal/storage"
)
func newTestStore(t *testing.T) PackStore {
t.Helper()
return storage.Typed[Pack](storage.NewMemoryProvider().Collection("sticker"))
}
// Pack is persisted with its fields hoisted to the document root, so a bson tag
// colliding with a reserved root field would panic at startup. Typed panics on
// collision; constructing the store is the assertion.
func TestPack_NoReservedFieldCollision(t *testing.T) {
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("Pack collides with a reserved storage field: %v", rec)
}
}()
_ = newTestStore(t)
}
// The key is the owner ID alone, which is what makes the lookup itself the
// ownership check: there is no key shape that reads another user's pack.
func TestGetPack_IsolatesOwners(t *testing.T) {
ctx := context.Background()
store := newTestStore(t)
want := Pack{Slug: "alpha", Name: "alpha_by_bot", Title: "Alpha", OwnerID: 1, Count: 3}
if err := store.Put(ctx, packKey(1), want); err != nil {
t.Fatalf("put: %v", err)
}
got, found, err := getPack(ctx, store, 1)
if err != nil || !found {
t.Fatalf("getPack(owner 1) = (%+v, %v, %v), want found", got, found, err)
}
if got.Slug != want.Slug || got.Count != want.Count {
t.Errorf("getPack(owner 1) = %+v, want %+v", got, want)
}
other, found, err := getPack(ctx, store, 2)
if err != nil {
t.Fatalf("getPack(owner 2) error: %v", err)
}
if found {
t.Errorf("getPack(owner 2) returned owner 1's pack: %+v", other)
}
}
// A user who has never run /newpack is the normal case, not an error worth
// propagating to every caller.
func TestGetPack_MissingIsNotAnError(t *testing.T) {
got, found, err := getPack(context.Background(), newTestStore(t), 404)
if err != nil {
t.Fatalf("getPack(unknown) error: %v", err)
}
if found {
t.Errorf("getPack(unknown) found %+v, want not found", got)
}
}
func TestShareLink(t *testing.T) {
if got, want := shareLink("mypack_by_bot"), "https://t.me/addstickers/mypack_by_bot"; got != want {
t.Errorf("shareLink = %q, want %q", got, want)
}
}
+103
View File
@@ -0,0 +1,103 @@
package sticker
import (
"crypto/rand"
"encoding/hex"
"strconv"
"strings"
"time"
"github.com/tiennm99/miti99bot/internal/storage"
)
const (
// callbackPrefix owns this module's inline-button namespace. Checked
// bidirectionally against every other module's prefix at registry build.
callbackPrefix = "sticker_pack:"
// deleteCallbackPrefix is the confirm button for /delpack.
deleteCallbackPrefix = callbackPrefix + "d:"
// pendingDeletePrefix namespaces pending actions inside the collection the
// Pack records also live in.
pendingDeletePrefix = "pending-delete:"
// pendingDeleteTTL is short on purpose. The stock module uses 24h for a
// non-destructive suggestion; deleting a pack is irreversible on Telegram's
// side, so the window to confirm is minutes, not a day.
pendingDeleteTTL = 10 * time.Minute
// maxCallbackBytes is Telegram's cap on inline-button callback data.
maxCallbackBytes = 64
)
// PendingDeleteStore is the second typed view over the module's collection.
type PendingDeleteStore = storage.DocStore[PendingDelete]
// PendingDelete is the server-side half of a /delpack confirm button.
//
// The payload in the button is an opaque id and nothing else. Everything that
// decides whether a press is legitimate — who, where, which message, until
// when — lives here, where the user cannot edit it.
type PendingDelete struct {
ID string `bson:"id"`
OwnerID int64 `bson:"ownerId"`
Slug string `bson:"slug"`
SetName string `bson:"setName"`
ChatID int64 `bson:"chatId"`
MessageID int `bson:"messageId"`
CreatedAt int64 `bson:"createdAt"`
ExpiresAt int64 `bson:"expiresAt"`
}
// pendingDeleteKey is deterministic per user, so running /delpack twice
// supersedes the first prompt instead of leaving two independently valid
// delete capabilities in scrollback — the shape stock/pending_dividend.go
// already uses and documents.
//
// A random per-invocation key produced two live confirmations at once, which is
// worse than untidy: the stale one could be pressed after the pack it named was
// already deleted and a *different* pack created, and its "set is gone" result
// then cleared the new pack's record.
//
// It also bounds storage. Pending actions are only deleted when consumed, so a
// random key let anyone accumulate documents by running a public command and
// never tapping.
func pendingDeleteKey(ownerID int64) string {
return pendingDeletePrefix + strconv.FormatInt(ownerID, 10)
}
// newActionID returns an unguessable id for a pending action. Guessability
// matters: the id is the entire contents of the callback payload.
func newActionID() (string, error) {
var buf [12]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
return hex.EncodeToString(buf[:]), nil
}
// deleteCallbackData builds the button payload — a prefix plus the opaque id,
// well inside the 64-byte cap.
func deleteCallbackData(id string) string { return deleteCallbackPrefix + id }
// parseDeleteCallback recovers the action id from client-controlled callback
// data. The id is only a lookup key; every authorisation check happens against
// the stored action.
func parseDeleteCallback(data string) (string, bool) {
if len(data) > maxCallbackBytes {
return "", false
}
id, ok := strings.CutPrefix(data, deleteCallbackPrefix)
if !ok || id == "" {
return "", false
}
for _, r := range id {
if !isHexDigit(r) {
return "", false
}
}
return id, true
}
func isHexDigit(r rune) bool {
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')
}
+86
View File
@@ -0,0 +1,86 @@
package sticker
import (
"bytes"
"context"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
// imageDocumentMimes is the allowlist for a replied document. Anything else is
// rejected before a single byte is downloaded.
var imageDocumentMimes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/webp": true,
}
// 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 (s *state) 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)
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}, 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("That image is too large — keep it under 2 MB.")
}
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("That image is too large — keep it under 2 MB.")
}
return doc.FileID, nil
}
return "", refuse("Reply to a sticker, photo, or image file with this command.")
}
+86
View File
@@ -0,0 +1,86 @@
package sticker
import (
"testing"
"github.com/go-telegram/bot/models"
)
// Source selection happens before any download, so an unsupported or oversized
// file costs nothing.
func TestPhotoFileID(t *testing.T) {
cases := []struct {
name string
replied *models.Message
want string
ok bool
}{
{
// Sizes are picked by FileSize rather than array order.
name: "largest photo size wins",
replied: &models.Message{Photo: []models.PhotoSize{
{FileID: "small", FileSize: 100},
{FileID: "large", FileSize: 5000},
{FileID: "medium", FileSize: 900},
}},
want: "large", ok: true,
},
{
name: "png document",
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/png"}},
want: "doc", ok: true,
},
{
name: "jpeg document",
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/jpeg"}},
want: "doc", ok: true,
},
{
name: "webp document",
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/webp"}},
want: "doc", ok: true,
},
{
name: "pdf rejected",
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "application/pdf"}},
ok: false,
},
{
name: "gif document rejected",
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/gif"}},
ok: false,
},
{
name: "oversized photo rejected",
replied: &models.Message{Photo: []models.PhotoSize{{FileID: "huge", FileSize: maxSourceBytes + 1}}},
ok: false,
},
{
name: "oversized document rejected",
replied: &models.Message{Document: &models.Document{FileID: "doc", MimeType: "image/png", FileSize: maxSourceBytes + 1}},
ok: false,
},
{
name: "nothing usable",
replied: &models.Message{Text: "hello"},
ok: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := photoFileID(tc.replied)
if tc.ok {
if err != nil {
t.Fatalf("photoFileID: %v", err)
}
if got != tc.want {
t.Errorf("file_id = %q, want %q", got, tc.want)
}
return
}
if err == nil {
t.Fatalf("photoFileID = %q, want a refusal", got)
}
})
}
}
+120
View File
@@ -0,0 +1,120 @@
package sticker
import (
"context"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
// stickerFormatStatic is InputSticker.Format for this module. The whole module
// is static-only; animated and video packs are out of scope.
const stickerFormatStatic = "static"
// notOwnedRefusal answers every "that sticker is not yours to manage" case.
//
// It is deliberately the same sentence whether the caller has no pack at all,
// or replied to a sticker from someone else's pack, or from a set this bot did
// not create. Distinct wording would answer "does this set belong to another
// user of this bot?" for any set the caller can find — a question they have no
// standing to ask. The uniformity is the feature; do not "improve" this into
// three specific messages.
//
// /newpack's slug-occupancy answer is a separate, accepted disclosure: a share
// link is publicly probeable without the bot, so it reveals nothing new.
const notOwnedRefusal = "That sticker is not in your pack. Reply to a sticker from your own pack — /mypack shows it."
// usageReplyToSticker is the shared "you must reply to something" line.
const usageReplyToSticker = "Reply to a sticker with this command."
// stickerSource is a resolved sticker ready to be added to a set: whatever the
// replied message carried, reduced to a file_id.
type stickerSource struct {
fileID string // usable directly as InputSticker.Sticker
emoji []string // inherited from a replied sticker; at most one element
}
// ownedSticker is an existing sticker in the caller's own pack.
type ownedSticker struct {
fileID string
pack Pack
}
// resolveSource turns the replied message into a sticker source.
//
// It takes ctx, b, and ownerID even though the sticker branch uses none of
// them: the photo branch (which resolves by downloading the image and calling
// UploadStickerFile) lives in this same function, and declaring the full
// signature up front keeps that from churning every call site.
func (s *state) resolveSource(ctx context.Context, b *bot.Bot, ownerID int64, msg *models.Message) (stickerSource, error) {
replied := msg.ReplyToMessage
if replied == nil {
return stickerSource{}, refuse(usageReplyToSticker)
}
if st := replied.Sticker; st != nil {
if err := requireStaticSticker(st); err != nil {
return stickerSource{}, err
}
src := stickerSource{fileID: st.FileID}
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
}
return s.resolvePhotoSource(ctx, b, ownerID, replied)
}
// requireStaticSticker enforces the module's static-only scope on a sticker the
// bot is about to copy into a set.
//
// IsAnimated and IsVideo are the obvious half. Type is the half that is easy to
// miss: a mask sticker and a custom-emoji sticker are both static, and both are
// invalid in a regular sticker set, so the boolean pair alone would let them
// through to fail at the API with an opaque error.
func requireStaticSticker(st *models.Sticker) error {
if st.IsAnimated || st.IsVideo {
return refuse("This module handles static stickers only — that one is animated or video.")
}
if st.Type != "" && st.Type != "regular" {
return refuse("That is a mask or custom-emoji sticker, which cannot go in a regular pack.")
}
return nil
}
// resolveOwned resolves a replied sticker that must already be in the caller's
// own pack. It is the single ownership gate for /delsticker, /editsticker,
// /ordersticker, and /setpackicon.
//
// Costs exactly one store Get and a string comparison — no List, no API call.
func (s *state) resolveOwned(ctx context.Context, msg *models.Message, ownerID int64) (ownedSticker, error) {
replied := msg.ReplyToMessage
if replied == nil || replied.Sticker == nil {
return ownedSticker{}, refuse(usageReplyToSticker)
}
st := replied.Sticker
if st.SetName == "" {
return ownedSticker{}, refuse("That sticker does not belong to any pack.")
}
// Before the store read: a malformed reply costs nothing, and this keeps
// "rejected before any API call" true by construction.
if err := requireStaticSticker(st); err != nil {
return ownedSticker{}, err
}
pack, found, err := getPack(ctx, s.store, ownerID)
if err != nil {
return ownedSticker{}, err
}
// Both branches answer identically — see notOwnedRefusal.
if !found || pack.Pending {
return ownedSticker{}, refuse(notOwnedRefusal)
}
if !ownsSet(pack, st.SetName) {
return ownedSticker{}, refuse(notOwnedRefusal)
}
return ownedSticker{fileID: st.FileID, pack: pack}, nil
}
+190
View File
@@ -0,0 +1,190 @@
package sticker
import (
"context"
"testing"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// resolveOwnedText runs the gate and returns the refusal the user would see.
func resolveOwnedText(t *testing.T, s *state, upd *models.Update) string {
t.Helper()
_, err := s.resolveOwned(context.Background(), upd.Message, testUser)
if err == nil {
t.Fatal("resolveOwned succeeded; want a refusal")
}
ue, ok := err.(userError)
if !ok {
t.Fatalf("err is %T (%v), want userError", err, err)
}
return ue.msg
}
// The point of the uniform refusal: "you have no pack" and "that set is not
// yours" must be indistinguishable, or a user can probe which sets exist under
// this bot by elimination.
//
// This asserts the two replies against *each other* rather than against fixed
// strings, so rewording the copy cannot silently reintroduce the disclosure.
func TestResolveOwned_RefusalsAreIdentical(t *testing.T) {
noPack := newTestState()
noPackText := resolveOwnedText(t, noPack, stickerReply("/delsticker", otherSet))
withPack := newTestState()
seedPack(t, withPack, 2)
foreignText := resolveOwnedText(t, withPack, stickerReply("/delsticker", otherSet))
if noPackText != foreignText {
t.Errorf("refusals differ and leak whether a set exists:\n no pack: %q\n foreign: %q", noPackText, foreignText)
}
}
// A pending record is not a usable pack, and must refuse identically too.
func TestResolveOwned_PendingRefusesIdentically(t *testing.T) {
noPack := newTestState()
noPackText := resolveOwnedText(t, noPack, stickerReply("/delsticker", otherSet))
pendingState := newTestState()
pack := seedPack(t, pendingState, 0)
pack.Pending = true
if err := pendingState.store.Put(context.Background(), packKey(testUser), pack); err != nil {
t.Fatalf("seed pending: %v", err)
}
pendingText := resolveOwnedText(t, pendingState, stickerReply("/delsticker", testSet))
if noPackText != pendingText {
t.Errorf("pending refusal differs:\n no pack: %q\n pending: %q", noPackText, pendingText)
}
}
// Telegram echoes SetName with the casing the set was created with, so
// ownership has to fold case or a user's own pack stops resolving.
func TestResolveOwned_CaseInsensitiveSetName(t *testing.T) {
s := newTestState()
seedPack(t, s, 1)
owned, err := s.resolveOwned(context.Background(), stickerReply("/delsticker", "MyPack_By_TestBot").Message, testUser)
if err != nil {
t.Fatalf("resolveOwned with differing case: %v", err)
}
if owned.pack.Name != testSet {
t.Errorf("pack.Name = %q, want %q", owned.pack.Name, testSet)
}
}
func TestResolveOwned_UsageErrors(t *testing.T) {
s := newTestState()
seedPack(t, s, 1)
t.Run("no reply", func(t *testing.T) {
upd := testutil.NewPrivateMessage(testUser, "/delsticker")
if _, err := s.resolveOwned(context.Background(), upd.Message, testUser); err == nil {
t.Error("resolveOwned with no reply succeeded")
}
})
t.Run("reply is not a sticker", func(t *testing.T) {
upd := testutil.NewPrivateMessage(testUser, "/delsticker")
upd.Message.ReplyToMessage = &models.Message{Text: "hello"}
if _, err := s.resolveOwned(context.Background(), upd.Message, testUser); err == nil {
t.Error("resolveOwned with a text reply succeeded")
}
})
t.Run("sticker has no set", func(t *testing.T) {
upd := stickerReply("/delsticker", testSet)
upd.Message.ReplyToMessage.Sticker.SetName = ""
if _, err := s.resolveOwned(context.Background(), upd.Message, testUser); err == nil {
t.Error("resolveOwned with an empty set_name succeeded")
}
})
}
// The static-only gate. IsAnimated/IsVideo are the obvious half; Type is the
// half a boolean-only check misses — a mask sticker is static yet invalid in a
// regular set.
func TestRequireStaticSticker(t *testing.T) {
cases := []struct {
name string
sticker models.Sticker
ok bool
}{
{"regular", models.Sticker{Type: "regular"}, true},
{"type absent", models.Sticker{}, true},
{"animated", models.Sticker{Type: "regular", IsAnimated: true}, false},
{"video", models.Sticker{Type: "regular", IsVideo: true}, false},
{"mask", models.Sticker{Type: "mask"}, false},
{"custom emoji", models.Sticker{Type: "custom_emoji"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := requireStaticSticker(&tc.sticker)
if tc.ok && err != nil {
t.Errorf("requireStaticSticker = %v, want nil", err)
}
if !tc.ok && err == nil {
t.Error("requireStaticSticker = nil, want a refusal")
}
})
}
}
// Both entry points must enforce it: resolveSource is the one that can actually
// receive a non-static sticker, and neither may reach an API call.
func TestStaticGate_BlocksBothPathsBeforeAnyAPICall(t *testing.T) {
for _, tc := range []struct {
name string
mutate func(*models.Sticker)
setName string
}{
{"animated source", func(st *models.Sticker) { st.IsAnimated = true }, otherSet},
{"video source", func(st *models.Sticker) { st.IsVideo = true }, otherSet},
{"mask source", func(st *models.Sticker) { st.Type = "mask" }, otherSet},
{"animated owned", func(st *models.Sticker) { st.IsAnimated = true }, testSet},
{"mask owned", func(st *models.Sticker) { st.Type = "mask" }, testSet},
} {
t.Run(tc.name, func(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
seedPack(t, s, 1)
upd := stickerReply("/addsticker", tc.setName)
tc.mutate(upd.Message.ReplyToMessage.Sticker)
if err := s.handleAddSticker(context.Background(), rb.Bot, upd); err != nil {
t.Fatalf("handleAddSticker: %v", err)
}
if countMethod(rb, "addStickerToSet") != 0 {
t.Errorf("methods = %v, want no API call", methodsSent(rb))
}
rb2 := testutil.NewRecordingBot(t)
if err := s.handleDelSticker(context.Background(), rb2.Bot, upd); err != nil {
t.Fatalf("handleDelSticker: %v", err)
}
if countMethod(rb2, "deleteStickerFromSet") != 0 {
t.Errorf("methods = %v, want no API call", methodsSent(rb2))
}
})
}
}
// A replied sticker contributes at most one emoji, because models.Sticker.Emoji
// is a single string.
func TestResolveSource_InheritsSingleEmoji(t *testing.T) {
rb := testutil.NewRecordingBot(t)
s := newTestState()
src, err := s.resolveSource(context.Background(), rb.Bot, testUser, stickerReply("/addsticker", otherSet).Message)
if err != nil {
t.Fatalf("resolveSource: %v", err)
}
if len(src.emoji) != 1 || src.emoji[0] != "🎉" {
t.Errorf("emoji = %q, want exactly one 🎉", src.emoji)
}
if src.fileID == "" {
t.Error("fileID is empty")
}
}
+39
View File
@@ -0,0 +1,39 @@
package sticker
import (
"errors"
"github.com/go-telegram/bot/models"
)
// errNoPersonalSender is returned when a message carries no usable personal
// identity. Handlers turn it into senderRefusal.
var errNoPersonalSender = errors.New("sticker: no personal sender")
// senderRefusal explains the fix rather than only denying. Anonymous posting is
// a per-message toggle, so the user can act on this immediately.
const senderRefusal = "Sticker packs need a personal account. Turn off anonymous posting for this message and try again."
// senderID returns the personal Telegram user behind msg.
//
// Every pack is keyed by this value, so it must identify one human. Telegram
// substitutes a single global GroupAnonymousBot user for *every* anonymous
// group-admin message and puts the real origin in SenderChat: without the
// SenderChat check, all anonymous admins across all groups would share one
// pack. Under one-pack-per-user that is worse than a leak — the first anonymous
// admin to run /newpack would own the result and block every other one.
//
// Other modules check only From != nil && From.ID != 0, which is safe for
// paper-trading state but not for durable Telegram-side objects.
func senderID(msg *models.Message) (int64, error) {
if msg == nil || msg.From == nil || msg.From.ID == 0 {
return 0, errNoPersonalSender
}
if msg.From.IsBot {
return 0, errNoPersonalSender
}
if msg.SenderChat != nil {
return 0, errNoPersonalSender
}
return msg.From.ID, nil
}
+63
View File
@@ -0,0 +1,63 @@
package sticker
import (
"strings"
"testing"
"github.com/go-telegram/bot/models"
)
// Every pack is keyed by this value, so anything that is not one human must be
// refused before the key is built.
func TestSenderID(t *testing.T) {
cases := []struct {
name string
msg *models.Message
want int64
ok bool
}{
{"personal user", &models.Message{From: &models.User{ID: 42}}, 42, true},
{"nil message", nil, 0, false},
{"nil from", &models.Message{}, 0, false},
{"zero id", &models.Message{From: &models.User{ID: 0}}, 0, false},
{"bot sender", &models.Message{From: &models.User{ID: 7, IsBot: true}}, 0, false},
{
// Telegram substitutes one global GroupAnonymousBot user for every
// anonymous admin message; without this check they would all share
// a single pack.
"anonymous admin",
&models.Message{From: &models.User{ID: 1087968824, IsBot: true}, SenderChat: &models.Chat{ID: -100}},
0, false,
},
{
// A channel post carries SenderChat with a non-bot From.
"sender chat present",
&models.Message{From: &models.User{ID: 42}, SenderChat: &models.Chat{ID: -100}},
0, false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := senderID(tc.msg)
if tc.ok {
if err != nil || got != tc.want {
t.Fatalf("senderID = (%d, %v), want (%d, nil)", got, err, tc.want)
}
return
}
if err == nil {
t.Fatalf("senderID = (%d, nil), want an error", got)
}
})
}
}
// Denying without explaining leaves the user with no move; anonymous posting is
// a per-message toggle they can flip immediately.
func TestSenderRefusal_ExplainsTheFix(t *testing.T) {
for _, want := range []string{"personal account", "anonymous"} {
if !strings.Contains(senderRefusal, want) {
t.Errorf("senderRefusal %q does not mention %q", senderRefusal, want)
}
}
}
+119
View File
@@ -0,0 +1,119 @@
package sticker
import (
"context"
"fmt"
"regexp"
"strings"
"sync"
"github.com/go-telegram/bot"
)
const (
// maxSetNameLen is Telegram's cap on a sticker set's short name.
maxSetNameLen = 64
// maxTitleLen is Telegram's cap on a set title.
maxTitleLen = 64
// minSlugLen / maxSlugLen keep the share link readable and leave room for
// the "_by_<botusername>" suffix inside maxSetNameLen.
minSlugLen = 3
maxSlugLen = 40
)
// slugRe is the user-chosen half of a set name: 3-40 chars, starting with a
// letter. Telegram additionally forbids consecutive underscores, which a
// character class cannot express, so validateSlug checks that separately.
var slugRe = regexp.MustCompile(`^[a-z][a-z0-9_]{2,39}$`)
// validateSlug reports why a slug is unusable, or nil when it is fine.
//
// The slug is the one irreversible choice in this module: it fixes
// t.me/addstickers/<slug>_by_<bot> forever, because Telegram has no
// rename-short-name method. Rejecting loudly here is much cheaper than a user
// discovering the typo is permanent.
func validateSlug(slug string) error {
if !slugRe.MatchString(slug) {
return refuse(fmt.Sprintf("Pack name must be %d-%d characters: lowercase letters, digits and underscores, starting with a letter.", minSlugLen, maxSlugLen))
}
if strings.Contains(slug, "__") {
return refuse("Pack name cannot contain two underscores in a row.")
}
if strings.HasSuffix(slug, "_") {
return refuse("Pack name cannot end with an underscore.")
}
return nil
}
// makeSetName builds the Telegram set name for a new pack. It is used only at
// creation — never to resolve ownership, which compares the *stored* name (see
// ownsSet).
//
// The error reports the remaining budget rather than only refusing, because the
// only fix available to the user is a shorter slug and the limit depends on the
// bot's username length, which they cannot see.
func makeSetName(slug, botUsername string) (string, error) {
if botUsername == "" {
return "", errNoUsername
}
suffix := "_by_" + botUsername
if len(slug)+len(suffix) > maxSetNameLen {
budget := maxSetNameLen - len(suffix)
if budget > maxSlugLen {
budget = maxSlugLen
}
return "", refuse(fmt.Sprintf("Pack name is too long for this bot — use at most %d characters.", budget))
}
return slug + suffix, nil
}
// ownsSet reports whether setName is the caller's pack, comparing
// case-insensitively against the *stored* Pack.Name.
//
// It deliberately does not re-derive the name from the live bot username.
// Renaming the bot in BotFather is supported and leaves existing set names
// untouched, so a derived comparison would make every user's own pack refuse as
// "not yours" while /mypack still displayed it. Comparing the stored name also
// sidesteps casing: Telegram returns SetName with whatever casing the set was
// created with.
func ownsSet(pack Pack, setName string) bool {
if pack.Name == "" || setName == "" {
return false
}
return strings.EqualFold(pack.Name, setName)
}
// usernameResolver caches the bot's username for building new set names.
//
// The bot starts with bot.WithSkipGetMe(), so nothing populates a username
// until this asks. Failures are never cached: a transient GetMe error must not
// disable /newpack for the process's lifetime.
type usernameResolver 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 *usernameResolver) 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
}
+97
View File
@@ -0,0 +1,97 @@
package sticker
import (
"strings"
"testing"
)
func TestValidateSlug(t *testing.T) {
cases := []struct {
slug string
ok bool
}{
{"mypack", true},
{"my_pack_2", true},
{"abc", true},
{strings.Repeat("a", maxSlugLen), true},
{"ab", false}, // too short
{strings.Repeat("a", maxSlugLen+1), false}, // too long
{"1pack", false}, // leading digit
{"My_Pack", false}, // uppercase
{"my__pack", false}, // consecutive underscores
{"mypack_", false}, // trailing underscore
{"my-pack", false}, // hyphen
{"", false},
}
for _, tc := range cases {
err := validateSlug(tc.slug)
if tc.ok && err != nil {
t.Errorf("validateSlug(%q) = %v, want nil", tc.slug, err)
}
if !tc.ok && err == nil {
t.Errorf("validateSlug(%q) = nil, want an error", tc.slug)
}
}
}
func TestMakeSetName(t *testing.T) {
got, err := makeSetName("mypack", "miti99bot")
if err != nil {
t.Fatalf("makeSetName: %v", err)
}
if want := "mypack_by_miti99bot"; got != want {
t.Errorf("makeSetName = %q, want %q", got, want)
}
}
// The set name has a hard 64-char ceiling and the slug is the only part the
// user controls, so the refusal has to name the budget they actually have.
func TestMakeSetName_TooLongReportsBudget(t *testing.T) {
username := strings.Repeat("b", 30)
slug := strings.Repeat("a", maxSlugLen)
_, err := makeSetName(slug, username)
if err == nil {
t.Fatalf("makeSetName(%d-char slug, %d-char username) succeeded; want a refusal", len(slug), len(username))
}
// 64 - len("_by_" + username) = 30
if !strings.Contains(err.Error(), "30") {
t.Errorf("refusal %q does not state the remaining budget", err)
}
}
func TestOwnsSet(t *testing.T) {
pack := Pack{Slug: "mypack", Name: "mypack_by_bot"}
cases := []struct {
setName string
want bool
}{
{"mypack_by_bot", true},
{"MyPack_By_Bot", true}, // Telegram echoes the creation casing
{"otherpack_by_bot", false},
{"mypack_by_otherbot", false},
{"", false},
}
for _, tc := range cases {
if got := ownsSet(pack, tc.setName); got != tc.want {
t.Errorf("ownsSet(%q) = %v, want %v", tc.setName, got, tc.want)
}
}
if ownsSet(Pack{}, "anything") {
t.Error("ownsSet with an empty stored name = true, want false")
}
}
// Renaming the bot in BotFather leaves existing set names untouched. Ownership
// compares the stored name for exactly this reason: deriving it from the live
// username would make every user's own pack refuse as "not yours".
func TestOwnsSet_SurvivesBotRename(t *testing.T) {
pack := Pack{Slug: "mypack", Name: "mypack_by_oldbot"}
if !ownsSet(pack, "mypack_by_oldbot") {
t.Error("pack stopped resolving after the bot was renamed")
}
// The new username only ever builds *new* names.
fresh, err := makeSetName("newpack", "newbot")
if err != nil || fresh != "newpack_by_newbot" {
t.Errorf("makeSetName after rename = (%q, %v)", fresh, err)
}
}
+56
View File
@@ -0,0 +1,56 @@
package sticker
import (
"bytes"
"context"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
// handleSetPackIcon sets the pack thumbnail from a sticker already in the pack.
//
// The sticker's own file_id cannot simply be handed to Telegram: a pack
// thumbnail must be exactly 100x100, which a 512px sticker is not. So the image
// is fetched, resized, and uploaded as a new file.
func (s *state) handleSetPackIcon(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
owned, err := s.resolveOwned(ctx, msg, ownerID)
if err != nil {
return replyErr(ctx, b, msg, "sticker_setpackicon_resolve", err)
}
// Reserve the reply tail before the slow leg. See mediaContext.
mediaCtx, cancelMedia := mediaContext(ctx)
defer cancelMedia()
raw, err := downloadFile(mediaCtx, b, owned.fileID)
if err != nil {
return replyErr(ctx, b, msg, "sticker_setpackicon_download", err)
}
thumb, err := toThumbnailPNG(raw)
if err != nil {
return replyErr(ctx, b, msg, "sticker_setpackicon_resize", err)
}
if _, err := b.SetStickerSetThumbnail(ctx, &bot.SetStickerSetThumbnailParams{
Name: owned.pack.Name,
UserID: ownerID,
Thumbnail: &models.InputFileUpload{Filename: "thumb.png", Data: bytes.NewReader(thumb)},
Format: stickerFormatStatic,
}); err != nil {
if isStickerSetMissing(err) {
s.dropPackRecord(ctx, ownerID)
}
return replyAPIError(ctx, b, msg, "sticker_setpackicon", err)
}
return reply(ctx, b, msg, "Pack icon updated.")
}
+118
View File
@@ -0,0 +1,118 @@
package sticker
import (
"context"
"errors"
"strconv"
"strings"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/keylock"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
)
const (
// handlerTimeout bounds every handler in this module.
//
// 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. A handler making several sequential API calls could
// therefore freeze the bot for every user for minutes.
handlerTimeout = 10 * time.Second
// commitTimeout bounds a post-success store write. These run on a context
// detached from the request (see commitContext), so they need their own.
commitTimeout = 5 * time.Second
)
// state holds everything the handlers share. Mirrors the shape used by coin
// and stock: a typed store, a second typed view for pending actions, the
// per-user lock map, and an injectable clock.
type state struct {
store PackStore
pending PendingDeleteStore
slugs SlugStore
resolver usernameResolver
locks keylock.Map
nowFn func() time.Time
}
func (s *state) now() time.Time {
if s.nowFn != nil {
return s.nowFn()
}
return time.Now().UTC()
}
// commitContext detaches a store write from the request context.
//
// rootCtx is cancelled by SIGTERM, and a handler is most likely to be mid-flight
// exactly when a deploy lands. A commit that records a completed Telegram-side
// action must not be lost because the process is shutting down: at that point
// the set already exists and only the bot's memory of it is at stake.
func commitContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), commitTimeout)
}
// handlerContext applies the module-wide deadline.
func handlerContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, handlerTimeout)
}
// mediaContext bounds the download-and-upload leg of a handler, reserving the
// tail of the parent's budget for what comes after it.
//
// This module is the only one that spends most of its deadline before it has
// anything to say: a photo /newpack downloads, resamples and re-uploads before
// it calls CreateNewStickerSet. 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. chathelper.FetchContext is the existing fix for
// exactly this, already used by coin, gold, stock and monkeyd.
func mediaContext(ctx context.Context) (context.Context, context.CancelFunc) {
return chathelper.FetchContext(ctx)
}
// lockUser serialises a user's mutations. Handlers run one at a time today, but
// the cron scheduler and the detached per-command stats hook run concurrently
// with them, so this is load-bearing rather than decorative.
func (s *state) lockUser(ownerID int64) func() {
return s.locks.Acquire(strconv.FormatInt(ownerID, 10))
}
// commandArgs returns the whitespace-separated arguments after the command.
func commandArgs(msg *models.Message) []string {
return strings.Fields(chathelper.ArgAfterCommand(msg.Text))
}
// commandArgText returns the raw text after the command, trimmed.
func commandArgText(msg *models.Message) string {
return chathelper.ArgAfterCommand(msg.Text)
}
// reply sends text as a reply to msg.
func reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error {
return chathelper.Reply(ctx, b, msg, text)
}
// 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 this module must never echo one.
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 reply(ctx, b, msg, ue.msg)
}
log.Error(op, "err", err)
return reply(ctx, b, msg, genericFailure)
}
const genericFailure = "Something went wrong. Try again in a moment."
+89
View File
@@ -0,0 +1,89 @@
package sticker
import (
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
)
// New is the sticker-packs module factory.
//
// Commands are unprefixed so they match the names @Stickers uses: the registry
// keys commands by Command.Name independent of module name, which is how misc
// ships /ff. Three typed views share one collection, with disjoint key spaces:
// Pack records keyed by owner ID, name reservations under "slug:", and pending
// deletes under "pending-delete:".
func New(deps modules.Deps) modules.Module {
s := &state{
store: storage.Typed[Pack](deps.Store),
pending: storage.Typed[PendingDelete](deps.Store),
slugs: storage.Typed[SlugReservation](deps.Store),
}
return modules.Module{
Commands: []modules.Command{
{
Name: "newpack",
Visibility: modules.VisibilityPublic,
Description: "Create your sticker pack from a replied sticker",
Parameters: "<pack> <title...>",
Handler: s.handleNewPack,
},
{
Name: "mypack",
Visibility: modules.VisibilityPublic,
Description: "Show your sticker pack and its link",
Handler: s.handleMyPack,
},
{
Name: "addsticker",
Visibility: modules.VisibilityPublic,
Description: "Add the replied sticker to your pack",
Parameters: "[emoji...]",
Handler: s.handleAddSticker,
},
{
Name: "delsticker",
Visibility: modules.VisibilityPublic,
Description: "Remove the replied sticker from your pack",
Handler: s.handleDelSticker,
},
{
Name: "editsticker",
Visibility: modules.VisibilityPublic,
Description: "Change the emoji of a sticker in your pack",
Parameters: "<emoji...>",
Handler: s.handleEditSticker,
},
{
Name: "ordersticker",
Visibility: modules.VisibilityPublic,
Description: "Move a sticker in your pack to a position",
Parameters: "<position>",
Handler: s.handleOrderSticker,
},
{
Name: "setpackicon",
Visibility: modules.VisibilityPublic,
Description: "Set your pack's icon from a sticker in it",
Handler: s.handleSetPackIcon,
},
{
Name: "renamepack",
Visibility: modules.VisibilityPublic,
Description: "Change your pack's title (the link cannot change)",
Parameters: "<title...>",
Handler: s.handleRenamePack,
},
{
Name: "delpack",
Visibility: modules.VisibilityPublic,
Description: "Delete your pack after confirmation",
Handler: s.handleDelPack,
},
},
Callbacks: []modules.Callback{{
Prefix: callbackPrefix,
Visibility: modules.VisibilityPublic,
Handler: s.handleDelPackCallback,
}},
}
}
@@ -0,0 +1,224 @@
package sticker
import (
"context"
"fmt"
"strconv"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/log"
)
// maxStickersPerPack is Telegram's documented ceiling for a regular set. It is
// not enforced locally — the server is the authority and a local copy would go
// stale — but it is quoted back to the user when the server refuses.
const maxStickersPerPack = 120
// handleAddSticker adds the replied sticker (or, from Phase 5, photo) to the
// caller's pack.
func (s *state) handleAddSticker(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
// Every argument is an emoji: with no pack token to disambiguate, a stray
// word fails here rather than being silently read as something else.
emoji, err := parseEmoji(commandArgs(msg))
if err != nil {
return replyErr(ctx, b, msg, "sticker_addsticker_emoji", err)
}
pack, found, err := getPack(ctx, s.store, ownerID)
if err != nil {
log.Error("sticker_addsticker_load", "err", err)
return reply(ctx, b, msg, genericFailure)
}
// Unlike resolveOwned's deliberately uniform refusal, this one is specific:
// it answers only "do *you* have a pack", about the caller's own state, and
// so discloses nothing about anyone else.
if !found {
return reply(ctx, b, msg, noPackYet)
}
if pack.Pending {
return reply(ctx, b, msg, noPackYet+pendingMarker)
}
source, err := s.resolveSource(ctx, b, ownerID, msg)
if err != nil {
return replyErr(ctx, b, msg, "sticker_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}
}
defer s.lockUser(ownerID)()
_, err = b.AddStickerToSet(ctx, &bot.AddStickerToSetParams{
UserID: ownerID, // always the caller: a non-owner never reaches this call
Name: pack.Name,
Sticker: models.InputSticker{
Sticker: source.fileID,
Format: stickerFormatStatic,
EmojiList: emoji,
},
})
if err != nil {
if isStickerSetMissing(err) {
s.dropPackRecord(ctx, ownerID)
}
return replyAPIError(ctx, b, msg, "sticker_addsticker", err)
}
updated, err := s.adjustCount(ctx, ownerID, +1)
if err != nil {
// The sticker is already in the set; only our count is stale.
log.Error("sticker_addsticker_commit", "err", err)
updated = pack
updated.Count++
}
return reply(ctx, b, msg, fmt.Sprintf("Added to %s (%d stickers).\n%s",
updated.Title, updated.Count, shareLink(updated.Name)))
}
// handleDelSticker removes the replied sticker from the caller's pack.
//
// It deliberately does *not* probe afterwards to see whether the set survived.
// Whether removing the last sticker also destroys the set is undocumented, and
// an earlier design that probed would have deleted the pack record whenever the
// probe merely failed — so a 429, a DNS blip, or a SIGTERM during a routine
// delete would erase the only record of a live pack. Deleting the record needs
// a positive signal, and the next command's STICKERSET_INVALID is one.
func (s *state) handleDelSticker(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
owned, err := s.resolveOwned(ctx, msg, ownerID)
if err != nil {
return replyErr(ctx, b, msg, "sticker_delsticker_resolve", err)
}
// Same read-modify-write on Count as /addsticker, so the same lock.
defer s.lockUser(ownerID)()
if _, err := b.DeleteStickerFromSet(ctx, &bot.DeleteStickerFromSetParams{Sticker: owned.fileID}); err != nil {
if isStickerSetMissing(err) {
s.dropPackRecord(ctx, ownerID)
}
return replyAPIError(ctx, b, msg, "sticker_delsticker", err)
}
pack, err := s.adjustCount(ctx, ownerID, -1)
if err != nil {
// The sticker is already gone from the set; only our count is stale.
log.Error("sticker_delsticker_commit", "err", err)
pack = owned.pack
if pack.Count > 0 {
pack.Count--
}
}
if pack.Count == 0 {
// Telegram may have removed the now-empty set. /mypack makes no API
// calls so it cannot notice, and /newpack stays blocked while a record
// exists — so name the command that clears it.
return reply(ctx, b, msg, "Removed. Your pack is now empty, and Telegram may have deleted it. "+
"If /addsticker says the pack is gone, use /delpack to clear it and /newpack to start again.")
}
return reply(ctx, b, msg, fmt.Sprintf("Removed. %s now has %d sticker(s).", pack.Title, pack.Count))
}
// handleEditSticker replaces the emoji of a sticker in the caller's pack.
func (s *state) handleEditSticker(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
emoji, err := parseEmoji(commandArgs(msg))
if err != nil {
return replyErr(ctx, b, msg, "sticker_editsticker_emoji", err)
}
// An empty emoji_list is invalid, so this cannot fall back to a default the
// way /addsticker does: the user has to say what they want.
if len(emoji) == 0 {
return reply(ctx, b, msg, "Usage: /editsticker <emoji...>\nReply to a sticker in your pack with at least one emoji.")
}
owned, err := s.resolveOwned(ctx, msg, ownerID)
if err != nil {
return replyErr(ctx, b, msg, "sticker_editsticker_resolve", err)
}
if _, err := b.SetStickerEmojiList(ctx, &bot.SetStickerEmojiListParams{
Sticker: owned.fileID,
EmojiList: emoji,
}); err != nil {
if isStickerSetMissing(err) {
s.dropPackRecord(ctx, ownerID)
}
return replyAPIError(ctx, b, msg, "sticker_editsticker", err)
}
return reply(ctx, b, msg, "Emoji updated.")
}
// handleOrderSticker moves a sticker to a new position in the caller's pack.
func (s *state) handleOrderSticker(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := handlerContext(ctx)
defer cancel()
msg := update.Message
ownerID, err := senderID(msg)
if err != nil {
return reply(ctx, b, msg, senderRefusal)
}
args := commandArgs(msg)
if len(args) != 1 {
return reply(ctx, b, msg, "Usage: /ordersticker <position>\nReply to a sticker in your pack. Positions start at 0.")
}
pos, err := strconv.Atoi(args[0])
if err != nil || pos < 0 {
// Only the lower bound is checked locally. The upper bound is the set's
// current size, which Telegram knows and a local copy would not.
return reply(ctx, b, msg, "Position must be a whole number, 0 or greater.")
}
owned, err := s.resolveOwned(ctx, msg, ownerID)
if err != nil {
return replyErr(ctx, b, msg, "sticker_ordersticker_resolve", err)
}
if _, err := b.SetStickerPositionInSet(ctx, &bot.SetStickerPositionInSetParams{
Sticker: owned.fileID,
Position: pos,
}); err != nil {
if isStickerSetMissing(err) {
s.dropPackRecord(ctx, ownerID)
}
return replyAPIError(ctx, b, msg, "sticker_ordersticker", err)
}
return reply(ctx, b, msg, fmt.Sprintf("Moved to position %d.", pos))
}