Files
tiennm99bot/internal/modules/alias/handlers.go
T
tiennm99 cb86ec4cf3 feat(alias): log the shape of a capture at debug level
The capture failures worth debugging are all about what Telegram did not
deliver: a reply stripped of its content, a caption where text was
expected, or a kind that falls through the switch. None of that is
visible from the user-facing refusal, which only says "unsupported".

One alias_capture line per /alias reports field names, lengths and
counts — never message text, so aliased messages cannot travel with the
logs.
2026-09-08 16:02:18 +07:00

371 lines
13 KiB
Go

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
}
// Capture first, only so the debug line below can report what arrived even
// on the early returns. It is a pure read of the update — no store, no API
// — so running it before the name checks costs nothing and changes nothing
// about which reply the caller sees.
entry, ok := capture(msg.ReplyToMessage)
log.Debug("alias_capture", replyShape(msg.ReplyToMessage, entry, ok)...)
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.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"<code>/%s</code> is already a command of mine. Pick another name.",
html.EscapeString(key)))
}
}
if !ok {
if fromAnotherBot(msg.ReplyToMessage) {
return chathelper.Reply(ctx, b, msg, otherBotRefusal)
}
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.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"Replaced <code>/insert %s</code> — it was a %s, now it is a %s.",
html.EscapeString(display), describe(previous.Kind), describe(entry.Kind)))
}
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"Saved. Use <code>/insert %s</code> to send that %s.",
html.EscapeString(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.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"Nothing is saved as <code>%s</code>. Reply to a message with <code>/alias %s</code> to save one.",
html.EscapeString(display), html.EscapeString(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.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"<code>%s</code> can no longer be sent. Save it again with <code>/alias %s</code>.",
html.EscapeString(display), html.EscapeString(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.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"Nothing is saved as <code>%s</code>.", html.EscapeString(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.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"Deleted <code>%s</code>.", html.EscapeString(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 with what it holds.
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, s.renderNames(ctx, names))
}
// renderNames formats the list as Telegram HTML, trimmed to fit one message.
//
// One line per alias, each showing what the name holds, with the invocation
// wrapped in <code> so tapping it copies a command ready to send.
//
// This costs one store read per *listed* alias: DocStore has no bulk get and
// the kind lives in the document. The reads stop as soon as the message is
// full, so the cost is bounded by what fits in one reply rather than by how
// many aliases exist.
func (s *state) renderNames(ctx context.Context, names []string) string {
var sb strings.Builder
fmt.Fprintf(&sb, "%d aliases:", len(names))
for i, name := range names {
entry, found, err := s.get(ctx, name)
if err != nil {
// One unreadable record must not blank the whole list.
log.Error("alias_list_get", "name", name, "err", err)
} else if !found {
continue // deleted between the List above and this read
}
// Escaped despite parseName 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.
line := "\n<code>/" + html.EscapeString(name) + "</code> — " + kindLabel(entry.Kind)
// Reserve room for the "…and N more" tail before committing to a line,
// so the trim can never be what pushes the message over the limit.
if sb.Len()+len(line) > maxListBytes {
fmt.Fprintf(&sb, "\n…and %d more.", len(names)-i)
return sb.String()
}
sb.WriteString(line)
}
return sb.String()
}
// kindLabel names a kind for the list.
//
// Differs from describe in exactly one case: text reads as "message" in a
// sentence ("send that message") but as "text" in a column of kinds, next to
// sticker and video. An empty kind means the read above failed.
func kindLabel(kind string) string {
switch kind {
case kindText:
return "text"
case "":
return "unreadable"
}
return describe(kind)
}
// 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"
}