mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
822bcdbca8
commit
be91d2eb41
33 files changed
+1882
-52
No files matched your search
+1
-1
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+20
-2
@@ -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
|
||||
}
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user