feat(alias): add a shared alias dictionary invocable as a bare command

/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.
This commit is contained in:
tiennm99 committed 2026-09-04 11:36:50 +07:00
1 parent 822bcdbca8
commit be91d2eb41
33 files changed
+1882 -52

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ MONGO_DATABASE=miti99bot
# 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.
MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd
MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker,alias
# 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).
+3 -1
View File
@@ -7,7 +7,7 @@ Atlas via long polling and an in-process cron scheduler.
| Module | What it does |
|---|---|
| `util` | `/help`, `/info`, `/stickerid`, `/addsticker` (adds a replied sticker, image, video or GIF to one shared pack — see [docs/sticker-packs.md](docs/sticker-packs.md)) |
| `util` | `/help`, `/info`, `/stickerid` |
| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/xlt1`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only |
| `wordle` | Daily Wordle game |
@@ -17,6 +17,8 @@ Atlas via long polling and an in-process cron scheduler.
| `gold` | Gold paper trading (VNAppMob SJC buy/sell VND/luong) |
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <username>`, `/stats cmd <command_name>` |
| `sticker` | `/addsticker` — append a replied sticker, image, video or GIF to one shared pack. See [docs/sticker-packs.md](docs/sticker-packs.md) |
| `alias` | `/alias <name>` save a replied message under a name, then send it back with `/insert <name>`, bare `/<name>`, or inline `@botname <prefix>`; `/aliases` lists, `/unalias` deletes. See [docs/aliases.md](docs/aliases.md) |
| `monkeyd` | `/monkeyd_crawl <url> [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags <url>` list its tags as hashtags |
Disable modules with the `MODULES` environment variable.
+4
View File
@@ -73,6 +73,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"monkeyd_tags": "<url>",
"random": "<option,...>",
"addsticker": "[emoji...]",
"alias": "<name>",
"aliases": "",
"unalias": "<name>",
"insert": "<name>",
"stats": "[users | user <username> | cmd <command_name>]",
"stock_events": "<ticker> [days]",
"stock_info": "<ticker>",
+15 -11
View File
@@ -18,6 +18,7 @@ import (
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/metrics"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/alias"
"github.com/tiennm99/miti99bot/internal/modules/amlich"
"github.com/tiennm99/miti99bot/internal/modules/coin"
"github.com/tiennm99/miti99bot/internal/modules/gold"
@@ -26,6 +27,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"
@@ -80,17 +82,19 @@ func resolveCommitSHA(envSourceCommit string) string {
// import cycle (modules → util → modules).
func factories() map[string]modules.Factory {
return map[string]modules.Factory{
"util": util.New,
"misc": misc.New,
"amlich": amlich.New,
"monkeyd": monkeyd.New,
"wordle": wordle.New,
"loldle": loldle.New,
lol.CollectionName: lol.New,
coin.CollectionName: coin.New,
"gold": gold.New,
stock.CollectionName: stock.New,
"stats": stats.New,
"util": util.New,
"misc": misc.New,
"amlich": amlich.New,
"monkeyd": monkeyd.New,
"wordle": wordle.New,
"loldle": loldle.New,
lol.CollectionName: lol.New,
coin.CollectionName: coin.New,
"gold": gold.New,
stock.CollectionName: stock.New,
"stats": stats.New,
sticker.CollectionName: sticker.New,
"alias": alias.New,
}
}
+156
View File
@@ -0,0 +1,156 @@
# Aliases
The `alias` module lets anyone give a short name to a message and send it back
later by that name.
| Command | Parameters | Reply to | What it does |
|---|---|---|---|
| `/alias` | `<name>` | any supported message | Saves it under that name |
| `/insert` | `<name>` | — | Sends back whatever is saved under it |
| `/aliases` | — | — | Lists every saved name |
| `/unalias` | `<name>` | — | Deletes a saved name |
| `/<name>` | — | — | Same as `/insert <name>` |
| `@botname <prefix>` | — | — | Inline picker, in any chat |
All are public and single-shot.
## The namespace is global
A name assigned in any chat works in **every** chat, for **everyone** — the same
way the sticker pack `/addsticker` writes to is shared. The store is a plain map
from name to content, with no chat or user in the key.
The consequence is worth stating plainly: anyone can reassign anyone's name.
`/alias` overwrites rather than refusing, and its reply says what it replaced —
> Replaced /insert cheer — it was a sticker, now it is a GIF.
Overwriting is deliberate: refusing would make a mistyped alias awkward to
correct. `/unalias <name>` deletes one, and is open to anyone for the same
reason overwriting is — the namespace is shared, so the permission model is too.
A per-owner restriction would leave an alias whose assigner has left the chat
permanently unremovable.
## Invoking an alias
Three ways, same binding:
1. **`/<name>`** — a saved name works as its own command. `/cheer` is `/insert cheer`.
2. **`/insert <name>`** — always works, including when inline mode is off.
3. **`@botname <prefix>`** — an inline picker with previews, usable in any chat,
even ones the bot is not a member of.
**Code always beats an alias.** The dispatcher registers every real command
before the alias fallback, and the bot library returns the *first* handler whose
matcher accepts an update — so a command defined in code can never be shadowed
by a name resolved at runtime. `/alias` refuses a name that is already a
command for the same reason, since such an alias would only ever be reachable
through `/insert`.
If a future build adds a command whose name an alias already uses, the command
silently wins and the alias stays reachable via `/insert`. That is the intended
precedence, not a bug to fix.
**An unknown `/command` is answered with silence.** The fallback sees every
unrecognised command in every chat the bot is in, so replying would turn a typo
like `/pign` into noise, and would confirm to anyone probing which names exist.
## Inline mode
`@botname` with no query lists everything; with a query it filters by name
prefix, case-insensitively, sorted, capped at Telegram's 50 results per answer.
Each result is a **cached** inline type — it carries the `file_id` Telegram
already holds, so nothing is uploaded and the picker shows real previews. This
is the payoff for storing a `file_id` rather than bytes.
**Video-note aliases do not appear inline.** Telegram defines no
`InlineQueryResultCachedVideoNote`, and substituting a plain video would change
what was saved. They stay reachable through `/insert` and `/<name>`.
**Inline mode must be enabled in BotFather** (`/setinline`) or Telegram never
delivers these updates and the handler is never called. This is a one-time
operational step that cannot be done from code.
## Names
One word, username-shaped: starts with a letter, then letters, digits and
underscores, up to 32 characters. A leading `@` is stripped rather than
rejected, since these names imitate usernames and typing the sigil is a natural
slip.
Lookups fold case — `/insert LOUD` and `/insert loud` find the same entry — and
the spelling the assigner used is what gets echoed back.
Telegram's own username minimum is 5 characters; this allows 1 on purpose. The
point of an alias is to be shorter than what it replaces, and `gg` is a good
name for a sticker.
## What can be saved
| Replied message | Sent back with |
|---|---|
| Sticker | `sendSticker` |
| Photo | `sendPhoto` |
| GIF / animation | `sendAnimation` |
| Video | `sendVideo` |
| Video note | `sendVideoNote` |
| Audio | `sendAudio` |
| Voice message | `sendVoice` |
| File / document | `sendDocument` |
| Plain text | `sendMessage` |
Anything else — a location, a poll, a contact — is refused with the list above.
**Nothing is downloaded.** Every media kind is kept as the `file_id` Telegram
already issued, and `/insert` hands that same id straight back to a send call.
The module stores bytes for nothing but the name and a caption. A `file_id`
refers to a file on Telegram's servers, so an alias survives restarts and
redeploys.
The kind is stored alongside the id because a bare `file_id` does not say which
send method will accept it.
**Order matters when Telegram fills more than one field.** A GIF arrives as an
`Animation` *and* a `Document`, and the more specific kind is claimed first —
otherwise `/insert` would hand back a plain file instead of a looping GIF.
**Captions come back too**, for the kinds that can carry one. Stickers and video
notes cannot, and Telegram's send methods for them have no caption field at all.
## Listing
`/aliases` prints the count and every name, sorted, in one message.
**Names only, not what each holds.** The store answers "which keys exist" in a
single call, while naming each kind would cost one read per alias — a round trip
each against MongoDB, on a dispatcher that serves one update at a time. To find
out what a name holds, `/insert` it.
Names list in their folded (lowercase) form, which is exactly what `/insert`
takes, and each is wrapped in a `<code>` span so tapping one copies just that
name.
The list is trimmed to fit Telegram's 4096-character message limit and ends
with `…and N more.` when it does not fit; the count at the top is always the
true total. The trim budget counts the markup, not only the names — at 13 bytes
a pair the tags outweigh a short name.
## Behaviour worth knowing
**Text loses its formatting.** Bold, links and mentions are stored as plain
text: re-sending entities means carrying offsets that no longer line up once the
text is repeated in a different message. A bare URL still auto-links.
**A `file_id` can stop working** — the original file was deleted, or Telegram
rejects it. `/insert` answers with something actionable rather than a generic
failure:
> "gone" can no longer be sent. Save it again with /alias gone.
**Replies keep their forum topic.** Every send forwards `MessageThreadID`, for
the reason `chathelper.Reply` documents: without it Telegram routes the message
to a supergroup's General topic instead of the topic the command was typed in.
Both handlers run under a 10-second deadline. The bot processes updates one at a
time, so that bound is what keeps a slow store from stalling other users.
+2 -2
View File
@@ -1,8 +1,8 @@
# Sticker pack
`/addsticker` appends a sticker to **one shared pack** that every user of the
bot contributes to. It lives in the `util` module — there is no `sticker`
module any more, and no per-user packs.
bot contributes to. It is the whole of the `sticker` module — one command, no
storage, and no per-user packs.
| Command | Parameters | Reply to | What it does |
|---|---|---|---|
+94
View File
@@ -0,0 +1,94 @@
// Package alias implements /alias and /insert: a shared, bot-wide dictionary
// mapping a short name to any Telegram message the bot has seen.
//
// The namespace is global on purpose — a name assigned in any chat works in
// every chat, for everyone, the same way the sticker pack /addsticker writes to
// is shared. That makes the store a plain map from name to content, with no
// chat or user component in the key.
//
// Nothing is downloaded. Every media kind is kept as the file_id Telegram
// already issued, and /insert hands that same id straight back to a send call,
// so the module stores bytes for nothing but the name and a caption.
package alias
import (
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
)
// Alias is one name-to-content binding.
//
// FileID is bot-scoped: Telegram file IDs are only valid for the bot that was
// shown them, which is no constraint here because the same bot always sends
// them back. It survives restarts and re-deploys — the id refers to a file on
// Telegram's servers, not to anything this bot holds.
type Alias struct {
Name string `bson:"name"` // as typed at assignment, for echoing back
Kind string `bson:"kind"` // one of the kind* constants
FileID string `bson:"fileId"` // the media; empty for kindText
Text string `bson:"text"` // message text for kindText, else the caption
OwnerID int64 `bson:"ownerId"` // who assigned it last
CreatedAt int64 `bson:"createdAt"` // unix millis
}
// Store is the module's typed view over its collection.
type Store = storage.DocStore[Alias]
// state holds what the handlers share.
type state struct {
store Store
// reg resolves whether a name is already a real command. Captured rather
// than snapshotted: at factory time the registry holds only the modules
// ahead of this one in MODULES order, and by the time a handler runs it is
// complete.
reg *modules.Registry
}
// New is the module Factory.
func New(deps modules.Deps) modules.Module {
s := &state{store: storage.Typed[Alias](deps.Store), reg: deps.Registry}
return modules.Module{
// Makes a saved name invocable directly — /cheer rather than
// /insert cheer. Registered after every command by the dispatcher, so
// it can never shadow one.
Fallback: &modules.CommandFallback{
Visibility: modules.VisibilityPublic,
Handler: s.handleFallback,
},
// "@botname <prefix>" in any chat, with previews. Requires inline mode
// enabled in BotFather; see docs/aliases.md.
Inline: &modules.InlineQuery{
Visibility: modules.VisibilityPublic,
Handler: s.handleInline,
},
Commands: []modules.Command{
{
Name: "alias",
Visibility: modules.VisibilityPublic,
Description: "Reply to a message to save it under a name",
Parameters: "<name>",
Handler: s.handleAlias,
},
{
Name: "insert",
Visibility: modules.VisibilityPublic,
Description: "Send back whatever is saved under a name",
Parameters: "<name>",
Handler: s.handleInsert,
},
{
Name: "aliases",
Visibility: modules.VisibilityPublic,
Description: "List every saved alias name",
Handler: s.handleAliases,
},
{
Name: "unalias",
Visibility: modules.VisibilityPublic,
Description: "Delete a saved alias",
Parameters: "<name>",
Handler: s.handleUnalias,
},
},
}
}
+128
View File
@@ -0,0 +1,128 @@
package alias
import (
"context"
"sort"
"strings"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/log"
)
const (
// maxInlineResults is Telegram's cap on results per answerInlineQuery.
maxInlineResults = 50
// inlineCacheSeconds is how long Telegram may reuse an answer for the same
// query. Kept short because the namespace is shared and writable: a name
// saved now should show up in the picker within seconds, not minutes.
inlineCacheSeconds = 5
)
// handleInline answers "@botname <prefix>" with the matching aliases.
//
// This is the reason the module stores a file_id rather than bytes: every
// result below is a "Cached" inline type, which takes an id Telegram already
// holds. Nothing is uploaded, and the picker renders real previews.
func (s *state) handleInline(ctx context.Context, b *bot.Bot, update *models.Update) error {
query := update.InlineQuery
if query == nil {
return nil
}
names, err := s.store.List(ctx, "")
if err != nil {
log.Error("alias_inline_list", "err", err)
// Answer with nothing rather than leaving the client spinning. An empty
// answer is also what Telegram expects when a query has no matches.
return s.answer(ctx, b, query.ID, nil)
}
prefix := strings.ToLower(strings.TrimSpace(query.Query))
matches := make([]string, 0, len(names))
for _, name := range names {
if prefix == "" || strings.HasPrefix(name, prefix) {
matches = append(matches, name)
}
}
sort.Strings(matches)
if len(matches) > maxInlineResults {
matches = matches[:maxInlineResults]
}
results := make([]models.InlineQueryResult, 0, len(matches))
for _, name := range matches {
entry, found, err := s.get(ctx, name)
if err != nil {
// One unreadable record must not blank the whole picker.
log.Error("alias_inline_get", "name", name, "err", err)
continue
}
if !found {
continue // deleted between the List and this read
}
if r := inlineResult(name, entry); r != nil {
results = append(results, r)
}
}
return s.answer(ctx, b, query.ID, results)
}
func (s *state) answer(ctx context.Context, b *bot.Bot, queryID string, results []models.InlineQueryResult) error {
_, err := b.AnswerInlineQuery(ctx, &bot.AnswerInlineQueryParams{
InlineQueryID: queryID,
Results: results,
CacheTime: inlineCacheSeconds,
})
return err
}
// inlineResult maps one alias to the inline result type that carries it, or nil
// when the kind has no cached inline form.
//
// Telegram defines no InlineQueryResultCachedVideoNote, so a video-note alias
// simply does not appear in the picker — it stays reachable through /insert and
// its own /name. Returning nil rather than substituting another type is
// deliberate: sending a round video note as a plain video would change what the
// user saved.
func inlineResult(name string, a Alias) models.InlineQueryResult {
switch a.Kind {
case kindSticker:
// No Title field on this type — Telegram shows the sticker itself.
return &models.InlineQueryResultCachedSticker{ID: name, StickerFileID: a.FileID}
case kindPhoto:
return &models.InlineQueryResultCachedPhoto{
ID: name, PhotoFileID: a.FileID, Title: name, Caption: a.Text,
}
case kindAnimation:
return &models.InlineQueryResultCachedGif{
ID: name, GifFileID: a.FileID, Title: name, Caption: a.Text,
}
case kindVideo:
return &models.InlineQueryResultCachedVideo{
ID: name, VideoFileID: a.FileID, Title: name, Caption: a.Text,
}
case kindAudio:
return &models.InlineQueryResultCachedAudio{
ID: name, AudioFileID: a.FileID, Caption: a.Text,
}
case kindVoice:
return &models.InlineQueryResultCachedVoice{
ID: name, VoiceFileID: a.FileID, Title: name, Caption: a.Text,
}
case kindDocument:
return &models.InlineQueryResultCachedDocument{
ID: name, DocumentFileID: a.FileID, Title: name, Caption: a.Text,
}
case kindText:
return &models.InlineQueryResultArticle{
ID: name,
Title: name,
Description: a.Text,
InputMessageContent: &models.InputTextMessageContent{MessageText: a.Text},
}
}
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package alias_test
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// inlineQuery builds an inline-mode update: "@botname <query>" typed in any chat.
func inlineQuery(userID int64, query string) *models.Update {
return &models.Update{
ID: 1,
InlineQuery: &models.InlineQuery{
ID: "q1",
From: &models.User{ID: userID, FirstName: "Test"},
Query: query,
},
}
}
// inlineResults decodes the results the bot answered with. answerInlineQuery
// sends them as a JSON array in the "results" form field.
func inlineResults(t *testing.T, rb *testutil.RecordingBot) []map[string]any {
t.Helper()
call, ok := callTo(rb, "answerInlineQuery")
if !ok {
t.Fatalf("no answerInlineQuery call; got %+v", rb.Sent())
}
var out []map[string]any
if err := json.Unmarshal([]byte(call.Form["results"]), &out); err != nil {
t.Fatalf("decode results %q: %v", call.Form["results"], err)
}
return out
}
// Each kind must be offered as the cached inline type that carries it — the
// reason a file_id is stored rather than bytes.
func TestInline_OffersEachKindAsItsCachedType(t *testing.T) {
cases := []struct {
alias string
replied *models.Message
wantType string
idField string
}{
{"pic", &models.Message{Photo: []models.PhotoSize{{FileID: "photo-id", FileSize: 9}}}, "photo", "photo_file_id"},
{"stick", &models.Message{Sticker: &models.Sticker{FileID: "sticker-id"}}, "sticker", "sticker_file_id"},
{"movie", &models.Message{Video: &models.Video{FileID: "video-id"}}, "video", "video_file_id"},
{"loop", &models.Message{Animation: &models.Animation{FileID: "anim-id"}}, "gif", "gif_file_id"},
{"song", &models.Message{Audio: &models.Audio{FileID: "audio-id"}}, "audio", "audio_file_id"},
{"note", &models.Message{Voice: &models.Voice{FileID: "voice-id"}}, "voice", "voice_file_id"},
{"paper", &models.Message{Document: &models.Document{FileID: "doc-id"}}, "document", "document_file_id"},
{"words", &models.Message{Text: "hello"}, "article", ""},
}
for _, tc := range cases {
t.Run(tc.alias, func(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), aliasCmd(tc.alias, tc.replied))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, tc.alias))
results := inlineResults(t, rb)
if len(results) != 1 {
t.Fatalf("got %d results, want 1: %+v", len(results), results)
}
if got := results[0]["type"]; got != tc.wantType {
t.Errorf("type = %v, want %q", got, tc.wantType)
}
if got := results[0]["id"]; got != tc.alias {
t.Errorf("id = %v, want the alias name %q", got, tc.alias)
}
if tc.idField != "" {
if got, ok := results[0][tc.idField].(string); !ok || !strings.HasSuffix(got, "-id") {
t.Errorf("%s = %v, want the saved file_id", tc.idField, results[0][tc.idField])
}
}
})
}
}
// Telegram defines no cached inline type for a video note, so it must be left
// out rather than downgraded to a plain video — that would change what the
// user saved.
func TestInline_SkipsVideoNotes(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("round", &models.Message{VideoNote: &models.VideoNote{FileID: "note-id"}}))
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("flat", &models.Message{Text: "text"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, ""))
results := inlineResults(t, rb)
if len(results) != 1 {
t.Fatalf("got %d results, want only the non-video-note one: %+v", len(results), results)
}
if got := results[0]["id"]; got != "flat" {
t.Errorf("id = %v, want the text alias", got)
}
}
// An empty query lists everything; a non-empty one filters by prefix.
func TestInline_FiltersByPrefix(t *testing.T) {
rb := installAlias(t)
for _, name := range []string{"cheer", "cheese", "boo"} {
rb.Bot.ProcessUpdate(context.Background(), aliasCmd(name, &models.Message{Text: name}))
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, ""))
if got := len(inlineResults(t, rb)); got != 3 {
t.Errorf("empty query returned %d results, want all 3", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, "che"))
results := inlineResults(t, rb)
if len(results) != 2 {
t.Fatalf("prefix query returned %d results, want 2: %+v", len(results), results)
}
// Sorted, so the order is stable between identical queries.
if results[0]["id"] != "cheer" || results[1]["id"] != "cheese" {
t.Errorf("results = %v, %v; want cheer then cheese", results[0]["id"], results[1]["id"])
}
}
// The prefix is folded the same way names are, so typing uppercase still finds
// the alias.
func TestInline_PrefixIsCaseInsensitive(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("cheer", &models.Message{Text: "yay"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, "CHE"))
if got := len(inlineResults(t, rb)); got != 1 {
t.Errorf("got %d results for an uppercase prefix, want 1", got)
}
}
// A query matching nothing must still be answered, or the caller's client
// spins on an unanswered inline query.
func TestInline_NoMatchesStillAnswers(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, "nothing"))
call, ok := callTo(rb, "answerInlineQuery")
if !ok {
t.Fatalf("no answerInlineQuery call; got %+v", rb.Sent())
}
if got := call.Form["inline_query_id"]; got != "q1" {
t.Errorf("inline_query_id = %q, want the query's id", got)
}
}
// Telegram caps answerInlineQuery at 50 results.
func TestInline_CapsAtFiftyResults(t *testing.T) {
rb := installAlias(t)
for i := 0; i < 60; i++ {
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd(uniqueName(i), &models.Message{Text: "x"}))
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), inlineQuery(7, ""))
// Exactly the cap, not merely "at most": 60 were saved, so a smaller
// number would mean the listing quietly lost entries.
if got := len(inlineResults(t, rb)); got != 50 {
t.Errorf("returned %d results, want exactly Telegram's 50 cap", got)
}
}
// uniqueName builds a valid, distinct alias name for index i.
func uniqueName(i int) string {
return "n" + strings.Repeat("x", i/10) + string(rune('a'+i%10))
}
+131
View File
@@ -0,0 +1,131 @@
package alias
import (
"context"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
// The kinds an alias can hold. Each maps to exactly one Telegram send method,
// which is the whole reason the kind is stored rather than inferred later: a
// bare file_id does not say which send call will accept it.
const (
kindSticker = "sticker"
kindPhoto = "photo"
kindAnimation = "animation"
kindVideo = "video"
kindVideoNote = "video_note"
kindAudio = "audio"
kindVoice = "voice"
kindDocument = "document"
kindText = "text"
)
// unsupportedRefusal lists what can be saved, so a refusal teaches rather than
// only denies.
const unsupportedRefusal = "That message cannot be saved. Reply to a sticker, photo, GIF, video, video note, audio, voice message, file, or plain text."
// capture reduces a replied message to a storable alias.
//
// Order matters where Telegram populates more than one field: a GIF arrives as
// an Animation *and* a Document, and a video note as a VideoNote, so the more
// specific kind is claimed first or the alias would come back as a plain file.
func capture(replied *models.Message) (Alias, bool) {
switch {
case replied == nil:
return Alias{}, false
case replied.Sticker != nil:
return Alias{Kind: kindSticker, FileID: replied.Sticker.FileID}, true
case replied.Animation != nil:
return Alias{Kind: kindAnimation, FileID: replied.Animation.FileID, Text: replied.Caption}, true
case replied.VideoNote != nil:
return Alias{Kind: kindVideoNote, FileID: replied.VideoNote.FileID}, true
case replied.Video != nil:
return Alias{Kind: kindVideo, FileID: replied.Video.FileID, Text: replied.Caption}, true
case replied.Voice != nil:
return Alias{Kind: kindVoice, FileID: replied.Voice.FileID, Text: replied.Caption}, true
case replied.Audio != nil:
return Alias{Kind: kindAudio, FileID: replied.Audio.FileID, Text: replied.Caption}, true
case len(replied.Photo) > 0:
return Alias{Kind: kindPhoto, FileID: largestPhoto(replied.Photo), Text: replied.Caption}, true
case replied.Document != nil:
return Alias{Kind: kindDocument, FileID: replied.Document.FileID, Text: replied.Caption}, true
case replied.Text != "":
// Stored as plain text: entities (bold, links, mentions) are dropped,
// because re-sending them means carrying offsets that no longer line up
// once the text is repeated in a different message.
return Alias{Kind: kindText, Text: replied.Text}, true
}
return Alias{}, false
}
// largestPhoto picks the best size by file size rather than trusting the
// array's order.
func largestPhoto(sizes []models.PhotoSize) string {
best := sizes[0]
for _, size := range sizes[1:] {
if size.FileSize > best.FileSize {
best = size
}
}
return best.FileID
}
// send posts a stored alias into the chat msg came from.
//
// MessageThreadID is forwarded on every call for the reason chathelper.Reply
// documents: without it Telegram routes the message to a forum supergroup's
// General topic instead of the topic the command was typed in.
func send(ctx context.Context, b *bot.Bot, msg *models.Message, a Alias) error {
chatID := msg.Chat.ID
thread := msg.MessageThreadID
file := &models.InputFileString{Data: a.FileID}
var err error
switch a.Kind {
case kindSticker:
// SendStickerParams carries no Caption field — a sticker cannot have one.
_, err = b.SendSticker(ctx, &bot.SendStickerParams{
ChatID: chatID, MessageThreadID: thread, Sticker: file,
})
case kindPhoto:
_, err = b.SendPhoto(ctx, &bot.SendPhotoParams{
ChatID: chatID, MessageThreadID: thread, Photo: file, Caption: a.Text,
})
case kindAnimation:
_, err = b.SendAnimation(ctx, &bot.SendAnimationParams{
ChatID: chatID, MessageThreadID: thread, Animation: file, Caption: a.Text,
})
case kindVideo:
_, err = b.SendVideo(ctx, &bot.SendVideoParams{
ChatID: chatID, MessageThreadID: thread, Video: file, Caption: a.Text,
})
case kindVideoNote:
// Nor does a video note: Telegram renders it as a bare round clip.
_, err = b.SendVideoNote(ctx, &bot.SendVideoNoteParams{
ChatID: chatID, MessageThreadID: thread, VideoNote: file,
})
case kindAudio:
_, err = b.SendAudio(ctx, &bot.SendAudioParams{
ChatID: chatID, MessageThreadID: thread, Audio: file, Caption: a.Text,
})
case kindVoice:
_, err = b.SendVoice(ctx, &bot.SendVoiceParams{
ChatID: chatID, MessageThreadID: thread, Voice: file, Caption: a.Text,
})
case kindDocument:
_, err = b.SendDocument(ctx, &bot.SendDocumentParams{
ChatID: chatID, MessageThreadID: thread, Document: file, Caption: a.Text,
})
case kindText:
_, err = b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: chatID, MessageThreadID: thread, Text: a.Text,
})
default:
// A kind written by a newer version of this module, or a corrupted
// record. Neither is the caller's fault and neither is retryable.
return errUnknownKind
}
return err
}
+170
View File
@@ -0,0 +1,170 @@
package alias_test
import (
"context"
"testing"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/alias"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// The headline of path B: a saved name becomes its own command.
func TestFallback_SavedNameWorksAsItsOwnCommand(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("cheer", &models.Message{Sticker: &models.Sticker{FileID: "sticker-id"}}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/cheer"))
call, ok := callTo(rb, "sendSticker")
if !ok {
t.Fatalf("no sendSticker call; /cheer did not resolve: %+v", rb.Sent())
}
if got := call.Form["sticker"]; got != "sticker-id" {
t.Errorf("sticker = %q, want the saved file_id", got)
}
}
// Groups send /cmd@botname, and the fallback must normalise that the same way
// the command matcher does.
func TestFallback_ToleratesAtBotnameSuffix(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("cheer", &models.Message{Text: "yay"}))
rb.Reset()
// The fixture builder stops the entity at '@', but real Telegram includes
// the whole "/cheer@miti99bot" in it — which is the case the stripping
// exists for, so the entity is widened here to match the wire format.
upd := testutil.NewPrivateMessage(7, "/cheer@miti99bot")
upd.Message.Entities[0].Length = len(upd.Message.Text)
rb.Bot.ProcessUpdate(context.Background(), upd)
if got := rb.LastSent().Text(); got != "yay" {
t.Errorf("reply = %q, want the alias to resolve despite the @suffix", got)
}
}
// Mobile keyboards autocapitalise; /Cheer must reach the same alias.
func TestFallback_IsCaseInsensitive(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("cheer", &models.Message{Text: "yay"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/CHEER"))
if got := rb.LastSent().Text(); got != "yay" {
t.Errorf("reply = %q, want case-folded resolution", got)
}
}
// Silence on a miss is deliberate: the fallback sees every unrecognised command
// in every chat, so replying would turn typos into noise and would confirm
// which names are taken.
func TestFallback_UnknownNameIsSilent(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/pign"))
if calls := rb.Sent(); len(calls) != 0 {
t.Errorf("unknown command produced output: %+v", calls)
}
}
// The rule the user asked for: behaviour in code wins over an alias, always.
// Registered commands are installed before the fallback, and the bot library
// returns the first matching handler.
func TestFallback_NeverShadowsARegisteredCommand(t *testing.T) {
var realRan bool
realModule := func(_ modules.Deps) modules.Module {
return modules.Module{Commands: []modules.Command{{
Name: "cheer",
Visibility: modules.VisibilityPublic,
Description: "the real thing",
Handler: func(_ context.Context, _ *bot.Bot, _ *models.Update) error {
realRan = true
return nil
},
}}}
}
rb := testutil.NewRecordingBot(t)
reg, err := modules.Build([]string{"alias", "real"},
map[string]modules.Factory{"alias": alias.New, "real": realModule},
storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build: %v", err)
}
modules.Install(rb.Bot, reg, modules.Auth{})
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/cheer"))
if !realRan {
t.Error("the registered /cheer handler did not run")
}
}
// Refusing at assignment time is the companion to dispatch order: an alias
// named after a real command would only ever be reachable via /insert.
func TestAlias_RefusesNameOfARegisteredCommand(t *testing.T) {
rb := installAlias(t)
// /aliases is one of this module's own commands.
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("aliases", &models.Message{Text: "hijack"}))
rb.AssertSentText(t, "already a command of mine")
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert aliases"))
rb.AssertSentText(t, "Nothing is saved")
}
func TestUnalias_DeletesAndThenNameIsFree(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("temp", &models.Message{Text: "content"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/unalias temp"))
rb.AssertSentText(t, `Deleted "temp"`)
// Gone from /insert, from the list, and from the bare-command path.
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert temp"))
rb.AssertSentText(t, "Nothing is saved")
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/aliases"))
rb.AssertSentText(t, "No aliases saved yet")
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/temp"))
if calls := rb.Sent(); len(calls) != 0 {
t.Errorf("deleted alias still answered as a command: %+v", calls)
}
}
func TestUnalias_UnknownNameIsReported(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/unalias ghost"))
rb.AssertSentText(t, "Nothing is saved")
}
// Deleting is open to anyone, matching the overwrite rule — the namespace is
// shared, so the permission model is too.
func TestUnalias_AnyoneMayDelete(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("shared", &models.Message{Text: "content"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(99, "/unalias shared"))
rb.AssertSentText(t, "Deleted")
}
+336
View File
@@ -0,0 +1,336 @@
package alias
import (
"context"
"errors"
"fmt"
"html"
"regexp"
"sort"
"strings"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
"github.com/tiennm99/miti99bot/internal/storage"
)
const (
// handlerTimeout bounds both handlers. The bot dispatches updates inline on
// a single worker with no deadline of its own, so without this the
// library's 60s per-call HTTP ceiling is the only bound.
handlerTimeout = 10 * time.Second
// maxNameLen matches Telegram's own username cap, which is the format these
// names imitate.
maxNameLen = 32
// maxListBytes keeps /aliases inside Telegram's 4096-character sendMessage
// limit, with room to spare for the trimming notice and multi-byte names.
//
// The budget counts the <code> markup, not only the names: Telegram measures
// the message it is sent, and at 13 bytes a pair the tags outweigh a short
// name.
maxListBytes = 3800
)
// nameRe is the username shape: starts with a letter, then letters, digits or
// underscores.
//
// Telegram's own minimum is 5 characters; this deliberately allows 1, because
// the point of an alias is to be shorter than what it replaces and "gg" is a
// perfectly good name for a sticker.
var nameRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`)
const usageAlias = "Reply to a message with /alias <name> to save it. The name is one word: letters, digits and underscores, starting with a letter."
const genericFailure = "Something went wrong. Try again in a moment."
// errUnknownKind marks a stored record this build cannot send.
var errUnknownKind = errors.New("alias: unknown kind")
// parseName validates the single argument both commands take.
//
// A leading "@" is stripped rather than rejected: the names are username-shaped
// and typing one with the sigil is a natural mistake, not a different request.
func parseName(raw string) (display, key string, err error) {
fields := strings.Fields(raw)
if len(fields) == 0 {
return "", "", errors.New("empty")
}
if len(fields) > 1 {
// Spaces are what separate a name from a sentence, so this is the check
// that keeps "/alias my funny sticker" from silently saving "my".
return "", "", errors.New("not one word")
}
name := strings.TrimPrefix(fields[0], "@")
if name == "" || len(name) > maxNameLen || !nameRe.MatchString(name) {
return "", "", errors.New("bad shape")
}
// Lookups are case-insensitive, so the key is folded while the display form
// keeps whatever the assigner typed.
return name, strings.ToLower(name), nil
}
// handleAlias saves the replied message under a name.
func (s *state) handleAlias(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := context.WithTimeout(ctx, handlerTimeout)
defer cancel()
msg := update.Message
if msg == nil {
return nil
}
display, key, err := parseName(chathelper.ArgAfterCommand(msg.Text))
if err != nil {
return chathelper.Reply(ctx, b, msg, usageAlias)
}
if msg.ReplyToMessage == nil {
return chathelper.Reply(ctx, b, msg, usageAlias)
}
// A real command always wins at dispatch, so an alias sharing its name
// would be unreachable as /name and only work through /insert — a trap
// worth refusing up front rather than explaining later. Checked against the
// live registry, so it covers every module loaded in this deploy.
if s.reg != nil {
if _, taken := s.reg.AllCommands[key]; taken {
return chathelper.Reply(ctx, b, msg, fmt.Sprintf(
"/%s is already a command of mine. Pick another name.", key))
}
}
entry, ok := capture(msg.ReplyToMessage)
if !ok {
return chathelper.Reply(ctx, b, msg, unsupportedRefusal)
}
entry.Name = display
entry.CreatedAt = chathelper.NowMillis()
if msg.From != nil {
entry.OwnerID = msg.From.ID
}
// Read before writing purely to word the reply. The write is unconditional
// either way — last assignment wins — so a race here costs a wrong noun in
// one sentence, not a wrong binding.
previous, existed, err := s.get(ctx, key)
if err != nil {
log.Error("alias_save_lookup", "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
}
if err := s.store.Put(ctx, key, entry); err != nil {
log.Error("alias_save", "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
}
if existed {
return chathelper.Reply(ctx, b, msg, fmt.Sprintf(
"Replaced /insert %s — it was a %s, now it is a %s.",
display, describe(previous.Kind), describe(entry.Kind)))
}
return chathelper.Reply(ctx, b, msg, fmt.Sprintf(
"Saved. Use /insert %s to send that %s.", display, describe(entry.Kind)))
}
// handleInsert sends back whatever is stored under a name.
func (s *state) handleInsert(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := context.WithTimeout(ctx, handlerTimeout)
defer cancel()
msg := update.Message
if msg == nil {
return nil
}
display, key, err := parseName(chathelper.ArgAfterCommand(msg.Text))
if err != nil {
return chathelper.Reply(ctx, b, msg, "Usage: /insert <name>")
}
entry, found, err := s.get(ctx, key)
if err != nil {
log.Error("alias_insert_lookup", "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
}
if !found {
return chathelper.Reply(ctx, b, msg, fmt.Sprintf(
"Nothing is saved as %q. Reply to a message with /alias %s to save one.", display, display))
}
if err := send(ctx, b, msg, entry); err != nil {
// A file_id can stop working — the sender deleted the file, or the
// record predates a kind this build understands. Neither is worth an
// opaque failure, and neither is retryable by the caller.
log.Error("alias_insert_send", "name", key, "kind", entry.Kind, "err", err)
return chathelper.Reply(ctx, b, msg, fmt.Sprintf(
"%q can no longer be sent. Save it again with /alias %s.", display, display))
}
return nil
}
// handleUnalias deletes a saved name.
//
// Anyone may delete anyone's alias, which is the same rule /alias already
// follows by overwriting: the namespace is shared, so the permission model is
// too. A per-owner restriction would leave an alias whose assigner has left the
// chat permanently unremovable.
func (s *state) handleUnalias(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := context.WithTimeout(ctx, handlerTimeout)
defer cancel()
msg := update.Message
if msg == nil {
return nil
}
display, key, err := parseName(chathelper.ArgAfterCommand(msg.Text))
if err != nil {
return chathelper.Reply(ctx, b, msg, "Usage: /unalias <name>")
}
// Read first so a missing name is reported as such. Delete on a missing key
// is not distinguishable from a successful one in the store contract, and
// "deleted" for something that never existed reads as a bug.
if _, found, err := s.get(ctx, key); err != nil {
log.Error("alias_unalias_lookup", "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
} else if !found {
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Nothing is saved as %q.", display))
}
if err := s.store.Delete(ctx, key); err != nil {
log.Error("alias_unalias", "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
}
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Deleted %q.", display))
}
// handleFallback answers a /command nobody registered by treating it as an
// alias name — the whole point of the feature: /cheer instead of /insert cheer.
//
// Silent on a miss, deliberately. This sees every unrecognised command in every
// chat the bot is in, so replying would turn a typo like /pign into noise, and
// would confirm to anyone probing which names are taken.
func (s *state) handleFallback(ctx context.Context, b *bot.Bot, name string, update *models.Update) error {
ctx, cancel := context.WithTimeout(ctx, handlerTimeout)
defer cancel()
msg := update.Message
if msg == nil {
return nil
}
// The name arrives already lowercased by the dispatcher, but it has not been
// through parseName: reject anything that could not have been stored, so a
// malformed command never reaches the store as a key.
if _, key, err := parseName(name); err != nil {
return nil
} else if entry, found, err := s.get(ctx, key); err != nil {
log.Error("alias_fallback_lookup", "name", key, "err", err)
return nil
} else if found {
return send(ctx, b, msg, entry)
}
return nil
}
// handleAliases lists every saved name.
//
// Names only, not what each one holds: the store answers "which keys exist" in
// one call, while naming the kinds would cost one read per alias — a round trip
// each against MongoDB, on a dispatcher that serves one update at a time. The
// cheap way to find out what a name holds is to /insert it.
func (s *state) handleAliases(ctx context.Context, b *bot.Bot, update *models.Update) error {
ctx, cancel := context.WithTimeout(ctx, handlerTimeout)
defer cancel()
msg := update.Message
if msg == nil {
return nil
}
names, err := s.store.List(ctx, "")
if err != nil {
log.Error("alias_list", "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
}
if len(names) == 0 {
return chathelper.Reply(ctx, b, msg,
"No aliases saved yet. Reply to a message with /alias <name> to save one.")
}
// List gives no ordering guarantee, and an unstable list is unreadable when
// it is the same command run twice.
sort.Strings(names)
return chathelper.ReplyHTML(ctx, b, msg, renderNames(names))
}
// renderNames formats the list as Telegram HTML, trimmed to fit one message.
//
// Each name is wrapped in <code> so tapping it copies just that name. The list
// exists to be read *and* reused, and a plain comma-separated run makes the
// reader select text by hand on a phone.
func renderNames(names []string) string {
var sb strings.Builder
fmt.Fprintf(&sb, "%d aliases:\n", len(names))
for i, name := range names {
// Escaped despite parseName already restricting names to [a-zA-Z0-9_]:
// the validation and the rendering are far apart, and a later relaxation
// of the name rules must not silently become an HTML injection.
entry := "<code>" + html.EscapeString(name) + "</code>"
// Reserve room for the "…and N more" tail before committing to a name,
// so the trim can never be what pushes the message over the limit.
if sb.Len()+len(entry)+2 > maxListBytes {
fmt.Fprintf(&sb, "\n…and %d more.", len(names)-i)
return sb.String()
}
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(entry)
}
return sb.String()
}
// get reads an alias. A missing name is not an error — it is the normal state
// for a name nobody has claimed.
func (s *state) get(ctx context.Context, key string) (Alias, bool, error) {
a, _, err := s.store.Get(ctx, key)
if errors.Is(err, storage.ErrNotFound) {
return Alias{}, false, nil
}
if err != nil {
return Alias{}, false, err
}
return a, true, nil
}
// describe names a kind in the words a user would use for it.
func describe(kind string) string {
switch kind {
case kindSticker:
return "sticker"
case kindPhoto:
return "photo"
case kindAnimation:
return "GIF"
case kindVideo:
return "video"
case kindVideoNote:
return "video note"
case kindAudio:
return "audio track"
case kindVoice:
return "voice message"
case kindDocument:
return "file"
case kindText:
return "message"
}
return "message"
}
+357
View File
@@ -0,0 +1,357 @@
package alias_test
import (
"context"
"fmt"
"strings"
"testing"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/alias"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// installAlias builds a registry holding only the alias module. Both commands
// are public, so no auth is needed for them to dispatch.
func installAlias(t *testing.T) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
reg, err := modules.Build([]string{"alias"},
map[string]modules.Factory{"alias": alias.New},
storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build: %v", err)
}
modules.Install(rb.Bot, reg, modules.Auth{})
return rb
}
// aliasCmd builds "/alias <name>" replying to the given message.
func aliasCmd(name string, replied *models.Message) *models.Update {
upd := testutil.NewPrivateMessage(7, "/alias "+name)
upd.Message.ReplyToMessage = replied
return upd
}
func callTo(rb *testutil.RecordingBot, method string) (testutil.SentCall, bool) {
for _, c := range rb.Sent() {
if c.Method == method {
return c, true
}
}
return testutil.SentCall{}, false
}
// Every supported kind must round-trip: saved by file_id, then sent back
// through the one send method that accepts it.
func TestAlias_RoundTripsEveryKind(t *testing.T) {
cases := []struct {
name string
replied *models.Message
method string
field string
fileID string
}{
{
name: "sticker",
replied: &models.Message{Sticker: &models.Sticker{FileID: "sticker-id"}},
method: "sendSticker", field: "sticker", fileID: "sticker-id",
},
{
name: "photo",
replied: &models.Message{Photo: []models.PhotoSize{{FileID: "small", FileSize: 10}, {FileID: "photo-id", FileSize: 900}}},
method: "sendPhoto", field: "photo", fileID: "photo-id",
},
{
name: "animation",
replied: &models.Message{Animation: &models.Animation{FileID: "anim-id"}},
method: "sendAnimation", field: "animation", fileID: "anim-id",
},
{
name: "video",
replied: &models.Message{Video: &models.Video{FileID: "video-id"}},
method: "sendVideo", field: "video", fileID: "video-id",
},
{
name: "video note",
replied: &models.Message{VideoNote: &models.VideoNote{FileID: "note-id"}},
method: "sendVideoNote", field: "video_note", fileID: "note-id",
},
{
name: "audio",
replied: &models.Message{Audio: &models.Audio{FileID: "audio-id"}},
method: "sendAudio", field: "audio", fileID: "audio-id",
},
{
name: "voice",
replied: &models.Message{Voice: &models.Voice{FileID: "voice-id"}},
method: "sendVoice", field: "voice", fileID: "voice-id",
},
{
name: "document",
replied: &models.Message{Document: &models.Document{FileID: "doc-id"}},
method: "sendDocument", field: "document", fileID: "doc-id",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("thing", tc.replied))
rb.AssertSentText(t, "Use /insert thing")
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert thing"))
call, ok := callTo(rb, tc.method)
if !ok {
t.Fatalf("no %s call; got %+v", tc.method, rb.Sent())
}
// The stored file_id is handed straight back, never re-uploaded.
if got := call.Form[tc.field]; got != tc.fileID {
t.Errorf("%s = %q, want the saved file_id %q", tc.field, got, tc.fileID)
}
if _, ok := callTo(rb, "sendDocument"); ok && tc.method != "sendDocument" {
t.Error("media was re-sent as a document")
}
})
}
}
func TestAlias_TextRoundTrips(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("greeting", &models.Message{Text: "xin chào"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert greeting"))
if got := rb.LastSent().Text(); got != "xin chào" {
t.Errorf("insert sent %q, want the saved text", got)
}
}
// A photo's caption is part of what was saved, so it comes back with it.
func TestAlias_KeepsCaption(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("pic", &models.Message{
Photo: []models.PhotoSize{{FileID: "photo-id", FileSize: 100}},
Caption: "a caption",
}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert pic"))
call, ok := callTo(rb, "sendPhoto")
if !ok {
t.Fatalf("no sendPhoto call; got %+v", rb.Sent())
}
if got := call.Form["caption"]; got != "a caption" {
t.Errorf("caption = %q, want it preserved", got)
}
}
// A GIF arrives as an Animation *and* a Document. The specific kind must win,
// or /insert would send it as a plain file.
func TestAlias_AnimationBeatsDocument(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), aliasCmd("gif", &models.Message{
Animation: &models.Animation{FileID: "anim-id"},
Document: &models.Document{FileID: "doc-id"},
}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert gif"))
if _, ok := callTo(rb, "sendAnimation"); !ok {
t.Errorf("want sendAnimation; got %+v", rb.Sent())
}
if _, ok := callTo(rb, "sendDocument"); ok {
t.Error("a GIF was sent as a document")
}
}
// Global namespace, last assignment wins — and the reply says so, since there
// is no /unalias to undo a mistake with.
func TestAlias_OverwriteAnnouncesReplacement(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("dup", &models.Message{Sticker: &models.Sticker{FileID: "first"}}))
rb.Reset()
// A different user, to prove the namespace is shared rather than per-caller.
upd := testutil.NewPrivateMessage(99, "/alias dup")
upd.Message.ReplyToMessage = &models.Message{Text: "second"}
rb.Bot.ProcessUpdate(context.Background(), upd)
got := rb.LastSent().Text()
if !strings.Contains(got, "Replaced") || !strings.Contains(got, "was a sticker") {
t.Errorf("reply = %q, want it to name what it replaced", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert dup"))
if got := rb.LastSent().Text(); got != "second" {
t.Errorf("insert sent %q, want the newer binding", got)
}
}
// Lookups fold case; the stored spelling is what gets echoed back.
func TestAlias_NameIsCaseInsensitive(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("LOUD", &models.Message{Text: "shouted"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert loud"))
if got := rb.LastSent().Text(); got != "shouted" {
t.Errorf("insert sent %q, want the alias found case-insensitively", got)
}
}
// A leading @ is a natural slip for a username-shaped name, not a new request.
func TestAlias_StripsLeadingAt(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("@handle", &models.Message{Text: "saved"}))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert handle"))
if got := rb.LastSent().Text(); got != "saved" {
t.Errorf("insert sent %q, want the @-stripped name to resolve", got)
}
}
func TestAlias_RejectsBadNames(t *testing.T) {
// Spaces are what separate a name from a sentence; the rest are shapes a
// username cannot take.
for _, name := range []string{"", "two words", "9lives", "has-dash", "hasдot.", strings.Repeat("a", 33)} {
t.Run("name="+name, func(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd(name, &models.Message{Text: "content"}))
rb.AssertSentText(t, "The name is one word")
})
}
}
func TestAlias_RejectsUnsupportedMessage(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("place", &models.Message{Location: &models.Location{Latitude: 1, Longitude: 2}}))
rb.AssertSentText(t, "cannot be saved")
}
func TestAlias_NoReplyShowsUsage(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/alias solo"))
rb.AssertSentText(t, "Reply to a message")
}
func TestInsert_UnknownNameExplainsHowToSaveOne(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert nothing"))
rb.AssertSentText(t, "Nothing is saved")
rb.AssertSentText(t, "/alias nothing")
}
// A file_id can stop working — the file was deleted, or Telegram rejects it.
// The caller gets an actionable sentence, not an opaque failure.
func TestInsert_DeadFileIDIsExplained(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("gone", &models.Message{Sticker: &models.Sticker{FileID: "stale"}}))
rb.Reset()
rb.FailMethodCode("sendSticker", 400, "Bad Request: wrong file identifier")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/insert gone"))
rb.AssertSentText(t, "can no longer be sent")
}
// Forum supergroups route by topic: a reply without the thread id lands in
// General instead of where the command was typed.
func TestInsert_KeepsForumTopic(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd("topical", &models.Message{Sticker: &models.Sticker{FileID: "sticker-id"}}))
rb.Reset()
upd := testutil.NewSupergroupMessage(-100, 7, "/insert topical")
upd.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), upd)
call, ok := callTo(rb, "sendSticker")
if !ok {
t.Fatalf("no sendSticker call; got %+v", rb.Sent())
}
if got := call.Form["message_thread_id"]; got != "42" {
t.Errorf("message_thread_id = %q, want the topic the command came from", got)
}
}
func TestAliases_EmptyStoreExplainsHowToSave(t *testing.T) {
rb := installAlias(t)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/aliases"))
rb.AssertSentText(t, "No aliases saved yet")
}
// Names are listed sorted and counted. Order must be stable, or the same
// command run twice reads as a different list.
func TestAliases_ListsSortedNames(t *testing.T) {
rb := installAlias(t)
for _, name := range []string{"zeta", "alpha", "Mid"} {
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd(name, &models.Message{Text: "x"}))
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/aliases"))
call := rb.LastSent()
got := call.Text()
if !strings.HasPrefix(got, "3 aliases:") {
t.Errorf("reply = %q, want it to open with the count", got)
}
// Keys are folded, so "Mid" lists as "mid" — which is what /insert takes.
// Each name is its own <code> span so tapping one copies just that name.
if want := "<code>alpha</code>, <code>mid</code>, <code>zeta</code>"; !strings.Contains(got, want) {
t.Errorf("reply = %q, want sorted copyable names %q", got, want)
}
// The markup is inert without the parse mode.
if pm := call.Form["parse_mode"]; pm != "HTML" {
t.Errorf("parse_mode = %q, want HTML", pm)
}
}
// The reply must fit one Telegram message (4096 chars), and the trimming
// notice must not itself be what overflows it.
func TestAliases_TrimsToOneMessage(t *testing.T) {
rb := installAlias(t)
const total = 400
for i := 0; i < total; i++ {
rb.Bot.ProcessUpdate(context.Background(),
aliasCmd(fmt.Sprintf("name%026d", i), &models.Message{Text: "x"}))
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/aliases"))
got := rb.LastSent().Text()
if len(got) > 4096 {
t.Errorf("reply is %d bytes, past Telegram's 4096 limit", len(got))
}
if !strings.Contains(got, "more.") {
t.Errorf("reply = %q...; want a trimming notice", got[:min(120, len(got))])
}
if !strings.HasPrefix(got, fmt.Sprintf("%d aliases:", total)) {
t.Errorf("reply should still report the true total; got %q", got[:min(60, len(got))])
}
}
+74
View File
@@ -37,6 +37,8 @@ func (a Auth) Permits(v Visibility, update *models.Update) bool {
senderID = update.Message.From.ID
} else if update.CallbackQuery != nil {
senderID = update.CallbackQuery.From.ID
} else if update.InlineQuery != nil {
senderID = update.InlineQuery.From.ID
}
if senderID == 0 {
return false
@@ -119,6 +121,78 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) {
}
})
}
// Registered LAST, and that placement is load-bearing: the bot library
// returns the first handler whose matcher accepts an update, so every
// Command above out-ranks the fallback. A name defined in code therefore
// always beats one resolved at runtime — including an alias that shadows a
// command added in a later build.
if fb := reg.Fallback(); fb != nil {
fbCopy := *fb
b.RegisterHandlerMatchFunc(
func(update *models.Update) bool { return commandName(update) != "" },
func(ctx context.Context, b *bot.Bot, update *models.Update) {
name := commandName(update)
defer recoverHandler("fallback", name, nil)
if !auth.Permits(fbCopy.Visibility, update) {
return // silent — same rule the command path follows
}
// Deliberately not counted in metrics.IncCommand or the command
// hooks: an un-registered name is not a command, and feeding
// arbitrary user text to the stats module would let anyone mint
// unbounded metric labels.
if err := fbCopy.Handler(ctx, b, name, update); err != nil {
metrics.IncError("fallback-handler-error")
log.Error("fallback", "name", name, "err", err)
}
},
)
}
if inline := reg.Inline(); inline != nil {
inlineCopy := *inline
b.RegisterHandlerMatchFunc(
func(update *models.Update) bool { return update != nil && update.InlineQuery != nil },
func(ctx context.Context, b *bot.Bot, update *models.Update) {
defer recoverHandler("inline", "inline_query", nil)
if !auth.Permits(inlineCopy.Visibility, update) {
return
}
if err := inlineCopy.Handler(ctx, b, update); err != nil {
metrics.IncError("inline-handler-error")
log.Error("inline", "err", err)
}
},
)
}
}
// commandName returns the normalised bot_command in update, or "" when the
// update carries none.
//
// Shares matchCommand's parsing rules — leading slash dropped, @botname suffix
// stripped, case folded — so a fallback sees exactly the name a registered
// command would have matched on.
func commandName(update *models.Update) string {
if update == nil || update.Message == nil {
return ""
}
text := update.Message.Text
for _, e := range update.Message.Entities {
if e.Type != models.MessageEntityTypeBotCommand {
continue
}
end := e.Offset + e.Length
if e.Offset < 0 || end > len(text) || e.Length < 1 {
continue
}
tok := text[e.Offset+1 : end]
if i := strings.IndexByte(tok, '@'); i >= 0 {
tok = tok[:i]
}
return strings.ToLower(tok)
}
return ""
}
// recoverHandler contains a panic raised by a module handler. The bot runs with
+28
View File
@@ -74,6 +74,34 @@ type Module struct {
Callbacks []Callback
Crons []Cron
CommandHook func(ctx context.Context, name string, update *models.Update) // optional; called by dispatcher after each authorized command invocation. update carries the originating Telegram update so hooks can attribute usage to a user.
Fallback *CommandFallback // optional; handles a /command no module registered. At most one across all modules.
Inline *InlineQuery // optional; handles inline-mode queries. At most one across all modules.
}
// CommandFallback handles a /command that no module registered.
//
// Install registers it after every Command, and the bot library returns the
// *first* matching handler, so a registered command can never reach here. That
// ordering is the whole mechanism: code always wins over anything resolved at
// runtime, including an alias that shares a command's name.
//
// Name is the parsed command, lowercased with any @botname suffix stripped —
// the same normalisation matchCommand applies — so a fallback never re-parses
// the entity itself.
type CommandFallback struct {
Visibility Visibility
Handler func(ctx context.Context, b *bot.Bot, name string, update *models.Update) error
}
// InlineQuery handles inline-mode queries ("@botname <text>" typed in any chat).
//
// The bot library has no HandlerType for inline queries, so Install matches
// update.InlineQuery itself. Inline mode must also be enabled for the bot in
// BotFather; without that Telegram never delivers these updates and the handler
// is simply never called.
type InlineQuery struct {
Visibility Visibility
Handler func(ctx context.Context, b *bot.Bot, update *models.Update) error
}
// Deps is the dependency bundle a Factory receives.
+78 -17
View File
@@ -39,6 +39,77 @@ type Registry struct {
cronDeps map[string]Deps // cron name → owning module's prefixed Deps
callbacks map[string]Callback
commandHooks []func(ctx context.Context, name string, update *models.Update)
fallback *CommandFallback // at most one; owner tracked in Build
inline *InlineQuery // at most one; owner tracked in Build
}
// Fallback returns the single command fallback, or nil when no module declares
// one.
func (r *Registry) Fallback() *CommandFallback { return r.fallback }
// Inline returns the single inline-query handler, or nil when no module
// declares one.
func (r *Registry) Inline() *InlineQuery { return r.inline }
// addCommands validates and indexes one module's commands.
//
// Mirrors addCallbacks: the per-item validation, the cross-module uniqueness
// check, and the fan-out into the visibility indexes all belong to the item,
// not to Build's module loop.
func (r *Registry) addCommands(name string, cmds []Command, owners map[string]string) error {
for _, cmd := range cmds {
if err := validateCommand(cmd); err != nil {
return fmt.Errorf("module %q: %w", name, err)
}
if prev, dup := owners[cmd.Name]; dup {
return fmt.Errorf("command conflict: /%s defined in %q and %q", cmd.Name, prev, name)
}
owners[cmd.Name] = name
r.AllCommands[cmd.Name] = cmd
switch cmd.Visibility {
case VisibilityPublic:
r.publicCmds[cmd.Name] = cmd
case VisibilityProtected:
r.protected[cmd.Name] = cmd
case VisibilityPrivate:
r.private[cmd.Name] = cmd
}
}
return nil
}
// addSingletons claims the at-most-one Fallback and Inline slots.
//
// Two modules answering the same un-registered command, or the same inline
// query, is a configuration bug worth catching here rather than leaving the
// winner to map iteration order. A declared slot with a nil handler is rejected
// for the same reason: it would panic at dispatch instead of at startup.
//
// Extracted from Build rather than inlined so Build stays under the project's
// cyclomatic cap; the owner strings are threaded through because they are
// Build's loop state, not registry state.
func (r *Registry) addSingletons(name string, mod Module, fallbackOwner, inlineOwner *string) error {
if mod.Fallback != nil {
if *fallbackOwner != "" {
return fmt.Errorf("fallback conflict: defined in %q and %q", *fallbackOwner, name)
}
if mod.Fallback.Handler == nil {
return fmt.Errorf("module %q: fallback has no handler", name)
}
*fallbackOwner = name
r.fallback = mod.Fallback
}
if mod.Inline != nil {
if *inlineOwner != "" {
return fmt.Errorf("inline conflict: defined in %q and %q", *inlineOwner, name)
}
if mod.Inline.Handler == nil {
return fmt.Errorf("module %q: inline has no handler", name)
}
*inlineOwner = name
r.inline = mod.Inline
}
return nil
}
// PublicCommands returns commands tagged VisibilityPublic, sorted by name.
@@ -129,6 +200,7 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov
owners := map[string]string{} // command name → module that registered it
cronOwners := map[string]string{}
callbackOwners := map[string]string{}
var fallbackOwner, inlineOwner string
seenModule := map[string]bool{}
var unknown []string
@@ -165,23 +237,8 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov
reg.commandHooks = append(reg.commandHooks, mod.CommandHook)
}
for _, cmd := range mod.Commands {
if err := validateCommand(cmd); err != nil {
return nil, fmt.Errorf("module %q: %w", name, err)
}
if prev, dup := owners[cmd.Name]; dup {
return nil, fmt.Errorf("command conflict: /%s defined in %q and %q", cmd.Name, prev, name)
}
owners[cmd.Name] = name
reg.AllCommands[cmd.Name] = cmd
switch cmd.Visibility {
case VisibilityPublic:
reg.publicCmds[cmd.Name] = cmd
case VisibilityProtected:
reg.protected[cmd.Name] = cmd
case VisibilityPrivate:
reg.private[cmd.Name] = cmd
}
if err := reg.addCommands(name, mod.Commands, owners); err != nil {
return nil, err
}
for _, cron := range mod.Crons {
@@ -200,6 +257,10 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov
return nil, err
}
if err := reg.addSingletons(name, mod, &fallbackOwner, &inlineOwner); err != nil {
return nil, err
}
reg.Modules = append(reg.Modules, mod)
}
+59
View File
@@ -362,3 +362,62 @@ func TestBuild_PerModuleStoreIsolation(t *testing.T) {
t.Errorf("beta/score = %q, want 2", got)
}
}
// Fallback and inline are single-slot. Two modules claiming either is a
// configuration bug the registry catches, rather than leaving the winner to
// map iteration order.
func TestBuild_DetectsFallbackConflict(t *testing.T) {
withFallback := func(name string) Factory {
return func(_ Deps) Module {
return Module{Name: name, Fallback: &CommandFallback{
Visibility: VisibilityPublic,
Handler: func(_ context.Context, _ *bot.Bot, _ string, _ *models.Update) error {
return nil
},
}}
}
}
factories := map[string]Factory{"alpha": withFallback("alpha"), "beta": withFallback("beta")}
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), BuildOptions{})
if err == nil || !strings.Contains(err.Error(), "fallback conflict") {
t.Errorf("expected fallback conflict, got %v", err)
}
}
func TestBuild_DetectsInlineConflict(t *testing.T) {
withInline := func(name string) Factory {
return func(_ Deps) Module {
return Module{Name: name, Inline: &InlineQuery{
Visibility: VisibilityPublic,
Handler: func(_ context.Context, _ *bot.Bot, _ *models.Update) error {
return nil
},
}}
}
}
factories := map[string]Factory{"alpha": withInline("alpha"), "beta": withInline("beta")}
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), BuildOptions{})
if err == nil || !strings.Contains(err.Error(), "inline conflict") {
t.Errorf("expected inline conflict, got %v", err)
}
}
// A declared slot with no handler would panic at dispatch; reject it at build.
func TestBuild_RejectsHandlerlessFallbackAndInline(t *testing.T) {
cases := map[string]Factory{
"fallback has no handler": func(_ Deps) Module {
return Module{Fallback: &CommandFallback{Visibility: VisibilityPublic}}
},
"inline has no handler": func(_ Deps) Module {
return Module{Inline: &InlineQuery{Visibility: VisibilityPublic}}
},
}
for want, f := range cases {
t.Run(want, func(t *testing.T) {
_, err := Build([]string{"alpha"}, map[string]Factory{"alpha": f}, newProvider(), BuildOptions{})
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("expected %q, got %v", want, err)
}
})
}
}
@@ -1,4 +1,4 @@
package util
package sticker
import (
"bytes"
@@ -1,4 +1,4 @@
package util_test
package sticker_test
import (
"context"
@@ -7,9 +7,27 @@ import (
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/sticker"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// installSticker builds a registry holding only the sticker module. /addsticker
// is public, so no auth is needed for it to dispatch.
func installSticker(t *testing.T) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
reg, err := modules.Build([]string{"sticker"},
map[string]modules.Factory{"sticker": sticker.New},
storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build: %v", err)
}
modules.Install(rb.Bot, reg, modules.Auth{})
return rb
}
// installAddSticker builds the util module with the shared pack configured and
// getMe stubbed.
//
@@ -21,7 +39,7 @@ func installAddSticker(t *testing.T, packName, botUsername string) *testutil.Rec
t.Helper()
t.Setenv("OWNER_ID", "999")
t.Setenv("STICKER_PACK_NAME", packName)
rb := installUtil(t, 999)
rb := installSticker(t)
rb.StubMethod("getMe", `{"id":1,"is_bot":true,"username":"`+botUsername+`"}`)
return rb
}
+27
View File
@@ -0,0 +1,27 @@
// Package sticker implements /addsticker: it appends a replied sticker, image,
// video or GIF to one shared, env-configured Telegram sticker pack.
//
// One command, and no storage at all. AddStickerToSet takes the *set owner's*
// user ID rather than the caller's, so there is nothing per-user to record, key
// or lock — the module holds only the conversion pipeline that turns whatever
// was replied to into something Telegram will accept.
package sticker
import (
"github.com/tiennm99/miti99bot/internal/modules"
)
// CollectionName is this module's registry key. Exported so main can hand the
// same handle to Build and to any startup task, matching lol/coin/stock.
const CollectionName = "sticker"
// New is the module Factory. It takes no Deps: the command reads its pack from
// the environment and keeps no state, so the collection this module is handed
// goes unused.
func New(_ modules.Deps) modules.Module {
return modules.Module{
Commands: []modules.Command{
addStickerCommand(),
},
}
}
@@ -1,4 +1,4 @@
package util
package sticker
import (
"context"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"context"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"fmt"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"strings"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"bytes"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"bytes"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"context"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"errors"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"testing"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"testing"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"bytes"
@@ -1,4 +1,4 @@
package util
package sticker
import (
"bytes"
+3 -5
View File
@@ -1,7 +1,6 @@
// Package util implements /info, /help, /stickerid, /addsticker — the
// framework-validating "always on" module. /help is a pure renderer over the
// registry, /info and /stickerid are debug helpers, and /addsticker appends a
// replied sticker or photo to one shared, env-configured sticker pack.
// Package util implements /info, /help, /stickerid — the framework-validating
// "always on" module. /help is a pure renderer over the registry; the other
// two are debug helpers.
package util
import (
@@ -16,7 +15,6 @@ func New(deps modules.Deps) modules.Module {
infoCommand(),
helpCommand(deps.Registry),
stickerIDCommand(),
addStickerCommand(),
},
}
}