Files
miti99bot/internal/modules/alias/alias.go
T
tiennm99 0260ef7fb4 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.
2026-09-04 11:36:50 +07:00

95 lines
3.4 KiB
Go

// 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,
},
},
}
}