Production data is migrated and every marker is recorded in the system
collection, so the stock dividend-history, sticker legacy-pack and stats
stock_dividend migrations no longer do anything. Stats keeps its startup
index creation and still hides retired stock_dividend rows.
BREAKING CHANGE: the Go module path is now github.com/tiennm99/tiennm99bot,
the default sticker pack is stickers_by_<bot username>, and the health body,
deploy DM, User-Agents and image names say tiennm99bot.
The default sticker pack name and the lol User-Agent now follow whichever
bot runs the code, so a bot account change needs no code edit. The default
pack becomes miti99_by_<bot username>.
This reverts commit 153fc21. With no fallback in code, the deploy
crash-looped: Coolify keeps an entry for every variable compose.yml has
referenced, and empty entries appear to reach the containers as empty
values instead of the compose defaults.
compose.yml now holds each default as ${VAR:-default}, and the code keeps
no fallback values. The bot stops at startup on a missing or invalid
PORT or LOG_LEVEL, /addsticker refuses without STICKER_PACK_NAME, and the
renderer refuses to start until every RENDERER_* setting is a valid
value, listing each problem. Settings whose empty value means none or
all (MODULES, OWNER_ID, ADMIN_IDS, the API tokens) use ${VAR:-}.
The renderer's npm start and dev load .env when present, so a local run
works from a copy of .env.example.
BREAKING CHANGE: running outside compose now requires LOG_LEVEL and PORT
for the bot, STICKER_PACK_NAME for /addsticker, and every RENDERER_*
variable for the renderer.
Correct comments that described the retired webhook transport, a removed
/cron route, the old KV store and wrapper types, and behaviour that has since
changed; drop plan and review labels; reword two startup log lines that
overstated or misnamed what they report.
The module stores nothing: /addsticker takes its pack from STICKER_PACK_NAME
and the set owner from OWNER_ID, and the factory ignores the collection it is
handed. Everything still in the sticker collection is therefore unreachable by
any code path — pack documents keyed by owner ID, "slug:" name reservations and
"pending-delete:" confirmations, all orphaned when the per-user commands were
removed.
InitStore lists and deletes them once per database, guarded by a systemstate
marker in the same shape as the stock and stats migrations. It aborts without
writing the marker so a partial run retries on the next boot, and deletes are
idempotent. A collection that is already empty is the normal case on a fresh
deploy and on the memory backend.
This permanently removes data. Back up the sticker collection before the first
deploy that carries it.
/alias <name> saves a replied message under a name and /insert <name> sends it
back; /aliases lists every name and /unalias deletes one. Every Telegram format
is supported — sticker, photo, GIF, video, video note, audio, voice, document,
plain text — and each is kept as the file_id Telegram already issued, so nothing
is downloaded and an alias survives redeploys. The namespace is global and the
last assignment wins, matching the shared sticker pack; /unalias is open to
anyone for the same reason.
A saved name also works as its own command: /cheer rather than /insert cheer.
This needs two new seams in the module contract. Module.Fallback handles a
/command no module registered, and the dispatcher installs it after every
Command — the bot library returns the first matching handler, so code always
beats a name resolved at runtime, including an alias that shares a command
added in a later build. /alias refuses a name already in the registry for the
same reason, since such an alias would only reach /insert. An unknown command
stays silent: the fallback sees every unrecognised /foo in every chat, so
replying would make typos noisy and would confirm which names exist.
Module.Inline answers inline-mode queries — "@botname <prefix>" from any chat,
filtered by prefix and capped at Telegram's 50 results. Each result is a cached
inline type carrying the stored file_id, so the picker renders real previews
without an upload. Video notes are omitted because Telegram defines no
InlineQueryResultCachedVideoNote and substituting a plain video would change
what was saved. Inline mode must be enabled in BotFather before Telegram
delivers these updates.
Both slots are single-occupancy with conflict detection at Build. Auth.Permits
learns the inline sender so a gated inline handler would not deny everyone.
Build's command indexing and slot claiming move into addCommands/addSingletons,
keeping it under the project's cyclomatic cap.
Also restores the sticker module: /addsticker moves back out of util, which has
no store, into internal/modules/sticker as its only command.
/addsticker becomes a single stateless command in util, writing to one
env-configured set (STICKER_PACK_NAME, default miti99_by_miti99bot).
AddStickerToSet takes the set owner's user ID rather than the caller's, so
nothing is per-user any more: the sticker module's pack records, slug
reservations, pending deletes, per-user locks and its eight other commands are
removed with it.
The pack creates itself on first use. A positive STICKERSET_INVALID from the
add triggers createNewStickerSet owned by OWNER_ID, seeded with the sticker
that triggered it and titled with the slug half of the name; a name that is
occupied but unwritable is reported instead of taken over. The mandatory
"_by_<bot_username>" suffix is Telegram's own proof of authorship, so a
misconfigured pack name is refused offline before any API call. StickerSet
exposes no owner ID, so ownership is only provable when Telegram refuses.
Video, GIF, animation and video-note sources are transcoded to WEBM/VP9 with
ffmpeg: long edge scaled to exactly 512 in either direction, cut to 3s, capped
at 30fps, audio dropped, retried down a CRF ladder until under 256KB. Animated
and video stickers are copied by file_id with no conversion. Sticker format is
per-sticker since Bot API 7.2, so one pack holds all three.
ffmpeg cannot ship in distroless/static and Go has no VP9 encoder, so the
runtime base becomes alpine with apk add ffmpeg. The image grows from roughly
20MB to 213MB, and the transcode holds the single dispatcher worker — bounded
at 20s per encode and a 45s handler deadline for moving sources, against 10s
for stills.
The previous commit's regression test never reached the guard it was
named for. It broke the pack record with dropPackRecord, which now also
clears the confirmation, so the callback returned at the pending.Get miss
long before the allowlist. The test passed with the entire guard
reverted - shipping the fix with its own detector inoperative, which is
the defect that let five earlier rounds report a false clean.
Replace it with a table that leaves the confirmation intact and breaks
the record three ways, one per disjunct: record gone, record unconfirmed,
record moved on. Reverting the guard now fails two cases; each disjunct
was mutated individually.
The !found disjunct is an equivalent mutant: ownsSet already returns
false for a zero-value record's empty Name, so no test can kill it. Kept
and commented, because that redundancy is an accident of ownsSet's
empty-string guard rather than something this check should rely on.
Also revert the set-name half of the previous commit's resume change.
Carrying the retyped title is right; re-deriving Pack.Name was not. The
name comes from the bot username, which can change at BotFather, and the
stored one identifies the set the interrupted attempt may already have
created - refreshing it orphaned that set and aimed later commands at a
different name, contradicting ownsSet's own documented rule. Pinned.
A /delpack confirmation outlived the record that authorised it. The
under-lock re-check listed the states it would refuse - a pending record
still naming this set - and fell through on the two that mattered: no
record at all, and a record that had moved on to a different pack.
Reachable with ordinary commands and no attacker: run /delpack without
pressing, let the pack disappear from Telegram's side so a self-heal
frees the name, let another user claim it, then press. DeleteStickerSet
is keyed by set name, which Telegram authorises for every set this bot
created, so the press destroys whoever holds the name at that moment.
Invert the guard: delete only when a confirmed record still names this
exact set. A check phrased as "which states do I refuse" cannot fail
closed against a state nobody enumerated. Dropping a pack record now also
clears any outstanding confirmation, so a dead prompt stops existing
rather than merely being refused on use.
This also stops the reservation leaking when a confirmed delete lands on
a record that has moved on, since that case no longer reaches Telegram.
Alongside:
- Resuming an interrupted /newpack discarded a retyped title and reported
success quoting the old one.
- TestNewPack_DifferentSlugReplacesDeadIntent was named for releasing a
dead name and never asserted it.
- lockUser's comment justified the lock with cron and stats-hook
contention that does not exist: the map is state-local and this module
registers neither. The lock stays for the read-modify-write pattern; a
wrong reason for a right guard misleads the next reader.
Two ordinary /newpack commands could take over a stranger's pack. The
first probe returns an inconclusive error, which correctly keeps the
reservation so the user can retry - but that turned a fresh claim into a
resumed one and defeated the guard that made adoption conditional.
resolveStaleIntent had a second adopt path that never consulted the
guard at all. Both are reproduced by tests added here.
This is the fourth failure of the same mechanism, and it is structural.
Adoption must prove "this set is mine to finish" from local state, and
local state is what a restart on the in-memory backend erases while the
packs at Telegram survive. With the proof gone, a genuine interrupted
attempt and a stranger naming a public share link are indistinguishable.
Remove adoption entirely. /newpack refuses any name a set already
occupies, and leaves no intent or reservation behind when it does.
A pending record is not evidence of ownership either: anyone can make one
naming any set, and DeleteStickerSet is keyed by set name, which Telegram
authorises for every set this bot created. /delpack therefore clears a
pending record locally and contacts Telegram only for a confirmed one.
The cost is that a crash between creating a set and recording it strands
that set. That is documented rather than mitigated - every mitigation
available is the mechanism that just failed.
Also drop a test whose name claimed to pin the resumed-reservation
distinction but bailed past the code that implements it, rename a delpack
test after the guard that actually stops a foreign presser, and pin
releaseSlug's ownership check and detached read - the latter needed a
context-honouring store, since the in-memory one ignores cancellation and
made the first version of that test vacuous.
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.