(new): support Bot API 10.3
Golang lint / lint (push) Failing after 1m37s

(fix): finalize v2 contracts
(tests): cover v2 migration
(doc): prepare release guidance
This commit is contained in:
2026-09-08 23:21:38 +03:00
parent 24040fe164
commit d78526242b
88 changed files with 2321 additions and 918 deletions
+82 -61
View File
@@ -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 {