feat(sticker): add sticker pack module

Nine commands mirroring the names @Stickers uses: /newpack, /mypack,
/addsticker, /delsticker, /editsticker, /ordersticker, /setpackicon,
/renamepack and /delpack, plus a confirm callback for the destructive
one. Sources are replied stickers, photos or image documents; photos are
downloaded, resampled to 512px and re-uploaded.

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

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

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

Enabled explicitly via MODULES rather than by default.
This commit is contained in:
tiennm99 committed 2026-08-25 15:54:28 +07:00
1 parent e1bf7f1ddb
commit 3751010b8e
35 files changed
+5178 -5

No files matched your search

+27
View File
@@ -72,6 +72,15 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"monkeyd_crawl": "<url> [font_size]",
"monkeyd_tags": "<url>",
"random": "<option,...>",
"newpack": "<pack> <title...>",
"mypack": "",
"addsticker": "[emoji...]",
"delsticker": "",
"editsticker": "<emoji...>",
"ordersticker": "<position>",
"setpackicon": "",
"renamepack": "<title...>",
"delpack": "",
"stats": "[users | user <username> | cmd <command_name>]",
"stock_events": "<ticker> [days]",
"stock_info": "<ticker>",
@@ -91,7 +100,11 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
if len(menu) != len(reg.PublicCommands()) {
t.Fatalf("menu commands = %d, public commands = %d", len(menu), len(reg.PublicCommands()))
}
// This map lists the commands whose parameter strings are pinned; commands
// absent from it are not asserted (the lookup yields "" for them).
seen := map[string]bool{}
for _, command := range reg.PublicCommands() {
seen[command.Name] = true
if got := command.Parameters; got != expectedParameters[command.Name] {
t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name])
}
@@ -107,6 +120,20 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
}
}
// Every expectation must correspond to a registered command.
//
// Without this, an entry whose command stopped being registered simply
// stopped being checked: the forward loop only visits commands that exist,
// so removing a whole module from factories() left the suite green. That is
// also what makes the parameterless entries above load-bearing rather than
// decorative — "" == "" asserts nothing on its own, but the command having
// to exist at all does.
for name := range expectedParameters {
if !seen[name] {
t.Errorf("/%s is expected but not registered — a module dropped out of factories()", name)
}
}
help := moduleutil.RenderHelp(reg)
if utf8.RuneCountInString(help) > telegramMessageMaxRunesForTest {
t.Fatalf("/help source is %d characters, exceeds conservative Telegram limit %d", utf8.RuneCountInString(help), telegramMessageMaxRunesForTest)
+2
View File
@@ -26,6 +26,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/misc"
"github.com/tiennm99/miti99bot/internal/modules/monkeyd"
"github.com/tiennm99/miti99bot/internal/modules/stats"
"github.com/tiennm99/miti99bot/internal/modules/sticker"
"github.com/tiennm99/miti99bot/internal/modules/stock"
"github.com/tiennm99/miti99bot/internal/modules/util"
"github.com/tiennm99/miti99bot/internal/modules/wordle"
@@ -91,6 +92,7 @@ func factories() map[string]modules.Factory {
"gold": gold.New,
stock.CollectionName: stock.New,
"stats": stats.New,
"sticker": sticker.New,
}
}