From be91d2eb41ce71f41905091a5d400d87b991720c Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 4 Sep 2026 11:36:50 +0700 Subject: [PATCH] feat(alias): add a shared alias dictionary invocable as a bare command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /alias saves a replied message under a name and /insert 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 " 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. --- .env.example | 2 +- README.md | 4 +- cmd/server/command_menu_test.go | 4 + cmd/server/main.go | 26 +- docs/aliases.md | 156 ++++++++ docs/sticker-packs.md | 4 +- internal/modules/alias/alias.go | 94 +++++ internal/modules/alias/alias_inline.go | 128 +++++++ internal/modules/alias/alias_inline_test.go | 183 +++++++++ internal/modules/alias/alias_media.go | 131 +++++++ internal/modules/alias/fallback_test.go | 170 +++++++++ internal/modules/alias/handlers.go | 336 +++++++++++++++++ internal/modules/alias/handlers_test.go | 357 ++++++++++++++++++ internal/modules/dispatcher.go | 74 ++++ internal/modules/module.go | 28 ++ internal/modules/registry.go | 95 ++++- internal/modules/registry_test.go | 59 +++ .../{util => sticker}/addsticker_command.go | 2 +- .../addsticker_command_test.go | 22 +- internal/modules/sticker/sticker.go | 27 ++ .../{util => sticker}/sticker_download.go | 2 +- .../sticker_download_test.go | 2 +- .../{util => sticker}/sticker_emoji.go | 2 +- .../{util => sticker}/sticker_emoji_test.go | 2 +- .../{util => sticker}/sticker_image.go | 2 +- .../{util => sticker}/sticker_image_test.go | 2 +- .../modules/{util => sticker}/sticker_pack.go | 2 +- .../{util => sticker}/sticker_pack_test.go | 2 +- .../{util => sticker}/sticker_photo_test.go | 2 +- .../{util => sticker}/sticker_source_test.go | 2 +- .../{util => sticker}/sticker_video.go | 2 +- .../{util => sticker}/sticker_video_test.go | 2 +- internal/modules/util/util.go | 8 +- 33 files changed, 1882 insertions(+), 52 deletions(-) create mode 100644 docs/aliases.md create mode 100644 internal/modules/alias/alias.go create mode 100644 internal/modules/alias/alias_inline.go create mode 100644 internal/modules/alias/alias_inline_test.go create mode 100644 internal/modules/alias/alias_media.go create mode 100644 internal/modules/alias/fallback_test.go create mode 100644 internal/modules/alias/handlers.go create mode 100644 internal/modules/alias/handlers_test.go rename internal/modules/{util => sticker}/addsticker_command.go (99%) rename internal/modules/{util => sticker}/addsticker_command_test.go (95%) create mode 100644 internal/modules/sticker/sticker.go rename internal/modules/{util => sticker}/sticker_download.go (99%) rename internal/modules/{util => sticker}/sticker_download_test.go (99%) rename internal/modules/{util => sticker}/sticker_emoji.go (99%) rename internal/modules/{util => sticker}/sticker_emoji_test.go (99%) rename internal/modules/{util => sticker}/sticker_image.go (99%) rename internal/modules/{util => sticker}/sticker_image_test.go (99%) rename internal/modules/{util => sticker}/sticker_pack.go (99%) rename internal/modules/{util => sticker}/sticker_pack_test.go (99%) rename internal/modules/{util => sticker}/sticker_photo_test.go (99%) rename internal/modules/{util => sticker}/sticker_source_test.go (99%) rename internal/modules/{util => sticker}/sticker_video.go (99%) rename internal/modules/{util => sticker}/sticker_video_test.go (99%) diff --git a/.env.example b/.env.example index ab603a4..73efc87 100644 --- a/.env.example +++ b/.env.example @@ -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). diff --git a/README.md b/README.md index 7e5d594..6d3e8ca 100644 --- a/README.md +++ b/README.md @@ -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 `, `/stats cmd ` | +| `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 ` save a replied message under a name, then send it back with `/insert `, bare `/`, or inline `@botname `; `/aliases` lists, `/unalias` deletes. See [docs/aliases.md](docs/aliases.md) | | `monkeyd` | `/monkeyd_crawl [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags ` list its tags as hashtags | Disable modules with the `MODULES` environment variable. diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 77d2753..372da6e 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -73,6 +73,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "monkeyd_tags": "", "random": "", "addsticker": "[emoji...]", + "alias": "", + "aliases": "", + "unalias": "", + "insert": "", "stats": "[users | user | cmd ]", "stock_events": " [days]", "stock_info": "", diff --git a/cmd/server/main.go b/cmd/server/main.go index 7acf76e..6a756b6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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, } } diff --git a/docs/aliases.md b/docs/aliases.md new file mode 100644 index 0000000..f76265f --- /dev/null +++ b/docs/aliases.md @@ -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` | `` | any supported message | Saves it under that name | +| `/insert` | `` | — | Sends back whatever is saved under it | +| `/aliases` | — | — | Lists every saved name | +| `/unalias` | `` | — | Deletes a saved name | +| `/` | — | — | Same as `/insert ` | +| `@botname ` | — | — | 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 ` 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. **`/`** — a saved name works as its own command. `/cheer` is `/insert cheer`. +2. **`/insert `** — always works, including when inline mode is off. +3. **`@botname `** — 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 `/`. + +**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 `` 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. diff --git a/docs/sticker-packs.md b/docs/sticker-packs.md index bf96f2e..691f36c 100644 --- a/docs/sticker-packs.md +++ b/docs/sticker-packs.md @@ -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 | |---|---|---|---| diff --git a/internal/modules/alias/alias.go b/internal/modules/alias/alias.go new file mode 100644 index 0000000..21c4c3b --- /dev/null +++ b/internal/modules/alias/alias.go @@ -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 " 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: "", + Handler: s.handleAlias, + }, + { + Name: "insert", + Visibility: modules.VisibilityPublic, + Description: "Send back whatever is saved under a name", + Parameters: "", + 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: "", + Handler: s.handleUnalias, + }, + }, + } +} diff --git a/internal/modules/alias/alias_inline.go b/internal/modules/alias/alias_inline.go new file mode 100644 index 0000000..aaee73c --- /dev/null +++ b/internal/modules/alias/alias_inline.go @@ -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 " 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 +} diff --git a/internal/modules/alias/alias_inline_test.go b/internal/modules/alias/alias_inline_test.go new file mode 100644 index 0000000..9338b2b --- /dev/null +++ b/internal/modules/alias/alias_inline_test.go @@ -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 " 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)) +} diff --git a/internal/modules/alias/alias_media.go b/internal/modules/alias/alias_media.go new file mode 100644 index 0000000..8e664ec --- /dev/null +++ b/internal/modules/alias/alias_media.go @@ -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 +} diff --git a/internal/modules/alias/fallback_test.go b/internal/modules/alias/fallback_test.go new file mode 100644 index 0000000..ca23d5b --- /dev/null +++ b/internal/modules/alias/fallback_test.go @@ -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") +} diff --git a/internal/modules/alias/handlers.go b/internal/modules/alias/handlers.go new file mode 100644 index 0000000..ed2c95d --- /dev/null +++ b/internal/modules/alias/handlers.go @@ -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 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 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 ") + } + + 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 ") + } + + // 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 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 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 := "" + html.EscapeString(name) + "" + // 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" +} diff --git a/internal/modules/alias/handlers_test.go b/internal/modules/alias/handlers_test.go new file mode 100644 index 0000000..755f10b --- /dev/null +++ b/internal/modules/alias/handlers_test.go @@ -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 " 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 span so tapping one copies just that name. + if want := "alpha, mid, zeta"; !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))]) + } +} diff --git a/internal/modules/dispatcher.go b/internal/modules/dispatcher.go index 4416a69..63357e3 100644 --- a/internal/modules/dispatcher.go +++ b/internal/modules/dispatcher.go @@ -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 diff --git a/internal/modules/module.go b/internal/modules/module.go index 7b2d335..81964db 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -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 " 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. diff --git a/internal/modules/registry.go b/internal/modules/registry.go index fbd125a..39b0ff8 100644 --- a/internal/modules/registry.go +++ b/internal/modules/registry.go @@ -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) } diff --git a/internal/modules/registry_test.go b/internal/modules/registry_test.go index 539b2f4..58d6b29 100644 --- a/internal/modules/registry_test.go +++ b/internal/modules/registry_test.go @@ -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) + } + }) + } +} diff --git a/internal/modules/util/addsticker_command.go b/internal/modules/sticker/addsticker_command.go similarity index 99% rename from internal/modules/util/addsticker_command.go rename to internal/modules/sticker/addsticker_command.go index 5696cb2..a035258 100644 --- a/internal/modules/util/addsticker_command.go +++ b/internal/modules/sticker/addsticker_command.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "bytes" diff --git a/internal/modules/util/addsticker_command_test.go b/internal/modules/sticker/addsticker_command_test.go similarity index 95% rename from internal/modules/util/addsticker_command_test.go rename to internal/modules/sticker/addsticker_command_test.go index 9b4a293..e2605ed 100644 --- a/internal/modules/util/addsticker_command_test.go +++ b/internal/modules/sticker/addsticker_command_test.go @@ -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 } diff --git a/internal/modules/sticker/sticker.go b/internal/modules/sticker/sticker.go new file mode 100644 index 0000000..8a119a3 --- /dev/null +++ b/internal/modules/sticker/sticker.go @@ -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(), + }, + } +} diff --git a/internal/modules/util/sticker_download.go b/internal/modules/sticker/sticker_download.go similarity index 99% rename from internal/modules/util/sticker_download.go rename to internal/modules/sticker/sticker_download.go index a835c30..d3ba87d 100644 --- a/internal/modules/util/sticker_download.go +++ b/internal/modules/sticker/sticker_download.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "context" diff --git a/internal/modules/util/sticker_download_test.go b/internal/modules/sticker/sticker_download_test.go similarity index 99% rename from internal/modules/util/sticker_download_test.go rename to internal/modules/sticker/sticker_download_test.go index 2dcb9b1..216166d 100644 --- a/internal/modules/util/sticker_download_test.go +++ b/internal/modules/sticker/sticker_download_test.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "context" diff --git a/internal/modules/util/sticker_emoji.go b/internal/modules/sticker/sticker_emoji.go similarity index 99% rename from internal/modules/util/sticker_emoji.go rename to internal/modules/sticker/sticker_emoji.go index 8ee7ded..72f83d5 100644 --- a/internal/modules/util/sticker_emoji.go +++ b/internal/modules/sticker/sticker_emoji.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "fmt" diff --git a/internal/modules/util/sticker_emoji_test.go b/internal/modules/sticker/sticker_emoji_test.go similarity index 99% rename from internal/modules/util/sticker_emoji_test.go rename to internal/modules/sticker/sticker_emoji_test.go index a9ec5aa..8d1b1b7 100644 --- a/internal/modules/util/sticker_emoji_test.go +++ b/internal/modules/sticker/sticker_emoji_test.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "strings" diff --git a/internal/modules/util/sticker_image.go b/internal/modules/sticker/sticker_image.go similarity index 99% rename from internal/modules/util/sticker_image.go rename to internal/modules/sticker/sticker_image.go index ad5d471..19aefb2 100644 --- a/internal/modules/util/sticker_image.go +++ b/internal/modules/sticker/sticker_image.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "bytes" diff --git a/internal/modules/util/sticker_image_test.go b/internal/modules/sticker/sticker_image_test.go similarity index 99% rename from internal/modules/util/sticker_image_test.go rename to internal/modules/sticker/sticker_image_test.go index 5ec4e31..fbd2811 100644 --- a/internal/modules/util/sticker_image_test.go +++ b/internal/modules/sticker/sticker_image_test.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "bytes" diff --git a/internal/modules/util/sticker_pack.go b/internal/modules/sticker/sticker_pack.go similarity index 99% rename from internal/modules/util/sticker_pack.go rename to internal/modules/sticker/sticker_pack.go index cf8ba22..b7092b5 100644 --- a/internal/modules/util/sticker_pack.go +++ b/internal/modules/sticker/sticker_pack.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "context" diff --git a/internal/modules/util/sticker_pack_test.go b/internal/modules/sticker/sticker_pack_test.go similarity index 99% rename from internal/modules/util/sticker_pack_test.go rename to internal/modules/sticker/sticker_pack_test.go index 68284a3..9227f4e 100644 --- a/internal/modules/util/sticker_pack_test.go +++ b/internal/modules/sticker/sticker_pack_test.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "errors" diff --git a/internal/modules/util/sticker_photo_test.go b/internal/modules/sticker/sticker_photo_test.go similarity index 99% rename from internal/modules/util/sticker_photo_test.go rename to internal/modules/sticker/sticker_photo_test.go index 3f8b9fa..992a62e 100644 --- a/internal/modules/util/sticker_photo_test.go +++ b/internal/modules/sticker/sticker_photo_test.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "testing" diff --git a/internal/modules/util/sticker_source_test.go b/internal/modules/sticker/sticker_source_test.go similarity index 99% rename from internal/modules/util/sticker_source_test.go rename to internal/modules/sticker/sticker_source_test.go index c0b27ee..5c42d87 100644 --- a/internal/modules/util/sticker_source_test.go +++ b/internal/modules/sticker/sticker_source_test.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "testing" diff --git a/internal/modules/util/sticker_video.go b/internal/modules/sticker/sticker_video.go similarity index 99% rename from internal/modules/util/sticker_video.go rename to internal/modules/sticker/sticker_video.go index e25e0aa..44c6d6b 100644 --- a/internal/modules/util/sticker_video.go +++ b/internal/modules/sticker/sticker_video.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "bytes" diff --git a/internal/modules/util/sticker_video_test.go b/internal/modules/sticker/sticker_video_test.go similarity index 99% rename from internal/modules/util/sticker_video_test.go rename to internal/modules/sticker/sticker_video_test.go index 63bb57b..4091bc2 100644 --- a/internal/modules/util/sticker_video_test.go +++ b/internal/modules/sticker/sticker_video_test.go @@ -1,4 +1,4 @@ -package util +package sticker import ( "bytes" diff --git a/internal/modules/util/util.go b/internal/modules/util/util.go index 8f6404d..a1ee669 100644 --- a/internal/modules/util/util.go +++ b/internal/modules/util/util.go @@ -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(), }, } }