FILE / ScuroNeko/Laniakea
msg_context.go
Исходный файл и его история в репозитории.
Introduce first-class policy helpers and composition through middleware Normalize chat context for more updates and add regression coverage Document the completed backlog item in changelog, TODO, and wiki
775 lines
25 KiB
Go
775 lines
25 KiB
Go
package laniakea
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
|
"git.scuroneko.dev/scuroneko/slog"
|
|
)
|
|
|
|
// MsgContext holds the normalized per-update context passed to command, payload,
|
|
// scene, middleware, and generic update handlers.
|
|
//
|
|
// MsgContext is populated from the current Telegram update before handler routing.
|
|
// Not every field is guaranteed for every update kind. In particular:
|
|
// - Update is always present.
|
|
// - Msg is populated only for update kinds that carry a Telegram message object.
|
|
// - From and FromID are populated only when the update exposes a user identity.
|
|
// - Chat and ChatID are populated only when the update exposes a chat identity.
|
|
// - Text, Args, and Prefix are populated only by command or scene command routing.
|
|
// - CallbackQueryId, CallbackMsgId, and InlineMsgId are populated only for
|
|
// callback query handling when the corresponding callback targets exist.
|
|
//
|
|
// Helper methods on MsgContext may require a message-backed context. For example,
|
|
// reply helpers need Msg, while inline callback edit helpers can work through
|
|
// InlineMsgId when there is no chat message.
|
|
type MsgContext struct {
|
|
Api *tgapi.API
|
|
Update tgapi.Update
|
|
|
|
// Msg is the normalized Telegram message for message-backed update kinds.
|
|
// It is nil for updates that do not include a message object.
|
|
Msg *tgapi.Message
|
|
// From is the normalized Telegram user for update kinds that expose one.
|
|
// It stays nil for sender-chat-only updates and update kinds without a user.
|
|
From *tgapi.User
|
|
// Chat is the normalized Telegram chat for update kinds that expose one.
|
|
// It is nil for updates that do not include a chat identity.
|
|
Chat *tgapi.Chat
|
|
|
|
// Logger is the logger assigned by the matched plugin for the current handler call.
|
|
// It may fall back to the bot logger when the plugin has no dedicated logger.
|
|
Logger *slog.Logger
|
|
|
|
// InlineMsgId is the inline message identifier for callback queries that target
|
|
// an inline message instead of a chat message.
|
|
InlineMsgId string
|
|
// CallbackMsgId is the message ID targeted by the current callback query when
|
|
// the callback comes from a chat message.
|
|
CallbackMsgId int
|
|
// CallbackQueryId is the Telegram callback query ID for payload handlers and
|
|
// callback-backed scene handlers.
|
|
CallbackQueryId string
|
|
// FromID is the normalized sender ID when the current update exposes a user.
|
|
// It is zero when the update has no user identity.
|
|
FromID int64
|
|
// ChatID is the normalized chat ID when the current update exposes a chat.
|
|
// It is zero when the update has no chat identity.
|
|
ChatID int64
|
|
// Prefix is the matched command prefix for command routing and scene-local
|
|
// command routing. It is empty outside those flows.
|
|
Prefix string
|
|
// Text is the parsed command tail for command routing, the parsed scene-command
|
|
// tail for scene-local command routing, or the trimmed message text seen by a
|
|
// scene step/message handler. It is empty when the current routing path does
|
|
// not derive text input.
|
|
Text string
|
|
// Args contains parsed command or payload arguments for the current routing
|
|
// path. It is nil or empty when no argument vector is derived.
|
|
Args []string
|
|
|
|
errorTemplate string
|
|
l10n *L10n
|
|
draftProvider *DraftProvider
|
|
payloadType BotPayloadType
|
|
sceneRuntime sceneRuntime
|
|
|
|
ctx context.Context
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Internal helper for text edits with optional keyboard and parse mode.
|
|
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
if err := validateMessageText(text); err != nil {
|
|
ctx.Logger.Errorln(err)
|
|
return nil
|
|
}
|
|
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.Logger.Errorln(ErrEditTargetMissing)
|
|
return nil
|
|
}
|
|
if keyboard != nil {
|
|
params.ReplyMarkup = keyboard.Get()
|
|
}
|
|
msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params)
|
|
if err != nil {
|
|
ctx.Logger.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)
|
|
}
|
|
|
|
// Internal helper for editing callback-linked messages.
|
|
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
|
|
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
|
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)
|
|
}
|
|
|
|
// Internal helper for media-caption edits.
|
|
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
if err := validateCaptionText(text); err != nil {
|
|
ctx.Logger.Errorln(err)
|
|
return nil
|
|
}
|
|
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.Logger.Errorln(ErrEditTargetMissing)
|
|
return nil
|
|
}
|
|
if kb != nil {
|
|
params.ReplyMarkup = kb.Get()
|
|
}
|
|
|
|
msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params)
|
|
if err != nil {
|
|
ctx.Logger.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)
|
|
}
|
|
|
|
// Internal helper for message replies with optional keyboard and parse mode.
|
|
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
if ctx.Msg == nil {
|
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
|
return nil
|
|
}
|
|
if err := validateMessageText(text); err != nil {
|
|
ctx.Logger.Errorln(err)
|
|
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.SendMessageWithContext(ctx.Context(), params)
|
|
if err != nil {
|
|
ctx.Logger.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)
|
|
}
|
|
|
|
// AnswerLong sends one or more plain-text messages if text exceeds Telegram's limit.
|
|
//
|
|
// The text is split into Telegram-safe chunks. Returned messages preserve send
|
|
// order. If a chunk fails to send, already-sent messages are returned.
|
|
func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage {
|
|
return ctx.answerLong(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)
|
|
}
|
|
|
|
// AnswerLongf formats a string using fmt.Sprintf and sends it as one or more plain-text messages.
|
|
func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessage {
|
|
return ctx.answerLong(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)
|
|
}
|
|
|
|
// KeyboardLong sends long plain text split across multiple messages.
|
|
//
|
|
// The inline keyboard is attached only to the final chunk.
|
|
func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMessage {
|
|
return ctx.answerLong(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)
|
|
}
|
|
|
|
func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage {
|
|
if parseMode != tgapi.ParseNone {
|
|
ctx.Logger.Errorln(ErrMessageSplitImpossible)
|
|
return nil
|
|
}
|
|
if ctx.Msg == nil {
|
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
|
return nil
|
|
}
|
|
if err := validateMessageText(text); err == nil {
|
|
msg := ctx.answer(text, keyboard, parseMode)
|
|
if msg == nil {
|
|
return nil
|
|
}
|
|
return []*AnswerMessage{msg}
|
|
} else if !errors.Is(err, ErrMessageTooLong) {
|
|
ctx.Logger.Errorln(err)
|
|
return nil
|
|
}
|
|
|
|
parts := SplitMessageText(text)
|
|
messages := make([]*AnswerMessage, 0, len(parts))
|
|
for i, part := range parts {
|
|
partKeyboard := (*InlineKeyboard)(nil)
|
|
if i == len(parts)-1 {
|
|
partKeyboard = keyboard
|
|
}
|
|
msg := ctx.answer(part, partKeyboard, parseMode)
|
|
if msg == nil {
|
|
break
|
|
}
|
|
messages = append(messages, msg)
|
|
}
|
|
if len(messages) == 0 {
|
|
return nil
|
|
}
|
|
return messages
|
|
}
|
|
|
|
// Internal helper for photo replies with optional caption and keyboard.
|
|
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
|
if ctx.Msg == nil {
|
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
|
return nil
|
|
}
|
|
if err := validateCaptionText(text); err != nil {
|
|
ctx.Logger.Errorln(err)
|
|
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.SendPhotoWithContext(ctx.Context(), params)
|
|
if err != nil {
|
|
ctx.Logger.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)
|
|
}
|
|
|
|
// Internal helper that deletes a message by ID.
|
|
func (ctx *MsgContext) delete(messageId int) {
|
|
if messageId == 0 {
|
|
ctx.Logger.Errorln(ErrMessageIDZero)
|
|
return
|
|
}
|
|
if ctx.Msg == nil {
|
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
|
return
|
|
}
|
|
_, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessageP{
|
|
ChatID: ctx.Msg.Chat.ID,
|
|
MessageID: messageId,
|
|
})
|
|
if err != nil {
|
|
ctx.Logger.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.Logger.Errorln(ErrCallbackMessageMissing)
|
|
return
|
|
}
|
|
ctx.delete(ctx.CallbackMsgId)
|
|
}
|
|
|
|
// Internal helper that answers a callback query with optional text, alert, or URL.
|
|
func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
|
|
if len(ctx.CallbackQueryId) == 0 {
|
|
return
|
|
}
|
|
_, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQueryP{
|
|
CallbackQueryID: ctx.CallbackQueryId,
|
|
Text: text, ShowAlert: showAlert, URL: url,
|
|
})
|
|
if err != nil {
|
|
ctx.Logger.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.Logger.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.SendChatActionWithContext(ctx.Context(), params)
|
|
if err != nil {
|
|
ctx.Logger.Errorln(err)
|
|
}
|
|
}
|
|
|
|
// Internal helper that formats, sends, and logs an error.
|
|
func (ctx *MsgContext) error(err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
ctx.Logger.Errorln(err)
|
|
if IsInternalError(err) {
|
|
return
|
|
}
|
|
text := fmt.Sprintf(ctx.errorTemplate, err.Error())
|
|
|
|
if ctx.CallbackQueryId != "" {
|
|
ctx.answerCallbackQuery("", text, false)
|
|
} else {
|
|
ctx.answer(text, nil, tgapi.ParseNone)
|
|
}
|
|
}
|
|
|
|
// 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.Logger.Errorln(ErrMessageContextNil)
|
|
return nil
|
|
}
|
|
if ctx.Api == nil {
|
|
ctx.Logger.Errorln(ErrAPIIsNil)
|
|
return nil
|
|
}
|
|
if ctx.draftProvider == nil {
|
|
ctx.Logger.Errorln(ErrDraftProviderNil)
|
|
return nil
|
|
}
|
|
|
|
if ctx.Api.Limiter != nil {
|
|
c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second)
|
|
defer cancel()
|
|
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
|
ctx.Logger.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)
|
|
}
|
|
|
|
func bindPositional(args []string, dst any) error {
|
|
v := reflect.ValueOf(dst)
|
|
if v.Kind() != reflect.Pointer || v.IsNil() {
|
|
return ErrBindArgsTargetNotPointer
|
|
}
|
|
|
|
v = v.Elem()
|
|
if v.Kind() != reflect.Struct {
|
|
return ErrBindArgsTargetNotStruct
|
|
}
|
|
|
|
t := v.Type()
|
|
fields := make([]int, 0, v.NumField())
|
|
|
|
for i := 0; i < v.NumField(); i++ {
|
|
field := v.Field(i)
|
|
if !field.CanSet() {
|
|
continue
|
|
}
|
|
fields = append(fields, i)
|
|
}
|
|
|
|
argIndex := 0
|
|
for fieldPos, fieldIndex := range fields {
|
|
field := v.Field(fieldIndex)
|
|
fieldType := t.Field(fieldIndex)
|
|
|
|
if argIndex >= len(args) {
|
|
// Leave trailing fields at their zero values when arguments run out.
|
|
break
|
|
}
|
|
|
|
isLastBindableField := fieldPos == len(fields)-1
|
|
|
|
raw := args[argIndex]
|
|
if isLastBindableField && field.Kind() == reflect.String {
|
|
raw = strings.Join(args[argIndex:], " ")
|
|
}
|
|
|
|
switch field.Kind() {
|
|
case reflect.String:
|
|
field.SetString(raw)
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
n, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
|
|
}
|
|
field.SetInt(n)
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
n, err := strconv.ParseUint(raw, 10, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
|
|
}
|
|
field.SetUint(n)
|
|
case reflect.Float32, reflect.Float64:
|
|
f, err := strconv.ParseFloat(raw, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
|
|
}
|
|
field.SetFloat(f)
|
|
case reflect.Bool:
|
|
b, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
|
|
}
|
|
field.SetBool(b)
|
|
default:
|
|
return fmt.Errorf("%w: field %s: %s", ErrBindArgsUnsupportedFieldType, fieldType.Name, field.Kind())
|
|
}
|
|
|
|
if isLastBindableField && field.Kind() == reflect.String {
|
|
break
|
|
}
|
|
argIndex++
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// BindArgs binds positional command arguments from ctx.Args into dst.
|
|
//
|
|
// Exported struct fields are filled in declaration order. When fewer arguments
|
|
// are provided than fields, the remaining fields keep their zero values. If the
|
|
// final bindable field is a string, it receives the remaining arguments joined
|
|
// with spaces.
|
|
func (ctx *MsgContext) BindArgs(dst any) error {
|
|
return bindPositional(ctx.Args, dst)
|
|
}
|
|
|
|
// Context returns the request-scoped context associated with the current update.
|
|
func (ctx *MsgContext) Context() context.Context {
|
|
if ctx.ctx == nil {
|
|
return context.Background()
|
|
}
|
|
return ctx.ctx
|
|
}
|
|
|
|
// EnterScene enters the named scene at its configured entry step.
|
|
func (ctx *MsgContext) EnterScene(name string) error {
|
|
if ctx.sceneRuntime == nil {
|
|
return ErrSceneRuntimeNil
|
|
}
|
|
|
|
scene, ok := ctx.sceneRuntime.findScene(name)
|
|
if !ok {
|
|
return ErrSceneNotFound
|
|
}
|
|
|
|
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
|
|
if !ok {
|
|
return ErrCantFindSession
|
|
}
|
|
if scene.Entry == "" {
|
|
return ErrSceneEntryNotSet
|
|
}
|
|
if _, ok := scene.Steps[scene.Entry]; !ok {
|
|
return ErrSceneStepNotFound
|
|
}
|
|
|
|
session := SceneSession{
|
|
Scene: scene.Name,
|
|
Step: scene.Entry,
|
|
}
|
|
|
|
return ctx.sceneRuntime.setSession(key, session)
|
|
}
|
|
|
|
// EnterSceneStep enters the named scene at a specific step.
|
|
func (ctx *MsgContext) EnterSceneStep(name, step string) error {
|
|
if ctx.sceneRuntime == nil {
|
|
return ErrSceneRuntimeNil
|
|
}
|
|
|
|
scene, ok := ctx.sceneRuntime.findScene(name)
|
|
if !ok {
|
|
return ErrSceneNotFound
|
|
}
|
|
if _, ok := scene.Steps[step]; !ok {
|
|
return ErrSceneStepNotFound
|
|
}
|
|
|
|
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
|
|
if !ok {
|
|
return ErrCantFindSession
|
|
}
|
|
|
|
session := SceneSession{
|
|
Scene: scene.Name,
|
|
Step: step,
|
|
}
|
|
|
|
return ctx.sceneRuntime.setSession(key, session)
|
|
}
|
|
|
|
// ExitScene leaves the currently active scene for this context.
|
|
func (ctx *MsgContext) ExitScene() error {
|
|
if ctx.sceneRuntime == nil {
|
|
return ErrSceneRuntimeNil
|
|
}
|
|
|
|
_, session, err := ctx.sceneRuntime.findSceneSession(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if session.Scene == "" {
|
|
return ErrNotInScene
|
|
}
|
|
|
|
scene, ok := ctx.sceneRuntime.findScene(session.Scene)
|
|
if !ok {
|
|
return ErrSceneNotFound
|
|
}
|
|
|
|
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
|
|
if !ok {
|
|
return ErrCantFindSession
|
|
}
|
|
|
|
return ctx.sceneRuntime.deleteSession(key)
|
|
}
|