From 09fb9261df0ae618f6e8e8159e8c67a8095516cf Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 12 May 2026 16:23:30 +0300 Subject: [PATCH] (new): compact payload type --- CHANGELOG.md | 2 ++ bot.go | 4 +++ handler.go | 94 ++++++++++++++++++++++++++++++++++-------------- keyboard.go | 61 ++++++++++++++++++++++++++++--- keyboard_test.go | 46 ++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4098853..cc94fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware. - Added the `tgfmt` package with typed MarkdownV2, HTML, legacy Markdown formatting helpers, and a message entity builder. - Added `InlineKeyboardButtonBuilder.SetPayloadType(...)`, `InlineKeyboardButtonBuilder.SetCallbackData(...)`, and `MsgContext.NewInlineKeyboardButton(...)` helpers for payload-aware button building. +- Added compact callback payload encoding through `BotPayloadCompact`, `BotPayloadCompactBase64`, compact inline keyboard builders, and matching `CallbackData` helpers. - Added `tgapi.ResponseError` so Telegram API error codes, descriptions, and response parameters remain inspectable through returned errors. ### Changed @@ -35,6 +36,7 @@ - Added regression coverage for command group prefixing, middleware order, clone behavior, and plugin registration. - Added formatting coverage for escaping, composition, link destinations, HTML attributes, and legacy Markdown code blocks. - Added regression coverage for context-aware inline keyboard button payload encoding. +- Added regression coverage for compact and Base64-encoded compact callback payload decoding. - Added regression coverage for long-polling `retry_after` handling on Telegram 429 responses. ## v1.0.0-rc.16 diff --git a/bot.go b/bot.go index f5dcbde..87d00d1 100644 --- a/bot.go +++ b/bot.go @@ -52,6 +52,10 @@ var ( BotPayloadBase64 BotPayloadType = "base64" // BotPayloadJSON encodes callback data as a JSON string. BotPayloadJSON BotPayloadType = "json" + // BotPayloadCompact encodes callback data as a compact delimited string. + BotPayloadCompact BotPayloadType = "compact" + // BotPayloadCompactBase64 encodes compact callback data as a Base64 string. + BotPayloadCompactBase64 BotPayloadType = "compact-base64" ) var ( diff --git a/handler.go b/handler.go index f4e2a54..b1116ff 100644 --- a/handler.go +++ b/handler.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" @@ -153,36 +154,77 @@ func decodeBase64Payload(s string) (CallbackData, error) { return decodeJSONPayload(string(b)) } -func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) { +func encodeCompactPayload(d CallbackData) (string, error) { + args := strings.Join(d.Args, ",") + return d.Command + "|" + args, nil +} +func decodeCompactPayload(s string) (CallbackData, error) { + values := strings.SplitN(s, "|", 2) + if len(values) != 2 { + return CallbackData{}, errors.New("invalid payload") + } + cmd, argsRaw := values[0], values[1] + var args []string + if argsRaw != "" { + args = strings.Split(argsRaw, ",") + } + return CallbackData{Command: cmd, Args: args}, nil +} +func encodeCompactBase64Payload(d CallbackData) (string, error) { + payload, _ := encodeCompactPayload(d) + return base64.RawURLEncoding.EncodeToString([]byte(payload)), nil +} +func decodeCompactBase64Payload(s string) (CallbackData, error) { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return CallbackData{}, err + } + return decodeCompactPayload(string(b)) +} + +func decodePayloadAs(payloadType BotPayloadType, s string) (CallbackData, error) { switch payloadType { case BotPayloadBase64: - data, err := decodeBase64Payload(s) - if err == nil { - return data, BotPayloadBase64, nil - } - if strict { - return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64) - } - data, err = decodeJSONPayload(s) - if err != nil { - return CallbackData{}, "", err - } - return data, BotPayloadJSON, nil + return decodeBase64Payload(s) case BotPayloadJSON: - data, err := decodeJSONPayload(s) - if err == nil { - return data, BotPayloadJSON, nil - } - if strict { - return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJSON) - } - data, err = decodeBase64Payload(s) - if err != nil { - return CallbackData{}, "", err - } - return data, BotPayloadBase64, nil + return decodeJSONPayload(s) + case BotPayloadCompact: + return decodeCompactPayload(s) + case BotPayloadCompactBase64: + return decodeCompactBase64Payload(s) } - return CallbackData{}, "", ErrInvalidPayloadType + return CallbackData{}, ErrInvalidPayloadType +} + +func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) { + knownTypes := []BotPayloadType{ + BotPayloadBase64, + BotPayloadJSON, + BotPayloadCompact, + BotPayloadCompactBase64, + } + if _, err := decodePayloadAs(payloadType, ""); errors.Is(err, ErrInvalidPayloadType) { + return CallbackData{}, "", ErrInvalidPayloadType + } + + data, err := decodePayloadAs(payloadType, s) + if err == nil { + return data, payloadType, nil + } + if strict { + return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, payloadType) + } + + for _, candidate := range knownTypes { + if candidate == payloadType { + continue + } + data, err = decodePayloadAs(candidate, s) + if err == nil { + return data, candidate, nil + } + } + return CallbackData{}, "", err } func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { diff --git a/keyboard.go b/keyboard.go index 85958c8..0027adb 100644 --- a/keyboard.go +++ b/keyboard.go @@ -91,12 +91,31 @@ func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...a return b } +// SetCallbackDataCompact sets a structured callback payload encoded as compact text. +func (b InlineKeyboardButtonBuilder) SetCallbackDataCompact(cmd string, args ...any) InlineKeyboardButtonBuilder { + b.data = NewCallbackData(cmd, args...).ToCompact() + return b +} + +// SetCallbackDataCompactBase64 sets a compact callback payload encoded as Base64. +func (b InlineKeyboardButtonBuilder) SetCallbackDataCompactBase64(cmd string, args ...any) InlineKeyboardButtonBuilder { + b.data = NewCallbackData(cmd, args...).ToCompactBase64() + return b +} + // SetCallbackData sets a structured callback payload using the configured payload type. // The default payload type is JSON. func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) InlineKeyboardButtonBuilder { - if b.payloadType == BotPayloadBase64 { + switch b.payloadType { + case BotPayloadJSON: + b.data = NewCallbackData(cmd, args...).ToJSON() + case BotPayloadBase64: b.data = NewCallbackData(cmd, args...).ToBase64() - } else { + case BotPayloadCompact: + b.data = NewCallbackData(cmd, args...).ToCompact() + case BotPayloadCompactBase64: + b.data = NewCallbackData(cmd, args...).ToCompactBase64() + default: b.data = NewCallbackData(cmd, args...).ToJSON() } return b @@ -143,6 +162,16 @@ func NewInlineKeyboardBase64(maxRow int) *InlineKeyboard { return NewInlineKeyboard(BotPayloadBase64, maxRow) } +// NewInlineKeyboardCompact creates a keyboard builder using compact callback payloads. +func NewInlineKeyboardCompact(maxRow int) *InlineKeyboard { + return NewInlineKeyboard(BotPayloadCompact, maxRow) +} + +// NewInlineKeyboardCompactBase64 creates a keyboard builder using Base64-encoded compact payloads. +func NewInlineKeyboardCompactBase64(maxRow int) *InlineKeyboard { + return NewInlineKeyboard(BotPayloadCompactBase64, maxRow) +} + // NewInlineKeyboard creates a new keyboard builder with the specified payload encoding // type and maximum number of buttons per row. // @@ -292,15 +321,33 @@ func (d CallbackData) ToJSON() string { // ToBase64 serializes the CallbackData to a JSON string and then encodes it as Base64. // Returns an empty string if serialization or encoding fails. func (d CallbackData) ToBase64() string { - s, err := encodeBase64Payload(d) + data, err := encodeBase64Payload(d) if err != nil { return `` } - return s + return data +} + +// ToCompact serializes the CallbackData to a compact delimited string. +func (d CallbackData) ToCompact() string { + data, err := encodeCompactPayload(d) + if err != nil { + return `` + } + return data +} + +// ToCompactBase64 serializes the CallbackData to compact text and then encodes it as Base64. +func (d CallbackData) ToCompactBase64() string { + data, err := encodeCompactBase64Payload(d) + if err != nil { + return `` + } + return data } // Encode serializes the CallbackData according to the specified payload type. -// Supported types: BotPayloadJSON and BotPayloadBase64. +// Supported types: BotPayloadJSON, BotPayloadBase64, BotPayloadCompact, and BotPayloadCompactBase64. // For unknown types, returns an empty string. func (d CallbackData) Encode(t BotPayloadType) string { switch t { @@ -308,6 +355,10 @@ func (d CallbackData) Encode(t BotPayloadType) string { return d.ToBase64() case BotPayloadJSON: return d.ToJSON() + case BotPayloadCompact: + return d.ToCompact() + case BotPayloadCompactBase64: + return d.ToCompactBase64() } return "" } diff --git a/keyboard_test.go b/keyboard_test.go index 03d495f..267642f 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -114,6 +114,52 @@ func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing. } } +func TestDecodePayloadAcceptsCompactKeyboardPayloadWhenBotPrefersJSON(t *testing.T) { + kb := NewInlineKeyboardCompact(1). + AddCallbackButton("A", "cmd", 1, "two") + + got, decodedType, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, false) + if err != nil { + t.Fatalf("decodePayload returned error: %v", err) + } + if decodedType != BotPayloadCompact { + t.Fatalf("unexpected decoded payload type: got %q want %q", decodedType, BotPayloadCompact) + } + + want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected payload: got %#v want %#v", got, want) + } +} + +func TestDecodePayloadAcceptsCompactBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.T) { + kb := NewInlineKeyboardCompactBase64(1). + AddCallbackButton("A", "cmd", 1, "two") + + got, decodedType, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, false) + if err != nil { + t.Fatalf("decodePayload returned error: %v", err) + } + if decodedType != BotPayloadCompactBase64 { + t.Fatalf("unexpected decoded payload type: got %q want %q", decodedType, BotPayloadCompactBase64) + } + + want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected payload: got %#v want %#v", got, want) + } +} + +func TestDecodePayloadStrictRejectsCompactMismatchedType(t *testing.T) { + kb := NewInlineKeyboardCompact(1). + AddCallbackButton("A", "cmd", 1) + + _, _, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, true) + if !errors.Is(err, ErrPayloadTypeMismatch) { + t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err) + } +} + func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) { kb := NewInlineKeyboardBase64(1). AddCallbackButton("A", "cmd", 1)