mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 02:16:31 +00:00
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:
1 parent
e1bf7f1ddb
commit
3751010b8e
35 files changed
+5178
-5
No files matched your search
+5
-2
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>]
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user