Files
miti99bot/internal/modules/alias/alias_inline.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

129 lines
4.1 KiB
Go

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
}