(fix): finalize v2 contracts (tests): cover v2 migration (doc): prepare release guidance
This commit is contained in:
+82
-61
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/extypes"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/v2/tgapi"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -31,8 +31,9 @@ type InlineKeyboardButtonBuilder struct {
|
||||
emojiID string
|
||||
style tgapi.KeyboardButtonStyle
|
||||
|
||||
url string
|
||||
data string
|
||||
url string
|
||||
data string
|
||||
disabled bool
|
||||
|
||||
payloadType BotPayloadType
|
||||
}
|
||||
@@ -64,10 +65,21 @@ func (b InlineKeyboardButtonBuilder) SetURL(url string) InlineKeyboardButtonBuil
|
||||
b.url = url
|
||||
if url != "" {
|
||||
b.data = ""
|
||||
b.disabled = false
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetDisabled makes the button inert and clears URL and callback actions.
|
||||
//
|
||||
// Since: Bot API 10.3
|
||||
func (b InlineKeyboardButtonBuilder) SetDisabled() InlineKeyboardButtonBuilder {
|
||||
b.url = ""
|
||||
b.data = ""
|
||||
b.disabled = true
|
||||
return b
|
||||
}
|
||||
|
||||
// SetPayloadType sets the encoding used by SetCallbackData.
|
||||
func (b InlineKeyboardButtonBuilder) SetPayloadType(t BotPayloadType) InlineKeyboardButtonBuilder {
|
||||
b.payloadType = t
|
||||
@@ -83,6 +95,7 @@ func (b InlineKeyboardButtonBuilder) SetPayloadType(t BotPayloadType) InlineKeyb
|
||||
// Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}.
|
||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||
b.url = ""
|
||||
b.disabled = false
|
||||
b.data = NewCallbackData(cmd, args...).ToJSON()
|
||||
return b
|
||||
}
|
||||
@@ -91,6 +104,7 @@ func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any
|
||||
// Base64 does not bypass Telegram's 64-byte callback-data limit.
|
||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||
b.url = ""
|
||||
b.disabled = false
|
||||
b.data = NewCallbackData(cmd, args...).ToBase64()
|
||||
return b
|
||||
}
|
||||
@@ -98,6 +112,7 @@ func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...a
|
||||
// SetCallbackDataCompact sets a structured callback payload encoded as compact text.
|
||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataCompact(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||
b.url = ""
|
||||
b.disabled = false
|
||||
b.data = NewCallbackData(cmd, args...).ToCompact()
|
||||
return b
|
||||
}
|
||||
@@ -105,6 +120,7 @@ func (b InlineKeyboardButtonBuilder) SetCallbackDataCompact(cmd string, args ...
|
||||
// SetCallbackDataCompactBase64 sets a compact callback payload encoded as Base64.
|
||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataCompactBase64(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||
b.url = ""
|
||||
b.disabled = false
|
||||
b.data = NewCallbackData(cmd, args...).ToCompactBase64()
|
||||
return b
|
||||
}
|
||||
@@ -113,6 +129,7 @@ func (b InlineKeyboardButtonBuilder) SetCallbackDataCompactBase64(cmd string, ar
|
||||
// The default payload type is JSON.
|
||||
func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||
b.url = ""
|
||||
b.disabled = false
|
||||
switch b.payloadType {
|
||||
case BotPayloadJSON:
|
||||
b.data = NewCallbackData(cmd, args...).ToJSON()
|
||||
@@ -128,27 +145,32 @@ func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) In
|
||||
return b
|
||||
}
|
||||
|
||||
func (b InlineKeyboardButtonBuilder) validate() error { return validateButton(b.build()) }
|
||||
|
||||
func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton {
|
||||
var disabled *tgapi.DisabledButton
|
||||
if b.disabled {
|
||||
disabled = &tgapi.DisabledButton{}
|
||||
}
|
||||
return tgapi.InlineKeyboardButton{
|
||||
Text: b.text,
|
||||
URL: b.url,
|
||||
Style: b.style,
|
||||
IconCustomEmojiID: b.emojiID,
|
||||
CallbackData: b.data,
|
||||
Disabled: disabled,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks that the button has exactly one action and valid callback data.
|
||||
func (b InlineKeyboardButtonBuilder) Validate() error {
|
||||
return validateInlineKeyboardButton(b.build())
|
||||
}
|
||||
func (b InlineKeyboardButtonBuilder) Validate() error { return b.validate() }
|
||||
|
||||
// Build validates and returns the configured inline keyboard button.
|
||||
func (b InlineKeyboardButtonBuilder) Build() (tgapi.InlineKeyboardButton, error) {
|
||||
button := b.build()
|
||||
if err := validateInlineKeyboardButton(button); err != nil {
|
||||
if err := b.validate(); err != nil {
|
||||
return tgapi.InlineKeyboardButton{}, err
|
||||
}
|
||||
button := b.build()
|
||||
return button, nil
|
||||
}
|
||||
|
||||
@@ -162,13 +184,14 @@ type InlineKeyboard struct {
|
||||
// CurrentLine is the row currently being built.
|
||||
CurrentLine extypes.Slice[tgapi.InlineKeyboardButton]
|
||||
// Lines contains completed keyboard rows.
|
||||
Lines [][]tgapi.InlineKeyboardButton
|
||||
maxRow int // Max buttons per row (e.g., 3 or 4)
|
||||
Lines [][]tgapi.InlineKeyboardButton
|
||||
maxRow int // Max buttons per row (e.g., 3 or 4)
|
||||
unlimitedRows bool
|
||||
|
||||
payloadType BotPayloadType // Serialization format for callback data (JSON or Base64)
|
||||
}
|
||||
|
||||
// NewInlineKeyboardJSON creates a new keyboard builder with the specified maximum
|
||||
// NewInlineKeyboardJSON creates a keyboard builder with a positive maximum
|
||||
// number of buttons per row.
|
||||
//
|
||||
// Example: NewInlineKeyboardJSON(3) creates a keyboard with at most 3 buttons per line.
|
||||
@@ -176,7 +199,7 @@ func NewInlineKeyboardJSON(maxRow int) *InlineKeyboard {
|
||||
return NewInlineKeyboard(BotPayloadJSON, maxRow)
|
||||
}
|
||||
|
||||
// NewInlineKeyboardBase64 creates a new keyboard builder with the specified maximum
|
||||
// NewInlineKeyboardBase64 creates a keyboard builder with a positive maximum
|
||||
// number of buttons per row, using Base64 encoding for button payloads.
|
||||
//
|
||||
// Example: NewInlineKeyboardBase64(3) creates a keyboard with at most 3 buttons per line.
|
||||
@@ -194,8 +217,8 @@ 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.
|
||||
// NewInlineKeyboard creates a keyboard builder with the specified payload encoding
|
||||
// and a positive maximum number of buttons per row.
|
||||
//
|
||||
// Use NewInlineKeyboardJSON or NewInlineKeyboardBase64 for the common cases.
|
||||
func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard {
|
||||
@@ -218,19 +241,27 @@ func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard {
|
||||
// GetPayloadType returns the keyboard-local callback payload encoding type.
|
||||
func (in *InlineKeyboard) GetPayloadType() BotPayloadType { return in.payloadType }
|
||||
|
||||
// SetMaxRow sets the maximum number of buttons appended to a row before the
|
||||
// keyboard automatically starts a new line. Values <= 0 retain the legacy
|
||||
// unlimited-row behavior; this convention is subject to change in v2.
|
||||
// SetMaxRow sets the positive number of buttons appended to a row before the
|
||||
// keyboard automatically starts a new line. Get returns ErrInlineKeyboardMaxRow
|
||||
// when maxRow is not positive.
|
||||
func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard {
|
||||
in.maxRow = maxRow
|
||||
in.unlimitedRows = false
|
||||
return in
|
||||
}
|
||||
|
||||
// GetMaxRow returns the maximum number of buttons per row.
|
||||
// SetUnlimitedRows disables automatic row wrapping explicitly.
|
||||
func (in *InlineKeyboard) SetUnlimitedRows() *InlineKeyboard {
|
||||
in.maxRow = 0
|
||||
in.unlimitedRows = true
|
||||
return in
|
||||
}
|
||||
|
||||
// GetMaxRow returns the maximum number of buttons per row, or zero in explicit unlimited mode.
|
||||
func (in *InlineKeyboard) GetMaxRow() int { return in.maxRow }
|
||||
|
||||
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
|
||||
if in.maxRow > 0 && in.CurrentLine.Len() >= in.maxRow {
|
||||
if !in.unlimitedRows && in.maxRow > 0 && in.CurrentLine.Len() >= in.maxRow {
|
||||
in.AddLine()
|
||||
}
|
||||
in.CurrentLine = in.CurrentLine.Push(button)
|
||||
@@ -254,7 +285,7 @@ func (in *InlineKeyboard) AddURLButtonStyle(text string, style tgapi.KeyboardBut
|
||||
func (in *InlineKeyboard) AddCallbackButton(text, cmd string, args ...any) *InlineKeyboard {
|
||||
return in.append(tgapi.InlineKeyboardButton{
|
||||
Text: text,
|
||||
CallbackData: NewCallbackData(cmd, args...).Encode(in.payloadType),
|
||||
CallbackData: NewCallbackData(cmd, args...).encode(in.payloadType),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -264,7 +295,7 @@ func (in *InlineKeyboard) AddCallbackButtonStyle(text string, style tgapi.Keyboa
|
||||
return in.append(tgapi.InlineKeyboardButton{
|
||||
Text: text,
|
||||
Style: style,
|
||||
CallbackData: NewCallbackData(cmd, args...).Encode(in.payloadType),
|
||||
CallbackData: NewCallbackData(cmd, args...).encode(in.payloadType),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -289,7 +320,7 @@ func (in *InlineKeyboard) AddLine() *InlineKeyboard {
|
||||
// Automatically flushes the current line if not empty.
|
||||
//
|
||||
// Returns a pointer to a ReplyMarkup suitable for use with tgapi.SendMessage.
|
||||
func (in *InlineKeyboard) Get() *tgapi.ReplyMarkup {
|
||||
func (in *InlineKeyboard) Get() (*tgapi.ReplyMarkup, error) {
|
||||
if in.CurrentLine.Len() > 0 {
|
||||
in.AddLine()
|
||||
}
|
||||
@@ -297,38 +328,26 @@ func (in *InlineKeyboard) Get() *tgapi.ReplyMarkup {
|
||||
for i := range in.Lines {
|
||||
lines[i] = append([]tgapi.InlineKeyboardButton(nil), in.Lines[i]...)
|
||||
}
|
||||
return &tgapi.ReplyMarkup{InlineKeyboard: lines}
|
||||
}
|
||||
|
||||
// GetValidated finalizes and validates the keyboard before returning it.
|
||||
//
|
||||
// Existing fluent Add* methods remain error-free for v1 compatibility. Their
|
||||
// signatures are subject to change in v2; new code should use GetValidated.
|
||||
func (in *InlineKeyboard) GetValidated() (*tgapi.ReplyMarkup, error) {
|
||||
markup := in.Get()
|
||||
if err := in.validateMarkup(markup); err != nil {
|
||||
markup := &tgapi.ReplyMarkup{InlineKeyboard: lines}
|
||||
if err := in.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return markup, nil
|
||||
}
|
||||
|
||||
// Validate checks completed and pending rows without finalizing the keyboard.
|
||||
func (in *InlineKeyboard) Validate() error {
|
||||
lines := make([][]tgapi.InlineKeyboardButton, 0, len(in.Lines)+1)
|
||||
lines = append(lines, in.Lines...)
|
||||
if len(in.CurrentLine) > 0 {
|
||||
lines = append(lines, in.CurrentLine)
|
||||
func (in *InlineKeyboard) validate() error {
|
||||
if !in.unlimitedRows && in.maxRow <= 0 {
|
||||
return fmt.Errorf("%w: got %d", ErrInlineKeyboardMaxRow, in.maxRow)
|
||||
}
|
||||
return in.validateMarkup(&tgapi.ReplyMarkup{InlineKeyboard: lines})
|
||||
}
|
||||
|
||||
func (in *InlineKeyboard) validateMarkup(markup *tgapi.ReplyMarkup) error {
|
||||
for rowIndex, row := range markup.InlineKeyboard {
|
||||
if in.maxRow > 0 && len(row) > in.maxRow {
|
||||
return fmt.Errorf("%w: row %d has %d buttons, limit %d", ErrInlineKeyboardRowTooLong, rowIndex, len(row), in.maxRow)
|
||||
for rowIndex, row := range in.Lines {
|
||||
if !in.unlimitedRows && len(row) > in.maxRow {
|
||||
return fmt.Errorf(
|
||||
"%w: row %d has %d buttons, limit %d",
|
||||
ErrInlineKeyboardRowTooLong, rowIndex, len(row), in.maxRow,
|
||||
)
|
||||
}
|
||||
for columnIndex, button := range row {
|
||||
if err := validateInlineKeyboardButton(button); err != nil {
|
||||
if err := validateButton(button); err != nil {
|
||||
return fmt.Errorf("row %d button %d: %w", rowIndex, columnIndex, err)
|
||||
}
|
||||
}
|
||||
@@ -336,19 +355,22 @@ func (in *InlineKeyboard) validateMarkup(markup *tgapi.ReplyMarkup) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInlineKeyboardButton(button tgapi.InlineKeyboardButton) error {
|
||||
func validateButton(b tgapi.InlineKeyboardButton) error {
|
||||
actions := 0
|
||||
if button.URL != "" {
|
||||
if b.URL != "" {
|
||||
actions++
|
||||
}
|
||||
if button.CallbackData != "" {
|
||||
if b.CallbackData != "" {
|
||||
actions++
|
||||
}
|
||||
if b.Disabled != nil {
|
||||
actions++
|
||||
}
|
||||
if actions != 1 {
|
||||
return ErrInlineKeyboardButtonAction
|
||||
}
|
||||
if button.CallbackData != "" {
|
||||
length := len([]byte(button.CallbackData))
|
||||
if b.CallbackData != "" {
|
||||
length := len([]byte(b.CallbackData))
|
||||
if length < 1 || length > 64 {
|
||||
return fmt.Errorf("%w: got %d", ErrCallbackDataLength, length)
|
||||
}
|
||||
@@ -436,10 +458,7 @@ func (d CallbackData) ToCompactBase64() string {
|
||||
return data
|
||||
}
|
||||
|
||||
// Encode serializes the CallbackData according to the specified payload type.
|
||||
// Supported types: BotPayloadJSON, BotPayloadBase64, BotPayloadCompact, and BotPayloadCompactBase64.
|
||||
// For unknown types, returns an empty string.
|
||||
func (d CallbackData) Encode(t BotPayloadType) string {
|
||||
func (d CallbackData) encode(t BotPayloadType) string {
|
||||
switch t {
|
||||
case BotPayloadBase64:
|
||||
return d.ToBase64()
|
||||
@@ -453,13 +472,15 @@ func (d CallbackData) Encode(t BotPayloadType) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// EncodeValidated serializes callback data and enforces Telegram's 1-64 byte limit.
|
||||
func (d CallbackData) EncodeValidated(t BotPayloadType) (string, error) {
|
||||
encoded := d.Encode(t)
|
||||
// Encode serializes the CallbackData according to the specified payload type.
|
||||
// Supported types: BotPayloadJSON, BotPayloadBase64, BotPayloadCompact, and BotPayloadCompactBase64.
|
||||
// For unknown types, returns an empty string.
|
||||
func (d CallbackData) Encode(t BotPayloadType) (string, error) {
|
||||
if t != BotPayloadBase64 && t != BotPayloadJSON && t != BotPayloadCompact && t != BotPayloadCompactBase64 {
|
||||
return "", ErrInvalidPayloadType
|
||||
}
|
||||
encoded := d.encode(t)
|
||||
if encoded == "" {
|
||||
if t != BotPayloadBase64 && t != BotPayloadJSON && t != BotPayloadCompact && t != BotPayloadCompactBase64 {
|
||||
return "", ErrInvalidPayloadType
|
||||
}
|
||||
return "", ErrCallbackDataLength
|
||||
}
|
||||
if length := len([]byte(encoded)); length > 64 {
|
||||
|
||||
Reference in New Issue
Block a user