Files
tiennm99bot/internal/modules/alias/alias_media.go
T
tiennm99 2a86e028f6 feat(alias): keep text formatting, name kinds in /aliases, copyable commands
Text and caption formatting now survives an alias. Bold, italic, code, links
and mentions are stored as entities beside the text and sent back with it, on
the /insert, bare-command and inline paths alike. This works because the text
is re-sent byte-identical, so the offsets the entities carry stay valid — the
earlier comment claiming otherwise was wrong. They go back as entities rather
than re-rendered markup, which avoids escaping and re-parsing content the user
never wrote as markup.

/aliases now lists one line per name with what it holds, so the list says what
each will send:

    3 aliases:
    /cheer — sticker
    /clip — video
    /greeting — text

That costs one store read per listed alias, since DocStore has no bulk get and
the kind lives in the document. The reads stop once the message is full, so the
cost is bounded by what fits in one reply rather than by how many aliases exist.

Every reply that names a command or an alias wraps it in <code>, so tapping it
copies something ready to send. The generic usage lines stay plain text: they
contain a literal <name> placeholder that HTML mode would swallow as a tag.

Replying to another bot's message gets its own refusal. Telegram delivers that
reply with the content stripped, so capture finds nothing and the format advice
read as if the wrong kind had been sent. Checked only after capture fails, so
this bot's own messages — which are readable — never reach it.
2026-09-04 13:28:50 +07:00

147 lines
6.0 KiB
Go

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."
// otherBotRefusal explains a refusal no change here can lift.
//
// Telegram's own rule: "Bots will not be able to see messages from other bots
// regardless of mode." The reply arrives with its content stripped, so there is
// nothing to save and no setting that would help — worth saying outright rather
// than letting unsupportedRefusal imply the format was wrong.
const otherBotRefusal = "Telegram does not let bots read other bots' messages, so I cannot save that one. Forward it to yourself first, then reply to your copy."
// fromAnotherBot reports whether a reply that captured nothing came from a bot.
//
// Only consulted after capture fails: this bot's *own* messages are readable,
// so anything of ours captures normally and never reaches here.
func fromAnotherBot(replied *models.Message) bool {
return replied != nil && replied.From != nil && replied.From.IsBot
}
// 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, Entities: replied.CaptionEntities}, 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, Entities: replied.CaptionEntities}, true
case replied.Voice != nil:
return Alias{Kind: kindVoice, FileID: replied.Voice.FileID, Text: replied.Caption, Entities: replied.CaptionEntities}, true
case replied.Audio != nil:
return Alias{Kind: kindAudio, FileID: replied.Audio.FileID, Text: replied.Caption, Entities: replied.CaptionEntities}, true
case len(replied.Photo) > 0:
return Alias{Kind: kindPhoto, FileID: largestPhoto(replied.Photo), Text: replied.Caption, Entities: replied.CaptionEntities}, true
case replied.Document != nil:
return Alias{Kind: kindDocument, FileID: replied.Document.FileID, Text: replied.Caption, Entities: replied.CaptionEntities}, true
case replied.Text != "":
// Entities come along: the text is re-sent unchanged, so the offsets
// they carry stay valid and the formatting survives.
return Alias{Kind: kindText, Text: replied.Text, Entities: replied.Entities}, 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, CaptionEntities: a.Entities,
})
case kindAnimation:
_, err = b.SendAnimation(ctx, &bot.SendAnimationParams{
ChatID: chatID, MessageThreadID: thread, Animation: file, Caption: a.Text, CaptionEntities: a.Entities,
})
case kindVideo:
_, err = b.SendVideo(ctx, &bot.SendVideoParams{
ChatID: chatID, MessageThreadID: thread, Video: file, Caption: a.Text, CaptionEntities: a.Entities,
})
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, CaptionEntities: a.Entities,
})
case kindVoice:
_, err = b.SendVoice(ctx, &bot.SendVoiceParams{
ChatID: chatID, MessageThreadID: thread, Voice: file, Caption: a.Text, CaptionEntities: a.Entities,
})
case kindDocument:
_, err = b.SendDocument(ctx, &bot.SendDocumentParams{
ChatID: chatID, MessageThreadID: thread, Document: file, Caption: a.Text, CaptionEntities: a.Entities,
})
case kindText:
_, err = b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: chatID, MessageThreadID: thread, Text: a.Text, Entities: a.Entities,
})
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
}