462 lines
16 KiB
Go
462 lines
16 KiB
Go
package laniakea
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
|
"git.nix13.pw/scuroneko/slog"
|
|
)
|
|
|
|
// MsgContext holds the context for handling a Telegram message or callback query.
|
|
// It provides methods to respond, edit, delete, and translate messages, as well as
|
|
// manage inline keyboards and message drafts.
|
|
type MsgContext struct {
|
|
Api *tgapi.API
|
|
Update tgapi.Update
|
|
|
|
Msg *tgapi.Message
|
|
From *tgapi.User
|
|
|
|
InlineMsgId string
|
|
CallbackMsgId int
|
|
CallbackQueryId string
|
|
FromID int64
|
|
Prefix string
|
|
Text string
|
|
Args []string
|
|
|
|
errorTemplate string
|
|
botLogger *slog.Logger
|
|
l10n *L10n
|
|
draftProvider *DraftProvider
|
|
payloadType BotPayloadType
|
|
}
|
|
|
|
// AnswerMessage represents a message sent or edited via MsgContext.
|
|
// It holds metadata to allow further editing or deletion.
|
|
type AnswerMessage struct {
|
|
MessageID int
|
|
Text string
|
|
IsMedia bool
|
|
ctx *MsgContext // internal back-reference
|
|
}
|
|
|
|
// edit is an internal helper to edit a message's text with optional keyboard and parse mode.
|
|
// Used by Edit, EditMarkdown, EditCallback, etc.
|
|
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
params := tgapi.EditMessageTextP{
|
|
Text: text,
|
|
ParseMode: parseMode,
|
|
}
|
|
switch {
|
|
case messageId > 0 && ctx.Msg != nil:
|
|
params.MessageID = messageId
|
|
params.ChatID = ctx.Msg.Chat.ID
|
|
case ctx.InlineMsgId != "":
|
|
params.InlineMessageID = ctx.InlineMsgId
|
|
default:
|
|
ctx.botLogger.Errorln("Can't edit message: no valid message target")
|
|
return nil
|
|
}
|
|
if keyboard != nil {
|
|
params.ReplyMarkup = keyboard.Get()
|
|
}
|
|
msg, _, err := ctx.Api.EditMessageText(params)
|
|
if err != nil {
|
|
ctx.botLogger.Errorln(err)
|
|
return nil
|
|
}
|
|
resultMessageID := messageId
|
|
if msg.MessageID > 0 {
|
|
resultMessageID = msg.MessageID
|
|
}
|
|
return &AnswerMessage{
|
|
MessageID: resultMessageID, ctx: ctx, Text: text, IsMedia: false,
|
|
}
|
|
}
|
|
|
|
// Edit replaces the text of the message without changing the keyboard or parse mode.
|
|
// Uses ParseNone (plain text).
|
|
func (m *AnswerMessage) Edit(text string) *AnswerMessage {
|
|
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseNone)
|
|
}
|
|
|
|
// EditMarkdown replaces the text of the message using MarkdownV2 formatting.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
// Unescaped input may cause Telegram API errors or broken formatting.
|
|
func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
|
|
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// editCallback is an internal helper to edit the message associated with a callback query.
|
|
// Supports both regular callback messages and inline callback messages.
|
|
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
|
|
ctx.botLogger.Errorln("Can't edit non-callback update message")
|
|
return nil
|
|
}
|
|
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
|
|
}
|
|
|
|
// EditCallback edits the callback message using plain text (ParseNone).
|
|
func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
|
return ctx.editCallback(text, keyboard, tgapi.ParseNone)
|
|
}
|
|
|
|
// EditCallbackMarkdown edits the callback message using MarkdownV2.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
|
return ctx.editCallback(text, keyboard, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// EditCallbackf formats a string using fmt.Sprintf and edits the callback message with plain text.
|
|
func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage {
|
|
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseNone)
|
|
}
|
|
|
|
// EditCallbackfMarkdown formats a string using fmt.Sprintf and edits the callback message with MarkdownV2.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage {
|
|
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// editPhotoText edits the caption of a photo/video message.
|
|
// Returns nil when no valid edit target is available for the current context.
|
|
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
params := tgapi.EditMessageCaptionP{
|
|
Caption: text,
|
|
ParseMode: parseMode,
|
|
}
|
|
switch {
|
|
case messageId > 0 && ctx.Msg != nil:
|
|
params.ChatID = ctx.Msg.Chat.ID
|
|
params.MessageID = messageId
|
|
case ctx.InlineMsgId != "":
|
|
params.InlineMessageID = ctx.InlineMsgId
|
|
default:
|
|
ctx.botLogger.Errorln("Can't edit caption: no valid message target")
|
|
return nil
|
|
}
|
|
if kb != nil {
|
|
params.ReplyMarkup = kb.Get()
|
|
}
|
|
|
|
msg, _, err := ctx.Api.EditMessageCaption(params)
|
|
if err != nil {
|
|
ctx.botLogger.Errorln(err)
|
|
return nil
|
|
}
|
|
resultMessageID := messageId
|
|
if msg.MessageID > 0 {
|
|
resultMessageID = msg.MessageID
|
|
}
|
|
return &AnswerMessage{
|
|
MessageID: resultMessageID, ctx: ctx, Text: text, IsMedia: true,
|
|
}
|
|
}
|
|
|
|
// EditCaption edits the caption of a media message using plain text.
|
|
func (m *AnswerMessage) EditCaption(text string) *AnswerMessage {
|
|
return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseNone)
|
|
}
|
|
|
|
// EditCaptionMarkdown edits the caption of a media message using MarkdownV2.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (m *AnswerMessage) EditCaptionMarkdown(text string) *AnswerMessage {
|
|
return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// EditCaptionKeyboard edits the caption of a media message with a new inline keyboard (plain text).
|
|
func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *AnswerMessage {
|
|
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseNone)
|
|
}
|
|
|
|
// EditCaptionKeyboardMarkdown edits the caption of a media message with a new inline keyboard using MarkdownV2.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeyboard) *AnswerMessage {
|
|
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// answer sends a new message with optional keyboard and parse mode.
|
|
// Uses API limiter to respect Telegram rate limits per chat.
|
|
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
if ctx.Msg == nil {
|
|
ctx.botLogger.Errorln("Can't answer message without a message")
|
|
return nil
|
|
}
|
|
params := tgapi.SendMessageP{
|
|
ChatID: ctx.Msg.Chat.ID,
|
|
Text: text,
|
|
ParseMode: parseMode,
|
|
}
|
|
if keyboard != nil {
|
|
params.ReplyMarkup = keyboard.Get()
|
|
}
|
|
if ctx.Msg.MessageThreadID > 0 {
|
|
params.MessageThreadID = ctx.Msg.MessageThreadID
|
|
}
|
|
if ctx.Msg.DirectMessageTopic != nil {
|
|
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
|
|
}
|
|
|
|
msg, err := ctx.Api.SendMessage(params)
|
|
if err != nil {
|
|
ctx.botLogger.Errorln(err)
|
|
return nil
|
|
}
|
|
return &AnswerMessage{
|
|
MessageID: msg.MessageID, ctx: ctx, IsMedia: false, Text: text,
|
|
}
|
|
}
|
|
|
|
// Answer sends a plain text message (ParseNone).
|
|
func (ctx *MsgContext) Answer(text string) *AnswerMessage {
|
|
return ctx.answer(text, nil, tgapi.ParseNone)
|
|
}
|
|
|
|
// AnswerMarkdown sends a message using MarkdownV2 formatting.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage {
|
|
return ctx.answer(text, nil, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// Answerf formats a string using fmt.Sprintf and sends it as a plain text message.
|
|
func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage {
|
|
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
|
|
}
|
|
|
|
// AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage {
|
|
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// Keyboard sends a message with an inline keyboard (plain text).
|
|
func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage {
|
|
return ctx.answer(text, kb, tgapi.ParseNone)
|
|
}
|
|
|
|
// KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
|
return ctx.answer(text, keyboard, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// answerPhoto sends a photo with optional caption and keyboard.
|
|
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
if ctx.Msg == nil {
|
|
ctx.botLogger.Errorln("Can't answer message without a message")
|
|
return nil
|
|
}
|
|
params := tgapi.SendPhotoP{
|
|
ChatID: ctx.Msg.Chat.ID,
|
|
Caption: text,
|
|
ParseMode: parseMode,
|
|
Photo: photoId,
|
|
}
|
|
if kb != nil {
|
|
params.ReplyMarkup = kb.Get()
|
|
}
|
|
if ctx.Msg.MessageThreadID > 0 {
|
|
params.MessageThreadID = ctx.Msg.MessageThreadID
|
|
}
|
|
if ctx.Msg.DirectMessageTopic != nil {
|
|
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
|
|
}
|
|
|
|
msg, err := ctx.Api.SendPhoto(params)
|
|
if err != nil {
|
|
ctx.botLogger.Errorln(err)
|
|
return nil
|
|
}
|
|
return &AnswerMessage{
|
|
MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true,
|
|
}
|
|
}
|
|
|
|
// AnswerPhoto sends a photo with plain text caption.
|
|
func (ctx *MsgContext) AnswerPhoto(photoId, text string) *AnswerMessage {
|
|
return ctx.answerPhoto(photoId, text, nil, tgapi.ParseNone)
|
|
}
|
|
|
|
// AnswerPhotoMarkdown sends a photo with MarkdownV2 caption.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (ctx *MsgContext) AnswerPhotoMarkdown(photoId, text string) *AnswerMessage {
|
|
return ctx.answerPhoto(photoId, text, nil, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text).
|
|
func (ctx *MsgContext) AnswerPhotoKeyboard(photoId, text string, kb *InlineKeyboard) *AnswerMessage {
|
|
return ctx.answerPhoto(photoId, text, kb, tgapi.ParseNone)
|
|
}
|
|
|
|
// AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoId, text string, kb *InlineKeyboard) *AnswerMessage {
|
|
return ctx.answerPhoto(photoId, text, kb, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// AnswerPhotof formats a string and sends it as a photo caption (plain text).
|
|
func (ctx *MsgContext) AnswerPhotof(photoId, template string, args ...any) *AnswerMessage {
|
|
return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
|
|
}
|
|
|
|
// AnswerPhotofMarkdown formats a string and sends it as a photo caption using MarkdownV2.
|
|
//
|
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
|
func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...any) *AnswerMessage {
|
|
return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2)
|
|
}
|
|
|
|
// delete removes a message by ID.
|
|
func (ctx *MsgContext) delete(messageId int) {
|
|
if messageId == 0 {
|
|
ctx.botLogger.Errorln("Can't delete message: message ID zero")
|
|
return
|
|
}
|
|
if ctx.Msg == nil {
|
|
ctx.botLogger.Errorln("Can't delete message: no chat message context")
|
|
return
|
|
}
|
|
_, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{
|
|
ChatID: ctx.Msg.Chat.ID,
|
|
MessageID: messageId,
|
|
})
|
|
if err != nil {
|
|
ctx.botLogger.Errorln(err)
|
|
}
|
|
}
|
|
|
|
// Delete removes the message associated with this AnswerMessage.
|
|
func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
|
|
|
|
// CallbackDelete deletes the message that triggered the callback query.
|
|
func (ctx *MsgContext) CallbackDelete() {
|
|
if ctx.CallbackMsgId == 0 {
|
|
ctx.botLogger.Errorln("Can't delete callback message: no callback message ID")
|
|
return
|
|
}
|
|
ctx.delete(ctx.CallbackMsgId)
|
|
}
|
|
|
|
// answerCallbackQuery sends a response to a callback query (optional text/alert/url).
|
|
// Does nothing if CallbackQueryId is empty.
|
|
func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
|
|
if len(ctx.CallbackQueryId) == 0 {
|
|
return
|
|
}
|
|
_, err := ctx.Api.AnswerCallbackQuery(tgapi.AnswerCallbackQueryP{
|
|
CallbackQueryID: ctx.CallbackQueryId,
|
|
Text: text, ShowAlert: showAlert, URL: url,
|
|
})
|
|
if err != nil {
|
|
ctx.botLogger.Errorln(err)
|
|
}
|
|
}
|
|
|
|
// AnswerCbQuery answers the callback query with no text or alert.
|
|
func (ctx *MsgContext) AnswerCbQuery() { ctx.answerCallbackQuery("", "", false) }
|
|
|
|
// AnswerCbQueryText answers the callback query with a text notification.
|
|
func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery("", text, false) }
|
|
|
|
// AnswerCbQueryAlert answers the callback query with a user-visible alert.
|
|
func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) }
|
|
|
|
// AnswerCbQueryUrl answers the callback query with a URL redirect.
|
|
func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "", false) }
|
|
|
|
// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
|
|
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
|
|
if ctx.Msg == nil {
|
|
ctx.botLogger.Errorln("Can't send action without chat message context")
|
|
return
|
|
}
|
|
params := tgapi.SendChatActionP{
|
|
ChatID: ctx.Msg.Chat.ID, Action: action,
|
|
}
|
|
if ctx.Msg.MessageThreadID > 0 {
|
|
params.MessageThreadID = ctx.Msg.MessageThreadID
|
|
}
|
|
_, err := ctx.Api.SendChatAction(params)
|
|
if err != nil {
|
|
ctx.botLogger.Errorln(err)
|
|
}
|
|
}
|
|
|
|
// error sends an error message to the user and logs it.
|
|
// Uses errorTemplate to format the message.
|
|
// For callbacks: sends as callback answer (no alert).
|
|
// For regular messages: sends as plain text.
|
|
func (ctx *MsgContext) error(err error) {
|
|
text := fmt.Sprintf(ctx.errorTemplate, err.Error())
|
|
|
|
if ctx.CallbackQueryId != "" {
|
|
ctx.answerCallbackQuery("", text, false)
|
|
} else {
|
|
ctx.answer(text, nil, tgapi.ParseNone)
|
|
}
|
|
ctx.botLogger.Errorln(err)
|
|
}
|
|
|
|
// Error is an alias for error().
|
|
func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
|
|
|
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
|
if ctx.Msg == nil {
|
|
ctx.botLogger.Errorln("can't create draft: ctx.Msg is nil")
|
|
return nil
|
|
}
|
|
|
|
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
|
ctx.botLogger.Errorln(err)
|
|
return nil
|
|
}
|
|
|
|
draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID)
|
|
return draft
|
|
}
|
|
|
|
// NewDraft creates a new message draft associated with the current chat.
|
|
// Uses the API limiter to avoid rate limiting.
|
|
func (ctx *MsgContext) NewDraft() *Draft {
|
|
return ctx.newDraft(tgapi.ParseNone)
|
|
}
|
|
|
|
// NewDraftMarkdown creates a new message draft associated with the current chat,
|
|
// with Markdown V2 parse mode enabled.
|
|
// Uses the API limiter to avoid rate limiting.
|
|
func (ctx *MsgContext) NewDraftMarkdown() *Draft {
|
|
return ctx.newDraft(tgapi.ParseMDV2)
|
|
}
|
|
|
|
// Translate looks up a key in the current user's language.
|
|
// Falls back to the bot's default language if user's language is unknown or unsupported.
|
|
func (ctx *MsgContext) Translate(key string) string {
|
|
if ctx.From == nil {
|
|
return key
|
|
}
|
|
lang := Val(ctx.From.LanguageCode, ctx.l10n.GetFallbackLanguage())
|
|
return ctx.l10n.Translate(lang, key)
|
|
}
|
|
|
|
// NewInlineKeyboard creates a new keyboard builder with the context's payload
|
|
// encoding type and the specified maximum number of buttons per row.
|
|
func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard {
|
|
return NewInlineKeyboard(ctx.payloadType, maxRow)
|
|
}
|