From f29ef979bf6152fee3d9179e7020968594c20e5c Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 12 Feb 2026 13:48:47 +0300 Subject: [PATCH 01/99] bump extypes --- go.mod | 2 +- go.sum | 12 ++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 40bdc50..d6912d1 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.nix13.pw/scuroneko/laniakea go 1.25 require ( - git.nix13.pw/scuroneko/extypes v1.1.0 + git.nix13.pw/scuroneko/extypes v1.2.0 git.nix13.pw/scuroneko/slog v1.0.2 github.com/redis/go-redis/v9 v9.17.3 github.com/vinovest/sqlx v1.7.1 diff --git a/go.sum b/go.sum index a552309..8340bd9 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -git.nix13.pw/scuroneko/extypes v1.1.0 h1:kdAraybAqQgVhArVkVfrIi7KVEX8HgTr8mzbIZAAAqg= -git.nix13.pw/scuroneko/extypes v1.1.0/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= +git.nix13.pw/scuroneko/extypes v1.2.0 h1:2n2hD6KsMAted+6MGhAyeWyli2Qzc9G2y+pQNB7C1dM= +git.nix13.pw/scuroneko/extypes v1.2.0/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -32,8 +32,6 @@ github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwp github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/muir/list v1.2.1 h1:lmF8fz2B1WbXkzHr/Eh0oWPJArDBzWqIifOwbA4gWSo= github.com/muir/list v1.2.1/go.mod h1:v0l2f997MxCohQlD7PTejJqyYKwFVz/i3mTpDl4LAf0= -github.com/muir/sqltoken v0.2.1 h1:19KvJrCj9aOMfU921hjnizWPlQmPTe+tb36zupOY2FA= -github.com/muir/sqltoken v0.2.1/go.mod h1:sSlj5M0VqQ4OuedmxwWs1TmzzRXaH3DLf5ukzg6meIo= github.com/muir/sqltoken v0.3.0 h1:3xbcqr80f3IA4OlwkOpdIHC4DTu6gsi1TwMqgYL4Dpg= github.com/muir/sqltoken v0.3.0/go.mod h1:+OSmbGI22QcVZ6DCzlHT8EAzEq/mqtqedtPP91Le+3A= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -57,8 +55,6 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -75,8 +71,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -85,8 +79,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -- 2.54.0 From 4129b8e6885cecb86fdddf0e10d6a9d7d560a4fd Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 12 Feb 2026 13:50:02 +0300 Subject: [PATCH 02/99] some cleanup --- bot.go | 57 ++++---- msg_context.go | 3 +- tgapi/api.go | 26 ++-- tgapi/attachments_methods.go | 18 +-- tgapi/bot_methods.go | 36 ++--- tgapi/business_methods.go | 46 +++---- tgapi/chat_methods.go | 64 ++++----- tgapi/forum_methods.go | 26 ++-- tgapi/messages_methods.go | 54 ++++---- tgapi/methods.go | 10 +- tgapi/stickers_methods.go | 30 ++-- tgapi/uploader_api.go | 150 ++++++++++++++++++++ tgapi/{uploader.go => uploader_methods.go} | 151 --------------------- tgapi/users_methods.go | 8 +- tgapi/utils.go | 15 -- utils/utils.go | 68 ---------- 16 files changed, 334 insertions(+), 428 deletions(-) create mode 100644 tgapi/uploader_api.go rename tgapi/{uploader.go => uploader_methods.go} (72%) delete mode 100644 tgapi/utils.go diff --git a/bot.go b/bot.go index d3cc5e9..3f1088b 100644 --- a/bot.go +++ b/bot.go @@ -16,34 +16,6 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo" ) -type Bot struct { - token string - debug bool - errorTemplate string - - logger *slog.Logger - RequestLogger *slog.Logger - - plugins []Plugin - middlewares []Middleware - prefixes []string - runners []Runner - - dbContext *DatabaseContext - api *tgapi.Api - - dbWriterRequested extypes.Slice[*slog.Logger] - - updateOffset int - updateTypes []tgapi.UpdateType - updateQueue *extypes.Queue[*tgapi.Update] -} - -func (b *Bot) GetUpdateOffset() int { return b.updateOffset } -func (b *Bot) SetUpdateOffset(offset int) { b.updateOffset = offset } -func (b *Bot) GetUpdateTypes() []tgapi.UpdateType { return b.updateTypes } -func (b *Bot) GetQueue() *extypes.Queue[*tgapi.Update] { return b.updateQueue } - type BotSettings struct { Token string Debug bool @@ -74,6 +46,30 @@ func LoadPrefixesFromEnv() []string { } return strings.Split(prefixesS, ";") } + +type Bot struct { + token string + debug bool + errorTemplate string + + logger *slog.Logger + RequestLogger *slog.Logger + + plugins []Plugin + middlewares []Middleware + prefixes []string + runners []Runner + + dbContext *DatabaseContext + api *tgapi.API + + dbWriterRequested extypes.Slice[*slog.Logger] + + updateOffset int + updateTypes []tgapi.UpdateType + updateQueue *extypes.Queue[*tgapi.Update] +} + func NewBot(settings *BotSettings) *Bot { updateQueue := extypes.CreateQueue[*tgapi.Update](256) api := tgapi.NewAPI(settings.Token) @@ -141,6 +137,11 @@ func (b *Bot) Close() { } } +func (b *Bot) GetUpdateOffset() int { return b.updateOffset } +func (b *Bot) SetUpdateOffset(offset int) { b.updateOffset = offset } +func (b *Bot) GetUpdateTypes() []tgapi.UpdateType { return b.updateTypes } +func (b *Bot) GetQueue() *extypes.Queue[*tgapi.Update] { return b.updateQueue } + type DatabaseContext struct { PostgresSQL *sqlx.DB MongoDB *mongo.Client diff --git a/msg_context.go b/msg_context.go index d19274f..8ff7396 100644 --- a/msg_context.go +++ b/msg_context.go @@ -9,7 +9,7 @@ import ( type MsgContext struct { Bot *Bot - Api *tgapi.Api + Api *tgapi.API Msg *tgapi.Message Update tgapi.Update @@ -26,7 +26,6 @@ type AnswerMessage struct { MessageID int Text string IsMedia bool - Keyboard *InlineKeyboard ctx *MsgContext } diff --git a/tgapi/api.go b/tgapi/api.go index 4cda187..8e7f2c9 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -7,26 +7,25 @@ import ( "fmt" "io" "net/http" - "strings" "time" "git.nix13.pw/scuroneko/laniakea/utils" "git.nix13.pw/scuroneko/slog" ) -type Api struct { +type API struct { token string client *http.Client Logger *slog.Logger } -func NewAPI(token string) *Api { +func NewAPI(token string) *API { l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API") l.AddWriter(l.CreateJsonStdoutWriter()) client := &http.Client{Timeout: time.Second * 45} - return &Api{token, client, l} + return &API{token, client, l} } -func (api *Api) CloseApi() error { +func (api *API) CloseApi() error { return api.Logger.Close() } @@ -45,7 +44,7 @@ type TelegramRequest[R, P any] struct { func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { return TelegramRequest[R, P]{method: method, params: params} } -func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *Api) (R, error) { +func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, error) { var zero R data, err := json.Marshal(r.params) if err != nil { @@ -54,12 +53,6 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *Api) (R, buf := bytes.NewBuffer(data) u := fmt.Sprintf("https://api.telegram.org/bot%s/%s", api.token, r.method) - if api.Logger != nil { - api.Logger.Debugln(strings.ReplaceAll(fmt.Sprintf( - "POST %s %s", u, buf.String(), - ), api.token, "")) - } - req, err := http.NewRequestWithContext(ctx, "POST", u, buf) if err != nil { return zero, err @@ -68,6 +61,7 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *Api) (R, req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) + api.Logger.Debugln("REQ", r.method, buf.String()) res, err := api.client.Do(req) if err != nil { return zero, err @@ -82,24 +76,20 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *Api) (R, if err != nil { return zero, err } - - if api.Logger != nil { - api.Logger.Debugln(fmt.Sprintf("RES %s %s", r.method, string(data))) - } + api.Logger.Debugln("RES", r.method, string(data)) var resp ApiResponse[R] err = json.Unmarshal(data, &resp) if err != nil { return zero, err } - if !resp.Ok { return zero, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description) } return resp.Result, nil } -func (r TelegramRequest[R, P]) Do(api *Api) (R, error) { +func (r TelegramRequest[R, P]) Do(api *API) (R, error) { ctx := context.Background() return r.DoWithContext(ctx, api) } diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 2e38771..7545e33 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -23,7 +23,7 @@ type SendPhotoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendPhoto(params SendPhotoP) (Message, error) { +func (api *API) SendPhoto(params SendPhotoP) (Message, error) { req := NewRequest[Message]("sendPhoto", params) return req.Do(api) } @@ -52,7 +52,7 @@ type SendAudioP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendAudio(params SendAudioP) (Message, error) { +func (api *API) SendAudio(params SendAudioP) (Message, error) { req := NewRequest[Message]("sendAudio", params) return req.Do(api) } @@ -78,7 +78,7 @@ type SendDocumentP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendDocument(params SendDocumentP) (Message, error) { +func (api *API) SendDocument(params SendDocumentP) (Message, error) { req := NewRequest[Message]("sendDocument", params) return req.Do(api) } @@ -113,7 +113,7 @@ type SendVideoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendVideo(params SendVideoP) (Message, error) { +func (api *API) SendVideo(params SendVideoP) (Message, error) { req := NewRequest[Message]("sendVideo", params) return req.Do(api) } @@ -144,7 +144,7 @@ type SendAnimationP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendAnimation(params SendAnimationP) (Message, error) { +func (api *API) SendAnimation(params SendAnimationP) (Message, error) { req := NewRequest[Message]("sendAnimation", params) return req.Do(api) } @@ -170,7 +170,7 @@ type SendVoiceP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendVoice(params *SendVoiceP) (Message, error) { +func (api *API) SendVoice(params *SendVoiceP) (Message, error) { req := NewRequest[Message]("sendVoice", params) return req.Do(api) } @@ -194,7 +194,7 @@ type SendVideoNoteP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendVideoNote(params SendVideoNoteP) (Message, error) { +func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) { req := NewRequest[Message]("sendVideoNote", params) return req.Do(api) } @@ -221,7 +221,7 @@ type SendPaidMediaP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendPaidMedia(params SendPaidMediaP) (Message, error) { +func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) { req := NewRequest[Message]("sendPaidMedia", params) return req.Do(api) } @@ -240,7 +240,7 @@ type SendMediaGroupP struct { ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` } -func (api *Api) SendMediaGroup(params SendMediaGroupP) (Message, error) { +func (api *API) SendMediaGroup(params SendMediaGroupP) (Message, error) { req := NewRequest[Message]("sendMediaGroup", params) return req.Do(api) } diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index 05741ae..2487f5e 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -6,7 +6,7 @@ type SetMyCommandsP struct { Language string `json:"language_code,omitempty"` } -func (api *Api) SetMyCommands(params SetMyCommandsP) (bool, error) { +func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) { req := NewRequest[bool]("setMyCommands", params) return req.Do(api) } @@ -16,7 +16,7 @@ type DeleteMyCommandsP struct { Language string `json:"language_code,omitempty"` } -func (api *Api) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) { +func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) { req := NewRequest[bool]("deleteMyCommands", params) return req.Do(api) } @@ -26,7 +26,7 @@ type GetMyCommands struct { Language string `json:"language_code,omitempty"` } -func (api *Api) GetMyCommands(params GetMyCommands) ([]BotCommand, error) { +func (api *API) GetMyCommands(params GetMyCommands) ([]BotCommand, error) { req := NewRequest[[]BotCommand]("getMyCommands", params) return req.Do(api) } @@ -36,7 +36,7 @@ type SetMyName struct { Language string `json:"language_code,omitempty"` } -func (api *Api) SetMyName(params SetMyName) (bool, error) { +func (api *API) SetMyName(params SetMyName) (bool, error) { req := NewRequest[bool]("setMyName", params) return req.Do(api) } @@ -45,7 +45,7 @@ type GetMyName struct { Language string `json:"language_code,omitempty"` } -func (api *Api) GetMyName(params GetMyName) (BotName, error) { +func (api *API) GetMyName(params GetMyName) (BotName, error) { req := NewRequest[BotName]("getMyName", params) return req.Do(api) } @@ -55,7 +55,7 @@ type SetMyDescription struct { Language string `json:"language_code,omitempty"` } -func (api *Api) SetMyDescription(params SetMyDescription) (bool, error) { +func (api *API) SetMyDescription(params SetMyDescription) (bool, error) { req := NewRequest[bool]("setMyDescription", params) return req.Do(api) } @@ -64,7 +64,7 @@ type GetMyDescription struct { Language string `json:"language_code,omitempty"` } -func (api *Api) GetMyDescription(params GetMyDescription) (BotDescription, error) { +func (api *API) GetMyDescription(params GetMyDescription) (BotDescription, error) { req := NewRequest[BotDescription]("getMyDescription", params) return req.Do(api) } @@ -74,7 +74,7 @@ type SetMyShortDescription struct { Language string `json:"language_code,omitempty"` } -func (api *Api) SetMyShortDescription(params SetMyShortDescription) (bool, error) { +func (api *API) SetMyShortDescription(params SetMyShortDescription) (bool, error) { req := NewRequest[bool]("setMyShortDescription", params) return req.Do(api) } @@ -83,7 +83,7 @@ type GetMyShortDescription struct { Language string `json:"language_code,omitempty"` } -func (api *Api) GetMyShortDescription(params GetMyShortDescription) (BotShortDescription, error) { +func (api *API) GetMyShortDescription(params GetMyShortDescription) (BotShortDescription, error) { req := NewRequest[BotShortDescription]("getMyShortDescription", params) return req.Do(api) } @@ -92,11 +92,11 @@ type SetMyProfilePhotoP struct { Photo InputProfilePhoto `json:"photo"` } -func (api *Api) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) { +func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) { req := NewRequest[bool]("setMyProfilePhoto", params) return req.Do(api) } -func (api *Api) RemoveMyProfilePhoto() (bool, error) { +func (api *API) RemoveMyProfilePhoto() (bool, error) { req := NewRequest[bool]("removeMyProfilePhoto", NoParams) return req.Do(api) } @@ -106,7 +106,7 @@ type SetChatMenuButtonP struct { MenuButton MenuButtonType `json:"menu_button"` } -func (api *Api) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) { +func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) { req := NewRequest[bool]("setChatMenuButton", params) return req.Do(api) } @@ -115,7 +115,7 @@ type GetChatMenuButtonP struct { ChatID int `json:"chat_id"` } -func (api *Api) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) { +func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) { req := NewRequest[BaseMenuButton]("getChatMenuButton", params) return req.Do(api) } @@ -125,7 +125,7 @@ type SetMyDefaultAdministratorRightsP struct { ForChannels bool `json:"for_channels"` } -func (api *Api) SetMyDefaultAdministratorRights(params SetMyDefaultAdministratorRightsP) (bool, error) { +func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministratorRightsP) (bool, error) { req := NewRequest[bool]("setMyDefaultAdministratorRights", params) return req.Do(api) } @@ -134,12 +134,12 @@ type GetMyDefaultAdministratorRightsP struct { ForChannels bool `json:"for_channels"` } -func (api *Api) GetMyDefaultAdministratorRights(params GetMyDefaultAdministratorRightsP) (ChatAdministratorRights, error) { +func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministratorRightsP) (ChatAdministratorRights, error) { req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params) return req.Do(api) } -func (api *Api) GetAvailableGifts() (Gifts, error) { +func (api *API) GetAvailableGifts() (Gifts, error) { req := NewRequest[Gifts]("getAvailableGifts", NoParams) return req.Do(api) } @@ -154,7 +154,7 @@ type SendGiftP struct { TextEntities []MessageEntity `json:"text_entities,omitempty"` } -func (api *Api) SendGift(params SendGiftP) (bool, error) { +func (api *API) SendGift(params SendGiftP) (bool, error) { req := NewRequest[bool]("sendGift", params) return req.Do(api) } @@ -168,7 +168,7 @@ type GiftPremiumSubscriptionP struct { TextEntities []MessageEntity `json:"text_entities,omitempty"` } -func (api *Api) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool, error) { +func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool, error) { req := NewRequest[bool]("giftPremiumSubscription", params) return req.Do(api) } diff --git a/tgapi/business_methods.go b/tgapi/business_methods.go index ff6792c..e06138b 100644 --- a/tgapi/business_methods.go +++ b/tgapi/business_methods.go @@ -5,7 +5,7 @@ type VerifyUserP struct { CustomDescription string `json:"custom_description,omitempty"` } -func (api *Api) VerifyUser(params VerifyUserP) (bool, error) { +func (api *API) VerifyUser(params VerifyUserP) (bool, error) { req := NewRequest[bool]("verifyUser", params) return req.Do(api) } @@ -15,7 +15,7 @@ type VerifyChatP struct { CustomDescription string `json:"custom_description,omitempty"` } -func (api *Api) VerifyChat(params VerifyChatP) (bool, error) { +func (api *API) VerifyChat(params VerifyChatP) (bool, error) { req := NewRequest[bool]("verifyChat", params) return req.Do(api) } @@ -24,7 +24,7 @@ type RemoveUserVerificationP struct { UserID int `json:"user_id"` } -func (api *Api) RemoveUserVerification(params RemoveUserVerificationP) (bool, error) { +func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, error) { req := NewRequest[bool]("removeUserVerification", params) return req.Do(api) } @@ -33,7 +33,7 @@ type RemoveChatVerificationP struct { ChatID int `json:"chat_id"` } -func (api *Api) RemoveChatVerification(params RemoveChatVerificationP) (bool, error) { +func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, error) { req := NewRequest[bool]("removeChatVerification", params) return req.Do(api) } @@ -44,7 +44,7 @@ type ReadBusinessMessageP struct { MessageID int `json:"message_id"` } -func (api *Api) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) { +func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) { req := NewRequest[bool]("readBusinessMessage", params) return req.Do(api) } @@ -54,7 +54,7 @@ type DeleteBusinessMessageP struct { MessageIDs []int `json:"message_ids"` } -func (api *Api) DeleteBusinessMessage(params DeleteBusinessMessageP) (bool, error) { +func (api *API) DeleteBusinessMessage(params DeleteBusinessMessageP) (bool, error) { req := NewRequest[bool]("deleteBusinessMessage", params) return req.Do(api) } @@ -65,7 +65,7 @@ type SetBusinessAccountNameP struct { LastName string `json:"last_name,omitempty"` } -func (api *Api) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, error) { +func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, error) { req := NewRequest[bool]("setBusinessAccountName", params) return req.Do(api) } @@ -75,7 +75,7 @@ type SetBusinessAccountUsernameP struct { Username string `json:"username,omitempty"` } -func (api *Api) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) (bool, error) { +func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) (bool, error) { req := NewRequest[bool]("setBusinessAccountUsername", params) return req.Do(api) } @@ -85,7 +85,7 @@ type SetBusinessAccountBioP struct { Bio string `json:"bio,omitempty"` } -func (api *Api) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, error) { +func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, error) { req := NewRequest[bool]("setBusinessAccountBio", params) return req.Do(api) } @@ -96,7 +96,7 @@ type SetBusinessAccountProfilePhoto struct { IsPublic bool `json:"is_public,omitempty"` } -func (api *Api) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfilePhoto) (bool, error) { +func (api *API) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfilePhoto) (bool, error) { req := NewRequest[bool]("setBusinessAccountProfilePhoto", params) return req.Do(api) } @@ -106,7 +106,7 @@ type RemoveBusinessAccountProfilePhotoP struct { IsPublic bool `json:"is_public,omitempty"` } -func (api *Api) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountProfilePhotoP) (bool, error) { +func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountProfilePhotoP) (bool, error) { req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params) return req.Do(api) } @@ -117,7 +117,7 @@ type SetBusinessAccountGiftSettingsP struct { AcceptedGiftTypes AcceptedGiftTypes `json:"accepted_gift_types"` } -func (api *Api) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSettingsP) (bool, error) { +func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSettingsP) (bool, error) { req := NewRequest[bool]("setBusinessAccountGiftSettings", params) return req.Do(api) } @@ -126,7 +126,7 @@ type GetBusinessAccountStarBalanceP struct { BusinessConnectionID string `json:"business_connection_id"` } -func (api *Api) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalanceP) (StarAmount, error) { +func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalanceP) (StarAmount, error) { req := NewRequest[StarAmount]("getBusinessAccountGiftSettings", params) return req.Do(api) } @@ -136,7 +136,7 @@ type TransferBusinessAccountStartP struct { StarCount int `json:"star_count"` } -func (api *Api) TransferBusinessAccountStart(params TransferBusinessAccountStartP) (bool, error) { +func (api *API) TransferBusinessAccountStart(params TransferBusinessAccountStartP) (bool, error) { req := NewRequest[bool]("transferBusinessAccountStart", params) return req.Do(api) } @@ -155,7 +155,7 @@ type GetBusinessAccountGiftsP struct { Limit int `json:"limit,omitempty"` } -func (api *Api) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedGifts, error) { +func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params) return req.Do(api) } @@ -165,7 +165,7 @@ type ConvertGiftToStarsP struct { OwnedGiftID string `json:"owned_gift_id"` } -func (api *Api) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) { +func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) { req := NewRequest[bool]("convertGiftToStars", params) return req.Do(api) } @@ -177,7 +177,7 @@ type UpgradeGiftP struct { StarCount int `json:"star_count,omitempty"` } -func (api *Api) UpgradeGift(params UpgradeGiftP) (bool, error) { +func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) { req := NewRequest[bool]("upgradeGift", params) return req.Do(api) } @@ -189,7 +189,7 @@ type TransferGiftP struct { StarCount int `json:"star_count,omitempty"` } -func (api *Api) TransferGift(params TransferGiftP) (bool, error) { +func (api *API) TransferGift(params TransferGiftP) (bool, error) { req := NewRequest[bool]("transferGift", params) return req.Do(api) } @@ -208,11 +208,11 @@ type PostStoryP struct { ProtectContent bool `json:"protect_content,omitempty"` } -func (api *Api) PostStoryPhoto(params PostStoryP) (Story, error) { +func (api *API) PostStoryPhoto(params PostStoryP) (Story, error) { req := NewRequest[Story]("postStory", params) return req.Do(api) } -func (api *Api) PostStoryVideo(params PostStoryP) (Story, error) { +func (api *API) PostStoryVideo(params PostStoryP) (Story, error) { req := NewRequest[Story]("postStory", params) return req.Do(api) } @@ -226,7 +226,7 @@ type RepostStoryP struct { ProtectContent bool `json:"protect_content,omitempty"` } -func (api *Api) RepostStory(params RepostStoryP) (Story, error) { +func (api *API) RepostStory(params RepostStoryP) (Story, error) { req := NewRequest[Story]("repostStory", params) return req.Do(api) } @@ -242,7 +242,7 @@ type EditStoryP struct { Areas []StoryArea `json:"areas,omitempty"` } -func (api *Api) EditStory(params EditStoryP) (Story, error) { +func (api *API) EditStory(params EditStoryP) (Story, error) { req := NewRequest[Story]("editStory", params) return req.Do(api) } @@ -252,7 +252,7 @@ type DeleteStoryP struct { StoryID int `json:"story_id"` } -func (api *Api) DeleteStory(params DeleteStoryP) (bool, error) { +func (api *API) DeleteStory(params DeleteStoryP) (bool, error) { req := NewRequest[bool]("deleteStory", params) return req.Do(api) } diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index f75bee5..fc48a72 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -7,7 +7,7 @@ type BanChatMemberP struct { RevokeMessages bool `json:"revoke_messages,omitempty"` } -func (api *Api) BanChatMember(params BanChatMemberP) (bool, error) { +func (api *API) BanChatMember(params BanChatMemberP) (bool, error) { req := NewRequest[bool]("banChatMember", params) return req.Do(api) } @@ -18,7 +18,7 @@ type UnbanChatMemberP struct { OnlyIfBanned bool `json:"only_if_banned"` } -func (api *Api) UnbanChatMember(params UnbanChatMemberP) (bool, error) { +func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) { req := NewRequest[bool]("unbanChatMember", params) return req.Do(api) } @@ -31,7 +31,7 @@ type RestrictChatMemberP struct { UntilDate int `json:"until_date,omitempty"` } -func (api *Api) RestrictChatMember(params RestrictChatMemberP) (bool, error) { +func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) { req := NewRequest[bool]("restrictChatMember", params) return req.Do(api) } @@ -58,7 +58,7 @@ type PromoteChatMember struct { CanManageDirectMessages bool `json:"can_manage_direct_messages,omitempty"` } -func (api *Api) PromoteChatMember(params PromoteChatMember) (bool, error) { +func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) { req := NewRequest[bool]("promoteChatMember", params) return req.Do(api) } @@ -69,7 +69,7 @@ type SetChatAdministratorCustomTitleP struct { CustomTitle string `json:"custom_title"` } -func (api *Api) SetChatAdministratorCustomTitle(params SetChatAdministratorCustomTitleP) (bool, error) { +func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCustomTitleP) (bool, error) { req := NewRequest[bool]("setChatAdministratorCustomTitle", params) return req.Do(api) } @@ -79,7 +79,7 @@ type BanChatSenderChatP struct { SenderChatID int `json:"sender_chat_id"` } -func (api *Api) BanChatSenderChat(params BanChatSenderChatP) (bool, error) { +func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) { req := NewRequest[bool]("banChatSenderChat", params) return req.Do(api) } @@ -89,7 +89,7 @@ type UnbanChatSenderChatP struct { SenderChatID int `json:"sender_chat_id"` } -func (api *Api) UnbanChatSenderChat(params BanChatSenderChatP) (bool, error) { +func (api *API) UnbanChatSenderChat(params BanChatSenderChatP) (bool, error) { req := NewRequest[bool]("unbanChatSenderChat", params) return req.Do(api) } @@ -100,7 +100,7 @@ type SetChatPermissionsP struct { UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"` } -func (api *Api) SetChatPermissions(params SetChatPermissionsP) (bool, error) { +func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) { req := NewRequest[bool]("setChatPermissions", params) return req.Do(api) } @@ -109,7 +109,7 @@ type ExportChatInviteLinkP struct { ChatID int `json:"chat_id"` } -func (api *Api) ExportChatInviteLink(params ExportChatInviteLinkP) (string, error) { +func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, error) { req := NewRequest[string]("exportChatInviteLink", params) return req.Do(api) } @@ -122,7 +122,7 @@ type CreateChatInviteLinkP struct { CreatesJoinRequest int `json:"creates_join_request,omitempty"` } -func (api *Api) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLink, error) { +func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLink, error) { req := NewRequest[ChatInviteLink]("createChatInviteLink", params) return req.Do(api) } @@ -137,7 +137,7 @@ type EditChatInviteLinkP struct { CreatesJoinRequest int `json:"creates_join_request,omitempty"` } -func (api *Api) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink, error) { +func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink, error) { req := NewRequest[ChatInviteLink]("editChatInviteLink", params) return req.Do(api) } @@ -149,7 +149,7 @@ type CreateChatSubscriptionInviteLinkP struct { SubscriptionPrice int `json:"subscription_price,omitempty"` } -func (api *Api) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) { +func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) { req := NewRequest[ChatInviteLink]("createChatSubscriptionInviteLink", params) return req.Do(api) } @@ -160,7 +160,7 @@ type EditChatSubscriptionInviteLinkP struct { Name string `json:"name,omitempty"` } -func (api *Api) EditChatSubscriptionInviteLink(params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) { +func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) { req := NewRequest[ChatInviteLink]("editChatSubscriptionInviteLink", params) return req.Do(api) } @@ -170,7 +170,7 @@ type RevokeChatInviteLinkP struct { InviteLink string `json:"invite_link"` } -func (api *Api) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLink, error) { +func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLink, error) { req := NewRequest[ChatInviteLink]("revokeChatInviteLink", params) return req.Do(api) } @@ -180,7 +180,7 @@ type ApproveChatJoinRequestP struct { UserID int `json:"user_id"` } -func (api *Api) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, error) { +func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, error) { req := NewRequest[bool]("approveChatJoinRequest", params) return req.Do(api) } @@ -190,12 +190,12 @@ type DeclineChatJoinRequestP struct { UserID int `json:"user_id"` } -func (api *Api) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, error) { +func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, error) { req := NewRequest[bool]("declineChatJoinRequest", params) return req.Do(api) } -func (api *Api) SetChatPhoto() { +func (api *API) SetChatPhoto() { uploader := NewUploader(api) defer uploader.Close() } @@ -204,7 +204,7 @@ type DeleteChatPhotoP struct { ChatID int `json:"chat_id"` } -func (api *Api) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) { +func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) { req := NewRequest[bool]("deleteChatPhoto", params) return req.Do(api) } @@ -214,7 +214,7 @@ type SetChatTitleP struct { Title string `json:"title"` } -func (api *Api) SetChatTitle(params SetChatTitleP) (bool, error) { +func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) { req := NewRequest[bool]("setChatTitle", params) return req.Do(api) } @@ -224,7 +224,7 @@ type SetChatDescriptionP struct { Description string `json:"description"` } -func (api *Api) SetChatDescription(params SetChatDescriptionP) (bool, error) { +func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) { req := NewRequest[bool]("setChatDescription", params) return req.Do(api) } @@ -236,7 +236,7 @@ type PinChatMessageP struct { DisableNotification bool `json:"disable_notification,omitempty"` } -func (api *Api) PinChatMessage(params PinChatMessageP) (bool, error) { +func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) { req := NewRequest[bool]("pinChatMessage", params) return req.Do(api) } @@ -247,7 +247,7 @@ type UnpinChatMessageP struct { MessageID int `json:"message_id"` } -func (api *Api) UnpinChatMessage(params UnpinChatMessageP) (bool, error) { +func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) { req := NewRequest[bool]("unpinChatMessage", params) return req.Do(api) } @@ -256,7 +256,7 @@ type UnpinAllChatMessagesP struct { ChatID int `json:"chat_id"` } -func (api *Api) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error) { +func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error) { req := NewRequest[bool]("unpinAllChatMessages", params) return req.Do(api) } @@ -265,7 +265,7 @@ type LeaveChatP struct { ChatID int `json:"chat_id"` } -func (api *Api) LeaveChat(params LeaveChatP) (bool, error) { +func (api *API) LeaveChat(params LeaveChatP) (bool, error) { req := NewRequest[bool]("leaveChatP", params) return req.Do(api) } @@ -274,7 +274,7 @@ type GetChatP struct { ChatID int `json:"chat_id"` } -func (api *Api) GetChatP(params GetChatP) (ChatFullInfo, error) { +func (api *API) GetChatP(params GetChatP) (ChatFullInfo, error) { req := NewRequest[ChatFullInfo]("getChatP", params) return req.Do(api) } @@ -283,7 +283,7 @@ type GetChatAdministratorsP struct { ChatID int `json:"chat_id"` } -func (api *Api) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMember, error) { +func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMember, error) { req := NewRequest[[]ChatMember]("getChatAdministrators", params) return req.Do(api) } @@ -292,7 +292,7 @@ type GetChatMembersCountP struct { ChatID int `json:"chat_id"` } -func (api *Api) GetChatMemberCount(params GetChatMembersCountP) (int, error) { +func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) { req := NewRequest[int]("getChatMemberCount", params) return req.Do(api) } @@ -302,7 +302,7 @@ type GetChatMemberP struct { UserID int `json:"user_id"` } -func (api *Api) GetChatMember(params GetChatMemberP) (ChatMember, error) { +func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) { req := NewRequest[ChatMember]("getChatMember", params) return req.Do(api) } @@ -312,7 +312,7 @@ type SetChatStickerSetP struct { StickerSetName string `json:"sticker_set_name"` } -func (api *Api) SetChatStickerSet(params SetChatStickerSetP) (bool, error) { +func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) { req := NewRequest[bool]("setChatStickerSet", params) return req.Do(api) } @@ -321,7 +321,7 @@ type DeleteChatStickerSetP struct { ChatID int `json:"chat_id"` } -func (api *Api) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) { +func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) { req := NewRequest[bool]("deleteChatStickerSet", params) return req.Do(api) } @@ -331,7 +331,7 @@ type GetUserChatBoostsP struct { UserID int `json:"user_id"` } -func (api *Api) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, error) { +func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, error) { req := NewRequest[UserChatBoosts]("getUserChatBoosts", params) return req.Do(api) } @@ -350,7 +350,7 @@ type GetChatGiftsP struct { Limit int `json:"limit,omitempty"` } -func (api *Api) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) { +func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getChatGifts", params) return req.Do(api) } diff --git a/tgapi/forum_methods.go b/tgapi/forum_methods.go index df2da3a..e098983 100644 --- a/tgapi/forum_methods.go +++ b/tgapi/forum_methods.go @@ -5,7 +5,7 @@ type BaseForumTopicP struct { MessageThreadID int `json:"message_thread_id"` } -func (api *Api) GetForumTopicIconSet() ([]Sticker, error) { +func (api *API) GetForumTopicIconSet() ([]Sticker, error) { req := NewRequest[[]Sticker]("getForumTopicIconSet", NoParams) return req.Do(api) } @@ -17,7 +17,7 @@ type CreateForumTopicP struct { IconCustomEmojiID string `json:"icon_custom_emoji_id"` } -func (api *Api) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) { +func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) { req := NewRequest[ForumTopic]("createForumTopic", params) return req.Do(api) } @@ -28,24 +28,24 @@ type EditForumTopicP struct { IconCustomEmojiID string `json:"icon_custom_emoji_id"` } -func (api *Api) EditForumTopic(params EditForumTopicP) (bool, error) { +func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) { req := NewRequest[bool]("editForumTopic", params) return req.Do(api) } -func (api *Api) CloseForumTopic(params BaseForumTopicP) (bool, error) { +func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) { req := NewRequest[bool]("closeForumTopic", params) return req.Do(api) } -func (api *Api) ReopenForumTopic(params BaseForumTopicP) (bool, error) { +func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) { req := NewRequest[bool]("reopenForumTopic", params) return req.Do(api) } -func (api *Api) DeleteForumTopic(params BaseForumTopicP) (bool, error) { +func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) { req := NewRequest[bool]("deleteForumTopic", params) return req.Do(api) } -func (api *Api) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error) { +func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error) { req := NewRequest[bool]("unpinAllForumTopicMessages", params) return req.Do(api) } @@ -59,28 +59,28 @@ type EditGeneralForumTopicP struct { Name string `json:"name"` } -func (api *Api) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, error) { +func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, error) { req := NewRequest[bool]("editGeneralForumTopic", params) return req.Do(api) } -func (api *Api) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { req := NewRequest[bool]("closeGeneralForumTopic", params) return req.Do(api) } -func (api *Api) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { req := NewRequest[bool]("reopenGeneralForumTopic", params) return req.Do(api) } -func (api *Api) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { req := NewRequest[bool]("hideGeneralForumTopic", params) return req.Do(api) } -func (api *Api) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { req := NewRequest[bool]("unhideGeneralForumTopic", params) return req.Do(api) } -func (api *Api) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP) (bool, error) { req := NewRequest[bool]("unpinAllGeneralForumTopicMessages", params) return req.Do(api) } diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index 09fc098..c17495e 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -20,7 +20,7 @@ type SendMessageP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendMessage(params SendMessageP) (Message, error) { +func (api *API) SendMessage(params SendMessageP) (Message, error) { req := NewRequest[Message, SendMessageP]("sendMessage", params) return req.Do(api) } @@ -40,7 +40,7 @@ type ForwardMessageP struct { SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` } -func (api *Api) ForwardMessage(params ForwardMessageP) (Message, error) { +func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) { req := NewRequest[Message]("forwardMessage", params) return req.Do(api) } @@ -56,7 +56,7 @@ type ForwardMessagesP struct { ProtectContent bool `json:"protect_content,omitempty"` } -func (api *Api) ForwardMessages(params ForwardMessagesP) ([]int, error) { +func (api *API) ForwardMessages(params ForwardMessagesP) ([]int, error) { req := NewRequest[[]int]("forwardMessages", params) return req.Do(api) } @@ -84,7 +84,7 @@ type CopyMessageP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) CopyMessage(params CopyMessageP) (int, error) { +func (api *API) CopyMessage(params CopyMessageP) (int, error) { req := NewRequest[int]("copyMessage", params) return req.Do(api) } @@ -101,7 +101,7 @@ type CopyMessagesP struct { RemoveCaption bool `json:"remove_caption,omitempty"` } -func (api *Api) CopyMessages(params CopyMessagesP) ([]int, error) { +func (api *API) CopyMessages(params CopyMessagesP) ([]int, error) { req := NewRequest[[]int]("copyMessages", params) return req.Do(api) } @@ -129,7 +129,7 @@ type SendLocationP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendLocation(params SendLocationP) (Message, error) { +func (api *API) SendLocation(params SendLocationP) (Message, error) { req := NewRequest[Message]("sendLocation", params) return req.Do(api) } @@ -159,7 +159,7 @@ type SendVenueP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendVenue(params SendVenueP) (Message, error) { +func (api *API) SendVenue(params SendVenueP) (Message, error) { req := NewRequest[Message]("sendVenue", params) return req.Do(api) } @@ -185,7 +185,7 @@ type SendContactP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendContact(params SendContactP) (Message, error) { +func (api *API) SendContact(params SendContactP) (Message, error) { req := NewRequest[Message]("sendContact", params) return req.Do(api) } @@ -219,7 +219,7 @@ type SendPollP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendPoll(params SendPollP) (Message, error) { +func (api *API) SendPoll(params SendPollP) (Message, error) { req := NewRequest[Message]("sendPoll", params) return req.Do(api) } @@ -237,7 +237,7 @@ type SendChecklistP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendChecklist(params SendChecklistP) (Message, error) { +func (api *API) SendChecklist(params SendChecklistP) (Message, error) { req := NewRequest[Message]("sendChecklist", params) return req.Do(api) } @@ -260,7 +260,7 @@ type SendDiceP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -func (api *Api) SendDice(params SendDiceP) (Message, error) { +func (api *API) SendDice(params SendDiceP) (Message, error) { req := NewRequest[Message]("sendDice", params) return req.Do(api) } @@ -274,7 +274,7 @@ type SendMessageDraftP struct { Entities []MessageEntity `json:"entities,omitempty"` } -func (api *Api) SendMessageDraft(params SendMessageDraftP) (bool, error) { +func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) { req := NewRequest[bool]("sendMessageDraft", params) return req.Do(api) } @@ -286,7 +286,7 @@ type SendChatActionP struct { Action ChatActionType `json:"action"` } -func (api *Api) SendChatAction(params SendChatActionP) (bool, error) { +func (api *API) SendChatAction(params SendChatActionP) (bool, error) { req := NewRequest[bool]("sendChatAction", params) return req.Do(api) } @@ -298,7 +298,7 @@ type SetMessageReactionP struct { IsBig bool `json:"is_big,omitempty"` } -func (api *Api) SetMessageReaction(params SetMessageReactionP) (bool, error) { +func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) { req := NewRequest[bool]("setMessageReaction", params) return req.Do(api) } @@ -317,7 +317,7 @@ type EditMessageTextP struct { // EditMessageText If inline message, first return will be zero-valued, and second will boolean // Otherwise, first return will be Message, and second false -func (api *Api) EditMessageText(params EditMessageTextP) (Message, bool, error) { +func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequest[bool]("editMessageText", params) @@ -341,7 +341,7 @@ type EditMessageCaptionP struct { // EditMessageCaption If inline message, first return will be zero-valued, and second will boolean // Otherwise, first return will be Message, and second false -func (api *Api) EditMessageCaption(params EditMessageCaptionP) (Message, bool, error) { +func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequest[bool]("editMessageCaption", params) @@ -364,7 +364,7 @@ type EditMessageMediaP struct { // EditMessageMedia If inline message, first return will be zero-valued, and second will boolean // Otherwise, first return will be Message, and second false -func (api *Api) EditMessageMedia(params EditMessageMediaP) (Message, bool, error) { +func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequest[bool]("editMessageMedia", params) @@ -393,7 +393,7 @@ type EditMessageLiveLocationP struct { // EditMessageLiveLocation If inline message, first return will be zero-valued, and second will boolean // Otherwise, first return will be Message, and second false -func (api *Api) EditMessageLiveLocation(params EditMessageLiveLocationP) (Message, bool, error) { +func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequest[bool]("editMessageLiveLocation", params) @@ -415,7 +415,7 @@ type StopMessageLiveLocationP struct { // StopMessageLiveLocation If inline message, first return will be zero-valued, and second will boolean // Otherwise, first return will be Message, and second false -func (api *Api) StopMessageLiveLocation(params StopMessageLiveLocationP) (Message, bool, error) { +func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequest[bool]("stopMessageLiveLocation", params) @@ -435,7 +435,7 @@ type EditMessageChecklistP struct { ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } -func (api *Api) EditMessageChecklist(params EditMessageChecklistP) (Message, error) { +func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, error) { req := NewRequest[Message]("editMessageChecklist", params) return req.Do(api) } @@ -448,7 +448,7 @@ type EditMessageReplyMarkupP struct { ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } -func (api *Api) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message, bool, error) { +func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequest[bool]("editMessageReplyMarkup", params) @@ -467,7 +467,7 @@ type StopPollP struct { InlineMessageID string `json:"inline_message_id,omitempty"` } -func (api *Api) StopPoll(params StopPollP) (Poll, error) { +func (api *API) StopPoll(params StopPollP) (Poll, error) { req := NewRequest[Poll]("stopPoll", params) return req.Do(api) } @@ -478,7 +478,7 @@ type ApproveSuggestedPostP struct { SendDate int `json:"send_date,omitempty"` } -func (api *Api) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error) { +func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error) { req := NewRequest[bool]("approveSuggestedPost", params) return req.Do(api) } @@ -489,7 +489,7 @@ type DeclineSuggestedPostP struct { Comment string `json:"comment,omitempty"` } -func (api *Api) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error) { +func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error) { req := NewRequest[bool]("declineSuggestedPost", params) return req.Do(api) } @@ -499,7 +499,7 @@ type DeleteMessageP struct { MessageID int `json:"message_id"` } -func (api *Api) DeleteMessage(params DeleteMessageP) (bool, error) { +func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) { req := NewRequest[bool]("deleteMessage", params) return req.Do(api) } @@ -509,7 +509,7 @@ type DeleteMessagesP struct { MessageIDs []int `json:"message_ids"` } -func (api *Api) DeleteMessages(params DeleteMessagesP) (bool, error) { +func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) { req := NewRequest[bool]("deleteMessages", params) return req.Do(api) } @@ -522,7 +522,7 @@ type AnswerCallbackQueryP struct { CacheTime int `json:"cache_time,omitempty"` } -func (api *Api) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) { +func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) { req := NewRequest[bool]("answerCallbackQuery", params) return req.Do(api) } diff --git a/tgapi/methods.go b/tgapi/methods.go index 9c20e88..bf0635f 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -19,19 +19,19 @@ type UpdateParams struct { AllowedUpdates []UpdateType `json:"allowed_updates"` } -func (api *Api) GetMe() (User, error) { +func (api *API) GetMe() (User, error) { req := NewRequest[User, EmptyParams]("getMe", NoParams) return req.Do(api) } -func (api *Api) LogOut() (bool, error) { +func (api *API) LogOut() (bool, error) { req := NewRequest[bool, EmptyParams]("logOut", NoParams) return req.Do(api) } -func (api *Api) Close() (bool, error) { +func (api *API) Close() (bool, error) { req := NewRequest[bool, EmptyParams]("close", NoParams) return req.Do(api) } -func (api *Api) GetUpdates(params UpdateParams) ([]Update, error) { +func (api *API) GetUpdates(params UpdateParams) ([]Update, error) { req := NewRequest[[]Update]("getUpdates", params) return req.Do(api) } @@ -40,7 +40,7 @@ type GetFileP struct { FileId string `json:"file_id"` } -func (api *Api) GetFile(params GetFileP) (File, error) { +func (api *API) GetFile(params GetFileP) (File, error) { req := NewRequest[File]("getFile", params) return req.Do(api) } diff --git a/tgapi/stickers_methods.go b/tgapi/stickers_methods.go index 99feeb8..abf6390 100644 --- a/tgapi/stickers_methods.go +++ b/tgapi/stickers_methods.go @@ -14,7 +14,7 @@ type SendStickerP struct { MessageEffectID string `json:"message_effect_id,omitempty"` } -func (api *Api) SendSticker(params SendStickerP) (Message, error) { +func (api *API) SendSticker(params SendStickerP) (Message, error) { req := NewRequest[Message]("sendSticker", params) return req.Do(api) } @@ -23,7 +23,7 @@ type GetStickerSetP struct { Name string `json:"name"` } -func (api *Api) GetStickerSet(params GetStickerSetP) (StickerSet, error) { +func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) { req := NewRequest[StickerSet]("getStickerSet", params) return req.Do(api) } @@ -32,7 +32,7 @@ type GetCustomEmojiStickersP struct { CustomEmojiIDs []string `json:"custom_emoji_ids"` } -func (api *Api) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticker, error) { +func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticker, error) { req := NewRequest[[]Sticker]("getCustomEmojiStickers", params) return req.Do(api) } @@ -47,7 +47,7 @@ type CreateNewStickerSetP struct { NeedsRepainting bool `json:"needs_repainting,omitempty"` } -func (api *Api) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) { +func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) { req := NewRequest[bool]("createNewStickerSet", params) return req.Do(api) } @@ -58,7 +58,7 @@ type AddStickerToSetP struct { Sticker InputSticker `json:"sticker"` } -func (api *Api) AddStickerToSet(params AddStickerToSetP) (bool, error) { +func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) { req := NewRequest[bool]("addStickerToSet", params) return req.Do(api) } @@ -68,7 +68,7 @@ type SetStickerPositionInSetP struct { Position int `json:"position"` } -func (api *Api) SetStickerPosition(params SetStickerPositionInSetP) (bool, error) { +func (api *API) SetStickerPosition(params SetStickerPositionInSetP) (bool, error) { req := NewRequest[bool]("setStickerPosition", params) return req.Do(api) } @@ -77,7 +77,7 @@ type DeleteStickerFromSetP struct { Sticker string `json:"sticker"` } -func (api *Api) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error) { +func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error) { req := NewRequest[bool]("deleteStickerFromSet", params) return req.Do(api) } @@ -89,7 +89,7 @@ type ReplaceStickerInSetP struct { Sticker InputSticker `json:"sticker"` } -func (api *Api) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) { +func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) { req := NewRequest[bool]("replaceStickerInSet", params) return req.Do(api) } @@ -99,7 +99,7 @@ type SetStickerEmojiListP struct { EmojiList []string `json:"emoji_list"` } -func (api *Api) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) { +func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) { req := NewRequest[bool]("setStickerEmojiList", params) return req.Do(api) } @@ -109,7 +109,7 @@ type SetStickerKeywordsP struct { Keywords []string `json:"keywords"` } -func (api *Api) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) { +func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) { req := NewRequest[bool]("setStickerKeywords", params) return req.Do(api) } @@ -119,7 +119,7 @@ type SetStickerMaskPositionP struct { MaskPosition *MaskPosition `json:"mask_position,omitempty"` } -func (api *Api) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, error) { +func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, error) { req := NewRequest[bool]("setStickerMaskPosition", params) return req.Do(api) } @@ -129,7 +129,7 @@ type SetStickerSetTitleP struct { Title string `json:"title"` } -func (api *Api) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) { +func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) { req := NewRequest[bool]("setStickerSetTitle", params) return req.Do(api) } @@ -141,7 +141,7 @@ type SetStickerSetThumbnailP struct { Format InputStickerFormat `json:"format"` } -func (api *Api) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) { +func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) { req := NewRequest[bool]("setStickerSetThumbnail", params) return req.Do(api) } @@ -151,7 +151,7 @@ type SetCustomEmojiStickerSetThumbnailP struct { CustomEmojiID string `json:"custom_emoji_id,omitempty"` } -func (api *Api) SetCustomEmojiStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) { +func (api *API) SetCustomEmojiStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) { req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params) return req.Do(api) } @@ -160,7 +160,7 @@ type DeleteStickerSetP struct { Name string `json:"name"` } -func (api *Api) DeleteStickerSet(params DeleteStickerSetP) (bool, error) { +func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) { req := NewRequest[bool]("deleteStickerSet", params) return req.Do(api) } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go new file mode 100644 index 0000000..d1aa164 --- /dev/null +++ b/tgapi/uploader_api.go @@ -0,0 +1,150 @@ +package tgapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "path/filepath" + + "git.nix13.pw/scuroneko/laniakea/utils" + "git.nix13.pw/scuroneko/slog" +) + +const ( + UploaderPhotoType UploaderFileType = "photo" + UploaderVideoType UploaderFileType = "video" + UploaderAudioType UploaderFileType = "audio" + UploaderDocumentType UploaderFileType = "document" + UploaderVoiceType UploaderFileType = "voice" + UploaderVideoNoteType UploaderFileType = "video_note" + UploaderThumbnailType UploaderFileType = "thumbnail" +) + +type UploaderFileType string +type UploaderFile struct { + filename string + data []byte + field UploaderFileType +} + +func NewUploaderFile(name string, data []byte) UploaderFile { + t := uploaderTypeByExt(name) + return UploaderFile{filename: name, data: data, field: t} +} + +// SetType used when auto-detect failed. +// i.e. you sending a voice message, but it detects as audio, or if you send audio with thumbnail +func (f UploaderFile) SetType(t UploaderFileType) UploaderFile { + f.field = t + return f +} + +type Uploader struct { + api *API + logger *slog.Logger +} + +func NewUploader(api *API) *Uploader { + logger := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("UPLOADER") + logger.AddWriter(logger.CreateJsonStdoutWriter()) + return &Uploader{api, logger} +} +func (u *Uploader) Close() error { return u.logger.Close() } + +type UploaderRequest[R, P any] struct { + method string + files []UploaderFile + params P +} + +func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { + return UploaderRequest[R, P]{method, files, params} +} +func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { + var zero R + url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", up.api.token, u.method) + + buf := bytes.NewBuffer(nil) + w := multipart.NewWriter(buf) + + for _, file := range u.files { + fw, err := w.CreateFormFile(string(file.field), file.filename) + if err != nil { + _ = w.Close() + return zero, err + } + _, err = fw.Write(file.data) + if err != nil { + _ = w.Close() + return zero, err + } + } + + err := utils.Encode(w, u.params) + if err != nil { + _ = w.Close() + return zero, err + } + err = w.Close() + if err != nil { + return zero, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, buf) + if err != nil { + return zero, err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) + + up.logger.Debugln("UPLOADER REQ", u.method) + res, err := up.api.client.Do(req) + if err != nil { + return zero, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return zero, fmt.Errorf("unexpected status code: %d", res.StatusCode) + } + + reader := io.LimitReader(res.Body, 10<<20) + body, err := io.ReadAll(reader) + if err != nil { + return zero, err + } + up.logger.Debugln("UPLOADER RES", u.method, string(body)) + + var resp ApiResponse[R] + err = json.Unmarshal(body, &resp) + if err != nil { + return zero, err + } + if !resp.Ok { + return zero, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description) + } + return resp.Result, nil +} +func (u UploaderRequest[R, P]) Do(up *Uploader) (R, error) { + return u.DoWithContext(context.Background(), up) +} + +func uploaderTypeByExt(filename string) UploaderFileType { + ext := filepath.Ext(filename) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp", ".bmp": + return UploaderPhotoType + case ".mp4": + return UploaderVideoType + case ".mp3", ".m4a": + return UploaderAudioType + case ".ogg": + return UploaderVoiceType + default: + return UploaderDocumentType + } +} diff --git a/tgapi/uploader.go b/tgapi/uploader_methods.go similarity index 72% rename from tgapi/uploader.go rename to tgapi/uploader_methods.go index 7062503..835faf4 100644 --- a/tgapi/uploader.go +++ b/tgapi/uploader_methods.go @@ -1,139 +1,5 @@ package tgapi -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "path/filepath" - - "git.nix13.pw/scuroneko/laniakea/utils" - "git.nix13.pw/scuroneko/slog" -) - -type Uploader struct { - api *Api - logger *slog.Logger -} - -func NewUploader(api *Api) *Uploader { - logger := slog.CreateLogger().Level(GetLoggerLevel()).Prefix("UPLOADER") - logger.AddWriter(logger.CreateJsonStdoutWriter()) - return &Uploader{api, logger} -} -func (u *Uploader) Close() error { - return u.logger.Close() -} - -type UploaderFileType string - -const ( - UploaderPhotoType UploaderFileType = "photo" - UploaderVideoType UploaderFileType = "video" - UploaderAudioType UploaderFileType = "audio" - UploaderDocumentType UploaderFileType = "document" - UploaderVoiceType UploaderFileType = "voice" - UploaderVideoNoteType UploaderFileType = "video_note" - UploaderThumbnailType UploaderFileType = "thumbnail" -) - -type UploaderFile struct { - filename string - data []byte - field UploaderFileType -} - -func NewUploaderFile(name string, data []byte) UploaderFile { - t := uploaderTypeByExt(name) - return UploaderFile{filename: name, data: data, field: t} -} - -// SetType used when auto-detect failed. I.e. you sending a voice message, but it detects as audio, or if you send audio with thumbnail -func (f UploaderFile) SetType(t UploaderFileType) UploaderFile { - f.field = t - return f -} - -type UploaderRequest[R, P any] struct { - method string - files []UploaderFile - params P -} - -func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { - return UploaderRequest[R, P]{method, files, params} -} -func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { - var zero R - url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", up.api.token, u.method) - - buf := bytes.NewBuffer(nil) - w := multipart.NewWriter(buf) - - for _, file := range u.files { - fw, err := w.CreateFormFile(string(file.field), file.filename) - if err != nil { - w.Close() - return zero, err - } - _, err = fw.Write(file.data) - if err != nil { - w.Close() - return zero, err - } - } - - err := utils.Encode(w, u.params) - if err != nil { - w.Close() - return zero, err - } - err = w.Close() - if err != nil { - return zero, err - } - - req, err := http.NewRequestWithContext(ctx, "POST", url, buf) - if err != nil { - return zero, err - } - req.Header.Set("Content-Type", w.FormDataContentType()) - req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) - - up.logger.Debugln("UPLOADER REQ", u.method) - res, err := up.api.client.Do(req) - if err != nil { - return zero, err - } - defer res.Body.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - return zero, err - } - up.logger.Debugln("UPLOADER RES", u.method, string(body)) - if res.StatusCode != http.StatusOK { - return zero, fmt.Errorf("[%d] %s", res.StatusCode, string(body)) - } - - var response ApiResponse[R] - err = json.Unmarshal(body, &response) - if err != nil { - return zero, err - } - if !response.Ok { - return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) - } - return response.Result, nil -} -func (u UploaderRequest[R, P]) Do(up *Uploader) (R, error) { - ctx := context.Background() - return u.DoWithContext(ctx, up) -} - type UploadPhotoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int `json:"chat_id"` @@ -330,7 +196,6 @@ func (u *Uploader) UploadVideoNote(params UploadVideoNoteP, files ...UploaderFil return req.Do(u) } -// setChatPhoto https://core.telegram.org/bots/api#setchatphoto type UploadChatPhotoP struct { ChatID int `json:"chat_id"` } @@ -339,19 +204,3 @@ func (u *Uploader) UploadChatPhoto(params UploadChatPhotoP, photo UploaderFile) req := NewUploaderRequest[Message]("sendChatPhoto", params, photo) return req.Do(u) } - -func uploaderTypeByExt(filename string) UploaderFileType { - ext := filepath.Ext(filename) - switch ext { - case ".jpg", ".jpeg", ".png", ".webp", ".bmp": - return UploaderPhotoType - case ".mp4": - return UploaderVideoType - case ".mp3", ".m4a": - return UploaderAudioType - case ".ogg": - return UploaderVoiceType - default: - return UploaderDocumentType - } -} diff --git a/tgapi/users_methods.go b/tgapi/users_methods.go index d254140..a2cad10 100644 --- a/tgapi/users_methods.go +++ b/tgapi/users_methods.go @@ -6,7 +6,7 @@ type GetUserProfilePhotosP struct { Limit int `json:"limit,omitempty"` } -func (api *Api) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfilePhotos, error) { +func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfilePhotos, error) { req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params) return req.Do(api) } @@ -17,7 +17,7 @@ type GetUserProfileAudiosP struct { Limit int `json:"limit,omitempty"` } -func (api *Api) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileAudios, error) { +func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileAudios, error) { req := NewRequest[UserProfileAudios]("getUserProfileAudios", params) return req.Do(api) } @@ -28,7 +28,7 @@ type SetUserEmojiStatusP struct { ExpirationDate int `json:"emoji_status_expiration_date,omitempty"` } -func (api *Api) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) { +func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) { req := NewRequest[bool]("setUserEmojiStatus", params) return req.Do(api) } @@ -45,7 +45,7 @@ type GetUserGiftsP struct { Limit int `json:"limit,omitempty"` } -func (api *Api) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) { +func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getUserGifts", params) return req.Do(api) } diff --git a/tgapi/utils.go b/tgapi/utils.go deleted file mode 100644 index a982ea3..0000000 --- a/tgapi/utils.go +++ /dev/null @@ -1,15 +0,0 @@ -package tgapi - -import ( - "os" - - "git.nix13.pw/scuroneko/slog" -) - -func GetLoggerLevel() slog.LogLevel { - level := slog.FATAL - if os.Getenv("DEBUG") == "true" { - level = slog.DEBUG - } - return level -} diff --git a/utils/utils.go b/utils/utils.go index c782c58..9d0cbac 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,7 +1,6 @@ package utils import ( - "encoding/json" "fmt" "os" "strings" @@ -17,73 +16,6 @@ func GetLoggerLevel() slog.LogLevel { return level } -func Cast[A, B any](src A) (*B, error) { - m, err := StructToMap(src) - if err != nil { - return nil, err - } - - out := new(B) - err = MapToStruct(m, out) - if err != nil { - return nil, err - } - return out, nil -} - -// MapToStruct unsafe function -func MapToStruct(m map[string]any, s any) error { - data, err := json.Marshal(m) - if err != nil { - return err - } - err = json.Unmarshal(data, s) - return err -} - -// SliceToStruct unsafe function -func SliceToStruct(sl []any, s any) error { - data, err := json.Marshal(sl) - if err != nil { - return err - } - err = json.Unmarshal(data, s) - return err -} - -// AnyToStruct unsafe function -func AnyToStruct(src, dest any) error { - data, err := json.Marshal(src) - if err != nil { - return err - } - err = json.Unmarshal(data, dest) - return err -} - -func MapToJson(m map[string]any) (string, error) { - data, err := json.Marshal(m) - return string(data), err -} - -func StructToMap(s any) (map[string]any, error) { - data, err := json.Marshal(s) - if err != nil { - return nil, err - } - m := make(map[string]any) - err = json.Unmarshal(data, &m) - return m, err -} - -func Map[T, V any](ts []T, fn func(T) V) []V { - result := make([]V, len(ts)) - for i, t := range ts { - result[i] = fn(t) - } - return result -} - func EscapeMarkdown(s string) string { s = strings.ReplaceAll(s, "_", `\_`) s = strings.ReplaceAll(s, "*", `\*`) -- 2.54.0 From 4527dd661a6d92ae7ae0e642fc6644bd2f149001 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 12 Feb 2026 13:59:35 +0300 Subject: [PATCH 03/99] small change --- bot.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/bot.go b/bot.go index 3f1088b..352c0d2 100644 --- a/bot.go +++ b/bot.go @@ -2,7 +2,6 @@ package laniakea import ( "fmt" - "log" "os" "sort" "strings" @@ -126,15 +125,13 @@ func NewBot(settings *BotSettings) *Bot { return bot } -func (b *Bot) Close() { +func (b *Bot) Close() error { err := b.logger.Close() if err != nil { - log.Println(err) + return err } err = b.RequestLogger.Close() - if err != nil { - log.Println(err) - } + return err } func (b *Bot) GetUpdateOffset() int { return b.updateOffset } -- 2.54.0 From bb51a0ecb1c4682c573ec1d0424af0de54e927e7 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 17 Feb 2026 22:44:23 +0300 Subject: [PATCH 04/99] l10n and cmd generator WIP --- bot.go | 8 +-- cmd_generator.go | 26 ++++++++ handler.go | 30 ++++++++++ plugins.go | 152 +++++++++++++++++++++++++++-------------------- 4 files changed, 149 insertions(+), 67 deletions(-) create mode 100644 cmd_generator.go diff --git a/bot.go b/bot.go index 352c0d2..b16b8fe 100644 --- a/bot.go +++ b/bot.go @@ -192,16 +192,16 @@ func (b *Bot) AddPlugins(plugin ...Plugin) *Bot { func (b *Bot) AddMiddleware(middleware ...Middleware) *Bot { b.middlewares = append(b.middlewares, middleware...) for _, m := range middleware { - b.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.Name)) + b.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) } sort.Slice(b.middlewares, func(i, j int) bool { first := b.middlewares[i] second := b.middlewares[j] - if first.Order == second.Order { - return first.Name < second.Name + if first.order == second.order { + return first.name < second.name } - return first.Order < second.Order + return first.order < second.order }) return b diff --git a/cmd_generator.go b/cmd_generator.go new file mode 100644 index 0000000..9653f96 --- /dev/null +++ b/cmd_generator.go @@ -0,0 +1,26 @@ +package laniakea + +import "git.nix13.pw/scuroneko/laniakea/tgapi" + +func generateBotCommand(cmd Command) tgapi.BotCommand { + return tgapi.BotCommand{ + Command: cmd.command, Description: cmd.command, + } +} + +func generateBotCommandForPlugin(pl Plugin) []tgapi.BotCommand { + cmds := make([]tgapi.BotCommand, 0) + for _, cmd := range pl.Commands { + cmds = append(cmds, generateBotCommand(cmd)) + } + return cmds +} + +func (b *Bot) AutoGenerateCommands() error { + commands := make([]tgapi.BotCommand, 0) + for _, pl := range b.plugins { + commands = append(commands, generateBotCommandForPlugin(pl)...) + } + _, err := b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands}) + return err +} diff --git a/handler.go b/handler.go index c250277..9d73ea1 100644 --- a/handler.go +++ b/handler.go @@ -1,6 +1,7 @@ package laniakea import ( + "encoding/base64" "encoding/json" "strings" @@ -112,3 +113,32 @@ func (b *Bot) checkPrefixes(text string) (string, bool) { } return "", false } + +func encodeJsonPayload(d CallbackData) (string, error) { + b, err := json.Marshal(d) + if err != nil { + return "", err + } + return string(b), nil +} +func decodeJsonPayload(s string) (CallbackData, error) { + var data CallbackData + err := json.Unmarshal([]byte(s), &data) + return data, err +} +func encodeBase64Payload(d CallbackData) (string, error) { + data, err := encodeJsonPayload(d) + if err != nil { + return "", err + } + dst := make([]byte, base64.StdEncoding.EncodedLen(len([]byte(data)))) + base64.StdEncoding.Encode(dst, []byte(data)) + return string(dst), nil +} +func decodeBase64Payload(s string) (CallbackData, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return CallbackData{}, err + } + return decodeJsonPayload(string(b)) +} diff --git a/plugins.go b/plugins.go index d6768f9..aa04a36 100644 --- a/plugins.go +++ b/plugins.go @@ -1,72 +1,97 @@ package laniakea import ( - "log" + "regexp" "git.nix13.pw/scuroneko/extypes" ) type CommandExecutor func(ctx *MsgContext, dbContext *DatabaseContext) -type PluginBuilder struct { - name string - commands map[string]CommandExecutor - payloads map[string]CommandExecutor - middlewares extypes.Slice[*PluginMiddleware] +const ( + CommandValueStringType CommandValueType = "string" + CommandValueIntType CommandValueType = "int" + CommandValueBoolType CommandValueType = "bool" + CommandValueAnyType CommandValueType = "any" +) + +var ( + CommandRegexInt = regexp.MustCompile("\\d+") + CommandRegexString = regexp.MustCompile("\\.+") +) + +type CommandValueType string +type CommandArg struct { + valueType CommandValueType + text string + regex *regexp.Regexp +} + +func NewCommandArg(text string, valueType CommandValueType) CommandArg { + regex := CommandRegexString + switch valueType { + case CommandValueIntType: + regex = CommandRegexInt + } + return CommandArg{valueType, text, regex} +} + +type Command struct { + command string + exec CommandExecutor + args []CommandArg + middlewares []Middleware +} + +func NewCommand(exec CommandExecutor, command string, args ...CommandArg) *Command { + return &Command{command, exec, args, make([]Middleware, 0)} } type Plugin struct { Name string - Commands map[string]CommandExecutor - Payloads map[string]CommandExecutor - Middlewares extypes.Slice[*PluginMiddleware] + Commands map[string]Command + Payloads map[string]Command + Middlewares extypes.Slice[PluginMiddleware] } -func NewPlugin(name string) *PluginBuilder { - return &PluginBuilder{ - name: name, - commands: make(map[string]CommandExecutor), - payloads: make(map[string]CommandExecutor), +func NewPlugin(name string) *Plugin { + return &Plugin{ + Name: name, + Commands: map[string]Command{}, + Payloads: map[string]Command{}, + Middlewares: extypes.Slice[PluginMiddleware]{}, } } -func (p *PluginBuilder) Command(f CommandExecutor, cmd ...string) *PluginBuilder { - for _, c := range cmd { - p.commands[c] = f - } +func (p *Plugin) AddCommand(command Command) *Plugin { + p.Commands[command.command] = command return p } -func (p *PluginBuilder) Payload(f CommandExecutor, payloads ...string) *PluginBuilder { - for _, payload := range payloads { - p.payloads[payload] = f - } +func (p *Plugin) AddPayload(command Command) *Plugin { + p.Payloads[command.command] = command return p } -func (p *PluginBuilder) AddMiddleware(middleware *PluginMiddleware) *PluginBuilder { - p.middlewares = p.middlewares.Push(middleware) +func (p *Plugin) AddMiddleware(middleware PluginMiddleware) *Plugin { + p.Middlewares = p.Middlewares.Push(middleware) return p } -func (p *PluginBuilder) Build() Plugin { - if len(p.commands) == 0 && len(p.payloads) == 0 { - log.Printf("no command or payloads for %s", p.name) - } - return Plugin{ - p.name, p.commands, - p.payloads, p.middlewares, - } -} - func (p *Plugin) Execute(cmd string, ctx *MsgContext, dbContext *DatabaseContext) { - (p.Commands[cmd])(ctx, dbContext) + command := p.Commands[cmd] + if !command.validateArgs(ctx.Args) { + return + } + command.exec(ctx, dbContext) } - func (p *Plugin) ExecutePayload(payload string, ctx *MsgContext, dbContext *DatabaseContext) { - (p.Payloads[payload])(ctx, dbContext) + pl := p.Payloads[payload] + if !pl.validateArgs(ctx.Args) { + return + } + pl.exec(ctx, dbContext) } - func (p *Plugin) executeMiddlewares(ctx *MsgContext, db *DatabaseContext) bool { for _, m := range p.Middlewares { if !m.Execute(ctx, db) { @@ -75,42 +100,43 @@ func (p *Plugin) executeMiddlewares(ctx *MsgContext, db *DatabaseContext) bool { } return true } +func (c *Command) validateArgs(args []string) bool { + if len(args) != len(c.args) { + return false + } + + for i, arg := range c.args { + if arg.regex == nil { + continue + } + if !arg.regex.MatchString(args[i]) { + return false + } + } + return true +} type Middleware struct { - Name string - Executor CommandExecutor - Order int - Async bool -} -type MiddlewareBuilder struct { - name string - executor CommandExecutor - order int - async bool + name string + exec CommandExecutor + order int + async bool } -func NewMiddleware(name string, executor CommandExecutor) *MiddlewareBuilder { - return &MiddlewareBuilder{name: name, executor: executor, order: 0, async: false} +func NewMiddleware(name string, executor CommandExecutor) *Middleware { + return &Middleware{name, executor, 0, false} } -func (m *MiddlewareBuilder) SetOrder(order int) *MiddlewareBuilder { +func (m *Middleware) SetOrder(order int) *Middleware { m.order = order return m } -func (m *MiddlewareBuilder) SetAsync(async bool) *MiddlewareBuilder { +func (m *Middleware) SetAsync(async bool) *Middleware { m.async = async return m } -func (m *MiddlewareBuilder) Build() Middleware { - return Middleware{ - Name: m.name, - Executor: m.executor, - Order: m.order, - Async: m.async, - } -} -func (m Middleware) Execute(ctx *MsgContext, db *DatabaseContext) { - if m.Async { - go m.Executor(ctx, db) +func (m *Middleware) Execute(ctx *MsgContext, db *DatabaseContext) { + if m.async { + go m.exec(ctx, db) } else { m.Execute(ctx, db) } -- 2.54.0 From b2bda02c0fe63979b92ceec2fbe0ced8ec2202c8 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 18 Feb 2026 11:39:20 +0300 Subject: [PATCH 05/99] l10n and bot command auto generation; v0.6.0 --- bot.go | 16 +++-- cmd_generator.go | 47 ++++++++++++--- go.mod | 5 +- go.sum | 10 ++- handler.go | 12 ++-- l10n.go | 27 +++++++++ msg_context.go | 8 +++ plugins.go | 147 +++++++++++++++++++++------------------------ tgapi/api.go | 6 +- tgapi/bot_types.go | 2 +- utils/version.go | 4 +- 11 files changed, 182 insertions(+), 102 deletions(-) create mode 100644 l10n.go diff --git a/bot.go b/bot.go index b16b8fe..86bbfc8 100644 --- a/bot.go +++ b/bot.go @@ -41,7 +41,7 @@ func LoadSettingsFromEnv() *BotSettings { func LoadPrefixesFromEnv() []string { prefixesS, exists := os.LookupEnv("PREFIXES") if !exists { - return []string{"!"} + return []string{"/"} } return strings.Split(prefixesS, ";") } @@ -61,6 +61,7 @@ type Bot struct { dbContext *DatabaseContext api *tgapi.API + l10n L10n dbWriterRequested extypes.Slice[*slog.Logger] @@ -76,7 +77,7 @@ func NewBot(settings *BotSettings) *Bot { updateOffset: 0, plugins: make([]Plugin, 0), debug: settings.Debug, errorTemplate: "%s", prefixes: settings.Prefixes, updateTypes: make([]tgapi.UpdateType, 0), runners: make([]Runner, 0), updateQueue: updateQueue, api: api, dbWriterRequested: make([]*slog.Logger, 0), - token: settings.Token, + token: settings.Token, l10n: L10n{}, } bot.dbWriterRequested = bot.dbWriterRequested.Push(api.Logger) @@ -182,9 +183,9 @@ func (b *Bot) Debug(debug bool) *Bot { b.debug = debug return b } -func (b *Bot) AddPlugins(plugin ...Plugin) *Bot { - b.plugins = append(b.plugins, plugin...) +func (b *Bot) AddPlugins(plugin ...*Plugin) *Bot { for _, p := range plugin { + b.plugins = append(b.plugins, *p) b.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.Name)) } return b @@ -211,6 +212,13 @@ func (b *Bot) AddRunner(runner Runner) *Bot { b.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.Name)) return b } +func (b *Bot) AddL10n(l L10n) *Bot { + b.l10n = l + return b +} +func (b *Bot) L10n(lang, key string) string { + return b.l10n.Translate(lang, key) +} func (b *Bot) Logger() *slog.Logger { return b.logger } diff --git a/cmd_generator.go b/cmd_generator.go index 9653f96..083b14b 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -1,26 +1,59 @@ package laniakea -import "git.nix13.pw/scuroneko/laniakea/tgapi" +import ( + "fmt" + "strings" + + "git.nix13.pw/scuroneko/laniakea/tgapi" +) func generateBotCommand(cmd Command) tgapi.BotCommand { - return tgapi.BotCommand{ - Command: cmd.command, Description: cmd.command, + desc := cmd.command + if len(cmd.description) > 0 { + desc = cmd.description } + var descArgs []string + for _, a := range cmd.args { + if a.required { + descArgs = append(descArgs, fmt.Sprintf("%s", a.text)) + } else { + descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text)) + } + } + desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " ")) + return tgapi.BotCommand{Command: cmd.command, Description: desc} } func generateBotCommandForPlugin(pl Plugin) []tgapi.BotCommand { - cmds := make([]tgapi.BotCommand, 0) + commands := make([]tgapi.BotCommand, 0) for _, cmd := range pl.Commands { - cmds = append(cmds, generateBotCommand(cmd)) + commands = append(commands, generateBotCommand(cmd)) } - return cmds + return commands } func (b *Bot) AutoGenerateCommands() error { + _, err := b.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) + if err != nil { + return err + } + commands := make([]tgapi.BotCommand, 0) for _, pl := range b.plugins { commands = append(commands, generateBotCommandForPlugin(pl)...) } - _, err := b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands}) + + privateChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType} + groupChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeGroupType} + chatAdminsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeAllChatAdministratorsType} + _, err = b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: privateChatsScope}) + if err != nil { + return err + } + _, err = b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: groupChatsScope}) + if err != nil { + return err + } + _, err = b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: chatAdminsScope}) return err } diff --git a/go.mod b/go.mod index d6912d1..bf03cd1 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module git.nix13.pw/scuroneko/laniakea -go 1.25 +go 1.26 require ( git.nix13.pw/scuroneko/extypes v1.2.0 git.nix13.pw/scuroneko/slog v1.0.2 - github.com/redis/go-redis/v9 v9.17.3 + github.com/redis/go-redis/v9 v9.18.0 github.com/vinovest/sqlx v1.7.1 go.mongodb.org/mongo-driver/v2 v2.5.0 ) @@ -23,6 +23,7 @@ require ( github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index 8340bd9..2368236 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -36,8 +38,8 @@ github.com/muir/sqltoken v0.3.0 h1:3xbcqr80f3IA4OlwkOpdIHC4DTu6gsi1TwMqgYL4Dpg= github.com/muir/sqltoken v0.3.0/go.mod h1:+OSmbGI22QcVZ6DCzlHT8EAzEq/mqtqedtPP91Le+3A= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= -github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vinovest/sqlx v1.7.1 h1:kdq4v0N9kRLpytWGSWOw4aulOGdQPmIoMR6Y+cTBxow= @@ -51,8 +53,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= diff --git a/handler.go b/handler.go index 9d73ea1..b774770 100644 --- a/handler.go +++ b/handler.go @@ -46,7 +46,6 @@ func (b *Bot) handleMessage(update *tgapi.Update, ctx *MsgContext) { text = strings.TrimSpace(text[len(prefix):]) for _, plugin := range b.plugins { - // Check every command for cmd := range plugin.Commands { if !strings.HasPrefix(text, cmd) { continue @@ -60,17 +59,22 @@ func (b *Bot) handleMessage(update *tgapi.Update, ctx *MsgContext) { break } } + if !isValid { continue } ctx.Text = strings.TrimSpace(text[len(cmd):]) - ctx.Args = strings.Split(ctx.Text, " ") + if ctx.Text == "" { + ctx.Args = []string{} + } else { + ctx.Args = strings.Split(ctx.Text, " ") + } if !plugin.executeMiddlewares(ctx, b.dbContext) { return } - go plugin.Execute(cmd, ctx, b.dbContext) + go plugin.executeCmd(cmd, ctx, b.dbContext) return } } @@ -100,7 +104,7 @@ func (b *Bot) handleCallback(update *tgapi.Update, ctx *MsgContext) { if !plugin.executeMiddlewares(ctx, b.dbContext) { return } - go plugin.ExecutePayload(data.Command, ctx, b.dbContext) + go plugin.executePayload(data.Command, ctx, b.dbContext) return } } diff --git a/l10n.go b/l10n.go new file mode 100644 index 0000000..a74f32f --- /dev/null +++ b/l10n.go @@ -0,0 +1,27 @@ +package laniakea + +// DictEntry {key:{ru:123,en:123}} +type DictEntry map[string]string +type L10n struct { + entries map[string]DictEntry + fallbackLang string +} + +func NewL10n(fallbackLanguage string) *L10n { + return &L10n{make(map[string]DictEntry), fallbackLanguage} +} +func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n { + l.entries[key] = value + return l +} +func (l *L10n) GetFallbackLanguage() string { + return l.fallbackLang +} + +func (l *L10n) Translate(lang, key string) string { + s, ok := l.entries[key] + if !ok { + return key + } + return s[lang] +} diff --git a/msg_context.go b/msg_context.go index 8ff7396..c20c249 100644 --- a/msg_context.go +++ b/msg_context.go @@ -212,3 +212,11 @@ func (ctx *MsgContext) error(err error) { func (ctx *MsgContext) Error(err error) { ctx.error(err) } + +func (ctx *MsgContext) Translate(key string) string { + if ctx.From == nil { + return key + } + lang := Val(ctx.From.LanguageCode, ctx.Bot.l10n.GetFallbackLanguage()) + return ctx.Bot.L10n(lang, key) +} diff --git a/plugins.go b/plugins.go index aa04a36..cecd014 100644 --- a/plugins.go +++ b/plugins.go @@ -1,13 +1,12 @@ package laniakea import ( + "errors" "regexp" "git.nix13.pw/scuroneko/extypes" ) -type CommandExecutor func(ctx *MsgContext, dbContext *DatabaseContext) - const ( CommandValueStringType CommandValueType = "string" CommandValueIntType CommandValueType = "int" @@ -17,7 +16,12 @@ const ( var ( CommandRegexInt = regexp.MustCompile("\\d+") - CommandRegexString = regexp.MustCompile("\\.+") + CommandRegexString = regexp.MustCompile(".+") +) + +var ( + ErrCmdArgCountMismatch = errors.New("command arg count mismatch") + ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") ) type CommandValueType string @@ -25,69 +29,102 @@ type CommandArg struct { valueType CommandValueType text string regex *regexp.Regexp + required bool } -func NewCommandArg(text string, valueType CommandValueType) CommandArg { +func NewCommandArg(text string, valueType CommandValueType) *CommandArg { regex := CommandRegexString switch valueType { case CommandValueIntType: regex = CommandRegexInt } - return CommandArg{valueType, text, regex} + return &CommandArg{valueType, text, regex, false} +} +func (c *CommandArg) SetRequired() *CommandArg { + c.required = true + return c } +type CommandExecutor func(ctx *MsgContext, dbContext *DatabaseContext) type Command struct { command string + description string exec CommandExecutor - args []CommandArg - middlewares []Middleware + args extypes.Slice[CommandArg] + middlewares extypes.Slice[Middleware] } func NewCommand(exec CommandExecutor, command string, args ...CommandArg) *Command { - return &Command{command, exec, args, make([]Middleware, 0)} + return &Command{command, "", exec, args, make(extypes.Slice[Middleware], 0)} +} +func (c *Command) Use(m Middleware) *Command { + c.middlewares = c.middlewares.Push(m) + return c +} +func (c *Command) SetDescription(desc string) *Command { + c.description = desc + return c +} +func (c *Command) validateArgs(args []string) error { + cmdArgs := c.args.Filter(func(e CommandArg) bool { return !e.required }) + if len(args) < cmdArgs.Len() { + return ErrCmdArgCountMismatch + } + + for i, arg := range args { + if i >= c.args.Len() { + break + } + cmdArg := c.args.Get(i) + if cmdArg.regex == nil { + continue + } + if !cmdArg.regex.MatchString(arg) { + return ErrCmdArgRegexpMismatch + } + } + return nil } type Plugin struct { Name string Commands map[string]Command Payloads map[string]Command - Middlewares extypes.Slice[PluginMiddleware] + Middlewares extypes.Slice[Middleware] } func NewPlugin(name string) *Plugin { return &Plugin{ - Name: name, - Commands: map[string]Command{}, - Payloads: map[string]Command{}, - Middlewares: extypes.Slice[PluginMiddleware]{}, + name, map[string]Command{}, + map[string]Command{}, extypes.Slice[Middleware]{}, } } -func (p *Plugin) AddCommand(command Command) *Plugin { - p.Commands[command.command] = command +func (p *Plugin) AddCommand(command *Command) *Plugin { + p.Commands[command.command] = *command return p } - -func (p *Plugin) AddPayload(command Command) *Plugin { - p.Payloads[command.command] = command +func (p *Plugin) AddPayload(command *Command) *Plugin { + p.Payloads[command.command] = *command return p } - -func (p *Plugin) AddMiddleware(middleware PluginMiddleware) *Plugin { +func (p *Plugin) AddMiddleware(middleware Middleware) *Plugin { p.Middlewares = p.Middlewares.Push(middleware) return p } -func (p *Plugin) Execute(cmd string, ctx *MsgContext, dbContext *DatabaseContext) { +func (p *Plugin) executeCmd(cmd string, ctx *MsgContext, dbContext *DatabaseContext) { command := p.Commands[cmd] - if !command.validateArgs(ctx.Args) { + if err := command.validateArgs(ctx.Args); err != nil { + ctx.error(err) return } command.exec(ctx, dbContext) } -func (p *Plugin) ExecutePayload(payload string, ctx *MsgContext, dbContext *DatabaseContext) { +func (p *Plugin) executePayload(payload string, ctx *MsgContext, dbContext *DatabaseContext) { pl := p.Payloads[payload] - if !pl.validateArgs(ctx.Args) { + if err := pl.validateArgs(ctx.Args); err != nil { + ctx.error(err) return } pl.exec(ctx, dbContext) @@ -100,30 +137,19 @@ func (p *Plugin) executeMiddlewares(ctx *MsgContext, db *DatabaseContext) bool { } return true } -func (c *Command) validateArgs(args []string) bool { - if len(args) != len(c.args) { - return false - } - for i, arg := range c.args { - if arg.regex == nil { - continue - } - if !arg.regex.MatchString(args[i]) { - return false - } - } - return true -} +type MiddlewareExecutor func(ctx *MsgContext, db *DatabaseContext) bool +// Middleware +// When async, returned value ignored type Middleware struct { - name string - exec CommandExecutor - order int - async bool + name string + executor MiddlewareExecutor + order int + async bool } -func NewMiddleware(name string, executor CommandExecutor) *Middleware { +func NewMiddleware(name string, executor MiddlewareExecutor) *Middleware { return &Middleware{name, executor, 0, false} } func (m *Middleware) SetOrder(order int) *Middleware { @@ -134,40 +160,7 @@ func (m *Middleware) SetAsync(async bool) *Middleware { m.async = async return m } -func (m *Middleware) Execute(ctx *MsgContext, db *DatabaseContext) { - if m.async { - go m.exec(ctx, db) - } else { - m.Execute(ctx, db) - } -} - -type PluginMiddlewareExecutor func(ctx *MsgContext, db *DatabaseContext) bool - -// PluginMiddleware -// When async, returned value ignored -type PluginMiddleware struct { - executor PluginMiddlewareExecutor - order int - async bool -} - -func NewPluginMiddleware(executor PluginMiddlewareExecutor) *PluginMiddleware { - return &PluginMiddleware{ - executor: executor, - order: 0, - async: false, - } -} -func (m *PluginMiddleware) SetOrder(order int) *PluginMiddleware { - m.order = order - return m -} -func (m *PluginMiddleware) SetAsync(async bool) *PluginMiddleware { - m.async = async - return m -} -func (m *PluginMiddleware) Execute(ctx *MsgContext, db *DatabaseContext) bool { +func (m *Middleware) Execute(ctx *MsgContext, db *DatabaseContext) bool { if m.async { go m.executor(ctx, db) return true diff --git a/tgapi/api.go b/tgapi/api.go index 8e7f2c9..320eee3 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -67,9 +67,6 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, return zero, err } defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return zero, fmt.Errorf("unexpected status code: %d", res.StatusCode) - } reader := io.LimitReader(res.Body, 10<<20) data, err = io.ReadAll(reader) @@ -77,6 +74,9 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, return zero, err } api.Logger.Debugln("RES", r.method, string(data)) + if res.StatusCode != http.StatusOK { + return zero, fmt.Errorf("unexpected status code: %d", res.StatusCode) + } var resp ApiResponse[R] err = json.Unmarshal(data, &resp) diff --git a/tgapi/bot_types.go b/tgapi/bot_types.go index d73c30c..a17eaf0 100644 --- a/tgapi/bot_types.go +++ b/tgapi/bot_types.go @@ -9,7 +9,7 @@ type BotCommandScopeType string const ( BotCommandScopeDefaultType BotCommandScopeType = "default" BotCommandScopePrivateType BotCommandScopeType = "all_private_chats" - BotCommandScopeGroupType BotCommandScopeType = "all_groups_chats" + BotCommandScopeGroupType BotCommandScopeType = "all_group_chats" BotCommandScopeAllChatAdministratorsType BotCommandScopeType = "all_chat_administrators" BotCommandScopeChatType BotCommandScopeType = "chat" BotCommandScopeChatAdministratorsType BotCommandScopeType = "chat_administrators" diff --git a/utils/version.go b/utils/version.go index adc8290..d1e9386 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,8 +1,8 @@ package utils const ( - VersionString = "0.5.0" + VersionString = "0.6.0" VersionMajor = 0 - VersionMinor = 5 + VersionMinor = 6 VersionPatch = 0 ) -- 2.54.0 From 746847cf611861f924bfe3e98e421f96a872599f Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 18 Feb 2026 11:39:27 +0300 Subject: [PATCH 06/99] l10n and bot command auto generation; v0.6.0 --- cmd_generator.go | 3 +++ plugins.go | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd_generator.go b/cmd_generator.go index 083b14b..7d67a2c 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -27,6 +27,9 @@ func generateBotCommand(cmd Command) tgapi.BotCommand { func generateBotCommandForPlugin(pl Plugin) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, cmd := range pl.Commands { + if cmd.skipAutoCmd { + continue + } commands = append(commands, generateBotCommand(cmd)) } return commands diff --git a/plugins.go b/plugins.go index cecd014..e8ae2df 100644 --- a/plugins.go +++ b/plugins.go @@ -52,10 +52,11 @@ type Command struct { exec CommandExecutor args extypes.Slice[CommandArg] middlewares extypes.Slice[Middleware] + skipAutoCmd bool } func NewCommand(exec CommandExecutor, command string, args ...CommandArg) *Command { - return &Command{command, "", exec, args, make(extypes.Slice[Middleware], 0)} + return &Command{command, "", exec, args, make(extypes.Slice[Middleware], 0), false} } func (c *Command) Use(m Middleware) *Command { c.middlewares = c.middlewares.Push(m) @@ -65,6 +66,10 @@ func (c *Command) SetDescription(desc string) *Command { c.description = desc return c } +func (c *Command) SkipCommandAutoGen() *Command { + c.skipAutoCmd = true + return c +} func (c *Command) validateArgs(args []string) error { cmdArgs := c.args.Filter(func(e CommandArg) bool { return !e.required }) if len(args) < cmdArgs.Len() { -- 2.54.0 From c2909b4cfb65d38b6bd7b65e56c820c5dfa63dbe Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 18 Feb 2026 11:46:48 +0300 Subject: [PATCH 07/99] small fix; v0.6.1 --- plugins.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins.go b/plugins.go index e8ae2df..c01b6f4 100644 --- a/plugins.go +++ b/plugins.go @@ -109,6 +109,9 @@ func (p *Plugin) AddCommand(command *Command) *Plugin { p.Commands[command.command] = *command return p } +func (p *Plugin) NewCommand(exec CommandExecutor, command string, args ...CommandArg) *Command { + return NewCommand(exec, command, args...) +} func (p *Plugin) AddPayload(command *Command) *Plugin { p.Payloads[command.command] = *command return p -- 2.54.0 From 434638a61df2a8a8558fd128af8af79b0c6c7179 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 18 Feb 2026 11:46:53 +0300 Subject: [PATCH 08/99] small fix; v0.6.1 --- utils/version.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/version.go b/utils/version.go index d1e9386..e5ad682 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,8 +1,8 @@ package utils const ( - VersionString = "0.6.0" + VersionString = "0.6.1" VersionMajor = 0 VersionMinor = 6 - VersionPatch = 0 + VersionPatch = 1 ) -- 2.54.0 From d84b0a1b55ac666feb315b4a5abd807011324398 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 18 Feb 2026 14:05:36 +0300 Subject: [PATCH 09/99] small fixes --- tgapi/api.go | 2 +- tgapi/uploader_api.go | 6 +++--- utils/version.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tgapi/api.go b/tgapi/api.go index 320eee3..fe8f5b4 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -75,7 +75,7 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, } api.Logger.Debugln("RES", r.method, string(data)) if res.StatusCode != http.StatusOK { - return zero, fmt.Errorf("unexpected status code: %d", res.StatusCode) + return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(data)) } var resp ApiResponse[R] diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index d1aa164..64fe7ca 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -108,9 +108,6 @@ func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) return zero, err } defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return zero, fmt.Errorf("unexpected status code: %d", res.StatusCode) - } reader := io.LimitReader(res.Body, 10<<20) body, err := io.ReadAll(reader) @@ -118,6 +115,9 @@ func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) return zero, err } up.logger.Debugln("UPLOADER RES", u.method, string(body)) + if res.StatusCode != http.StatusOK { + return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(body)) + } var resp ApiResponse[R] err = json.Unmarshal(body, &resp) diff --git a/utils/version.go b/utils/version.go index e5ad682..b3b2723 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,8 +1,8 @@ package utils const ( - VersionString = "0.6.1" + VersionString = "0.6.2" VersionMajor = 0 VersionMinor = 6 - VersionPatch = 1 + VersionPatch = 2 ) -- 2.54.0 From 0e0f8a08137e195b0e80e6464e69f9fdb6a41770 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 19 Feb 2026 11:49:04 +0300 Subject: [PATCH 10/99] v0.7.0; support for test server and local bot api --- bot.go | 16 +++++---- cmd_generator.go | 6 ++++ tgapi/api.go | 82 +++++++++++++++++++++++++++++++++---------- tgapi/uploader_api.go | 76 +++++++++++++++++++-------------------- 4 files changed, 115 insertions(+), 65 deletions(-) diff --git a/bot.go b/bot.go index 86bbfc8..9fe384f 100644 --- a/bot.go +++ b/bot.go @@ -15,7 +15,7 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo" ) -type BotSettings struct { +type BotOpts struct { Token string Debug bool ErrorTemplate string @@ -24,10 +24,12 @@ type BotSettings struct { LoggerBasePath string UseRequestLogger bool WriteToFile bool + UseTestServer bool + APIUrl string } -func LoadSettingsFromEnv() *BotSettings { - return &BotSettings{ +func LoadOptsFromEnv() *BotOpts { + return &BotOpts{ Token: os.Getenv("TG_TOKEN"), Debug: os.Getenv("DEBUG") == "true", ErrorTemplate: os.Getenv("ERROR_TEMPLATE"), @@ -35,6 +37,8 @@ func LoadSettingsFromEnv() *BotSettings { UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"), UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true", WriteToFile: os.Getenv("WRITE_TO_FILE") == "true", + UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", + APIUrl: os.Getenv("API_URL"), } } @@ -70,9 +74,10 @@ type Bot struct { updateQueue *extypes.Queue[*tgapi.Update] } -func NewBot(settings *BotSettings) *Bot { +func NewBot(settings *BotOpts) *Bot { updateQueue := extypes.CreateQueue[*tgapi.Update](256) - api := tgapi.NewAPI(settings.Token) + apiOpts := tgapi.NewAPIOpts(settings.Token).SetAPIUrl(settings.APIUrl).UseTestServer(settings.UseTestServer) + api := tgapi.NewAPI(apiOpts) bot := &Bot{ updateOffset: 0, plugins: make([]Plugin, 0), debug: settings.Debug, errorTemplate: "%s", prefixes: settings.Prefixes, updateTypes: make([]tgapi.UpdateType, 0), runners: make([]Runner, 0), @@ -237,7 +242,6 @@ func (b *Bot) Run() { return } - b.logger.Infoln("Executing runners...") b.ExecRunners() b.logger.Infoln("Bot running. Press CTRL+C to exit.") diff --git a/cmd_generator.go b/cmd_generator.go index 7d67a2c..b4f8c35 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -1,6 +1,7 @@ package laniakea import ( + "errors" "fmt" "strings" @@ -35,6 +36,8 @@ func generateBotCommandForPlugin(pl Plugin) []tgapi.BotCommand { return commands } +var ErrTooManyCommands = errors.New("too many commands. max 100") + func (b *Bot) AutoGenerateCommands() error { _, err := b.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) if err != nil { @@ -45,6 +48,9 @@ func (b *Bot) AutoGenerateCommands() error { for _, pl := range b.plugins { commands = append(commands, generateBotCommandForPlugin(pl)...) } + if len(commands) > 100 { + return ErrTooManyCommands + } privateChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType} groupChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeGroupType} diff --git a/tgapi/api.go b/tgapi/api.go index fe8f5b4..cafa5b8 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -13,17 +13,49 @@ import ( "git.nix13.pw/scuroneko/slog" ) -type API struct { - token string - client *http.Client - Logger *slog.Logger +type APIOpts struct { + token string + client *http.Client + useTestServer bool + apiUrl string } -func NewAPI(token string) *API { +func NewAPIOpts(token string) *APIOpts { + return &APIOpts{token: token, client: nil, useTestServer: false, apiUrl: "https://api.telegram.org"} +} +func (opts *APIOpts) SetHTTPClient(client *http.Client) *APIOpts { + if client != nil { + opts.client = client + } + return opts +} +func (opts *APIOpts) UseTestServer(use bool) *APIOpts { + opts.useTestServer = use + return opts +} +func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts { + if apiUrl != "" { + opts.apiUrl = apiUrl + } + return opts +} + +type API struct { + token string + client *http.Client + Logger *slog.Logger + useTestServer bool + apiUrl string +} + +func NewAPI(opts *APIOpts) *API { l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API") l.AddWriter(l.CreateJsonStdoutWriter()) - client := &http.Client{Timeout: time.Second * 45} - return &API{token, client, l} + client := opts.client + if client == nil { + client = &http.Client{Timeout: time.Second * 45} + } + return &API{opts.token, client, l, opts.useTestServer, opts.apiUrl} } func (api *API) CloseApi() error { return api.Logger.Close() @@ -52,8 +84,12 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, } buf := bytes.NewBuffer(data) - u := fmt.Sprintf("https://api.telegram.org/bot%s/%s", api.token, r.method) - req, err := http.NewRequestWithContext(ctx, "POST", u, buf) + methodPrefix := "" + if api.useTestServer { + methodPrefix = "/test" + } + url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method) + req, err := http.NewRequestWithContext(ctx, "POST", url, buf) if err != nil { return zero, err } @@ -61,15 +97,16 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) - api.Logger.Debugln("REQ", r.method, buf.String()) + api.Logger.Debugln("REQ", api.apiUrl, r.method, buf.String()) res, err := api.client.Do(req) if err != nil { return zero, err } - defer res.Body.Close() + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(res.Body) - reader := io.LimitReader(res.Body, 10<<20) - data, err = io.ReadAll(reader) + data, err = readBody(res.Body) if err != nil { return zero, err } @@ -77,9 +114,21 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, if res.StatusCode != http.StatusOK { return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(data)) } + return parseBody[R](data) +} +func (r TelegramRequest[R, P]) Do(api *API) (R, error) { + return r.DoWithContext(context.Background(), api) +} + +func readBody(body io.ReadCloser) ([]byte, error) { + reader := io.LimitReader(body, 10<<20) + return io.ReadAll(reader) +} +func parseBody[R any](data []byte) (R, error) { + var zero R var resp ApiResponse[R] - err = json.Unmarshal(data, &resp) + err := json.Unmarshal(data, &resp) if err != nil { return zero, err } @@ -87,9 +136,4 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, return zero, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description) } return resp.Result, nil - -} -func (r TelegramRequest[R, P]) Do(api *API) (R, error) { - ctx := context.Background() - return r.DoWithContext(ctx, api) } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 64fe7ca..ec0f9d7 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -3,9 +3,7 @@ package tgapi import ( "bytes" "context" - "encoding/json" "fmt" - "io" "mime/multipart" "net/http" "path/filepath" @@ -66,39 +64,22 @@ func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile } func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { var zero R - url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", up.api.token, u.method) - buf := bytes.NewBuffer(nil) - w := multipart.NewWriter(buf) - - for _, file := range u.files { - fw, err := w.CreateFormFile(string(file.field), file.filename) - if err != nil { - _ = w.Close() - return zero, err - } - _, err = fw.Write(file.data) - if err != nil { - _ = w.Close() - return zero, err - } - } - - err := utils.Encode(w, u.params) - if err != nil { - _ = w.Close() - return zero, err - } - err = w.Close() + buf, contentType, err := prepareMultipart(u.files, u.params) if err != nil { return zero, err } + methodPrefix := "" + if up.api.useTestServer { + methodPrefix = "/test" + } + url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, u.method) req, err := http.NewRequestWithContext(ctx, "POST", url, buf) if err != nil { return zero, err } - req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) @@ -109,30 +90,45 @@ func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) } defer res.Body.Close() - reader := io.LimitReader(res.Body, 10<<20) - body, err := io.ReadAll(reader) - if err != nil { - return zero, err - } + body, err := readBody(res.Body) up.logger.Debugln("UPLOADER RES", u.method, string(body)) if res.StatusCode != http.StatusOK { return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(body)) } - var resp ApiResponse[R] - err = json.Unmarshal(body, &resp) - if err != nil { - return zero, err - } - if !resp.Ok { - return zero, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description) - } - return resp.Result, nil + return parseBody[R](body) } func (u UploaderRequest[R, P]) Do(up *Uploader) (R, error) { return u.DoWithContext(context.Background(), up) } +func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { + buf := bytes.NewBuffer(nil) + w := multipart.NewWriter(buf) + + for _, file := range files { + fw, err := w.CreateFormFile(string(file.field), file.filename) + if err != nil { + _ = w.Close() + return buf, w.FormDataContentType(), err + } + + _, err = fw.Write(file.data) + if err != nil { + _ = w.Close() + return buf, w.FormDataContentType(), err + } + } + + err := utils.Encode(w, params) + if err != nil { + _ = w.Close() + return buf, w.FormDataContentType(), err + } + err = w.Close() + return buf, w.FormDataContentType(), err +} + func uploaderTypeByExt(filename string) UploaderFileType { ext := filepath.Ext(filename) switch ext { -- 2.54.0 From 466093e39b988a13706fc406d73ef6c8cb3d2ec9 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 19 Feb 2026 12:07:03 +0300 Subject: [PATCH 11/99] version fix --- utils/version.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/version.go b/utils/version.go index b3b2723..6c265e9 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,8 +1,8 @@ package utils const ( - VersionString = "0.6.2" + VersionString = "0.7.1" VersionMajor = 0 - VersionMinor = 6 - VersionPatch = 2 + VersionMinor = 7 + VersionPatch = 1 ) -- 2.54.0 From 7b9292557eb8cb950fb0319732c6ef0e3b4b93f6 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 19 Feb 2026 13:27:03 +0300 Subject: [PATCH 12/99] 0.8.0 beta 1 --- bot.go | 272 +++++++++++++++++++++--------------------- cmd_generator.go | 16 +-- go.mod | 16 --- go.sum | 82 ------------- handler.go | 36 +++--- l10n.go | 1 - methods.go | 22 ++-- msg_context.go | 32 ++--- plugins.go | 67 ++++++----- runners.go | 64 ++++------ tgapi/api.go | 12 +- tgapi/uploader_api.go | 3 +- utils.go | 5 +- utils/version.go | 7 +- 14 files changed, 264 insertions(+), 371 deletions(-) diff --git a/bot.go b/bot.go index 9fe384f..2d91cfa 100644 --- a/bot.go +++ b/bot.go @@ -10,9 +10,6 @@ import ( "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/slog" - "github.com/redis/go-redis/v9" - "github.com/vinovest/sqlx" - "go.mongodb.org/mongo-driver/v2/mongo" ) type BotOpts struct { @@ -41,7 +38,6 @@ func LoadOptsFromEnv() *BotOpts { APIUrl: os.Getenv("API_URL"), } } - func LoadPrefixesFromEnv() []string { prefixesS, exists := os.LookupEnv("PREFIXES") if !exists { @@ -50,7 +46,8 @@ func LoadPrefixesFromEnv() []string { return strings.Split(prefixesS, ";") } -type Bot struct { +type DbContext interface{} +type Bot[T DbContext] struct { token string debug bool errorTemplate string @@ -58,14 +55,15 @@ type Bot struct { logger *slog.Logger RequestLogger *slog.Logger - plugins []Plugin - middlewares []Middleware + plugins []Plugin[T] + middlewares []Middleware[T] prefixes []string - runners []Runner + runners []Runner[T] - dbContext *DatabaseContext api *tgapi.API - l10n L10n + uploader *tgapi.Uploader + dbContext *T + l10n *L10n dbWriterRequested extypes.Slice[*slog.Logger] @@ -74,34 +72,74 @@ type Bot struct { updateQueue *extypes.Queue[*tgapi.Update] } -func NewBot(settings *BotOpts) *Bot { - updateQueue := extypes.CreateQueue[*tgapi.Update](256) - apiOpts := tgapi.NewAPIOpts(settings.Token).SetAPIUrl(settings.APIUrl).UseTestServer(settings.UseTestServer) +func NewBot[T any](opts *BotOpts) *Bot[T] { + updateQueue := extypes.CreateQueue[*tgapi.Update](512) + + apiOpts := tgapi.NewAPIOpts(opts.Token).SetAPIUrl(opts.APIUrl).UseTestServer(opts.UseTestServer) api := tgapi.NewAPI(apiOpts) - bot := &Bot{ - updateOffset: 0, plugins: make([]Plugin, 0), debug: settings.Debug, errorTemplate: "%s", - prefixes: settings.Prefixes, updateTypes: make([]tgapi.UpdateType, 0), runners: make([]Runner, 0), - updateQueue: updateQueue, api: api, dbWriterRequested: make([]*slog.Logger, 0), - token: settings.Token, l10n: L10n{}, - } - bot.dbWriterRequested = bot.dbWriterRequested.Push(api.Logger) - if len(settings.ErrorTemplate) > 0 { - bot.errorTemplate = settings.ErrorTemplate - } - if len(settings.LoggerBasePath) == 0 { - settings.LoggerBasePath = "./" - } + uploader := tgapi.NewUploader(api) + bot := &Bot[T]{ + updateOffset: 0, + errorTemplate: "%s", + updateQueue: updateQueue, + api: api, + uploader: uploader, + debug: opts.Debug, + prefixes: opts.Prefixes, + token: opts.Token, + plugins: make([]Plugin[T], 0), + updateTypes: make([]tgapi.UpdateType, 0), + runners: make([]Runner[T], 0), + dbWriterRequested: make([]*slog.Logger, 0), + l10n: &L10n{}, + } + bot.dbWriterRequested = bot.dbWriterRequested.Push(api.GetLogger()).Push(uploader.GetLogger()) + + if len(opts.ErrorTemplate) > 0 { + bot.errorTemplate = opts.ErrorTemplate + } + if len(opts.LoggerBasePath) == 0 { + opts.LoggerBasePath = "./" + } + bot.initLoggers(opts) + + u, err := api.GetMe() + if err != nil { + _ = api.CloseApi() + _ = uploader.Close() + bot.logger.Fatal(err) + } + bot.logger.Infof("Authorized as %s\n", u.FirstName) + + return bot +} +func (bot *Bot[T]) Close() error { + if err := bot.uploader.Close(); err != nil { + bot.logger.Errorln(err) + } + if err := bot.api.CloseApi(); err != nil { + bot.logger.Errorln(err) + } + if err := bot.RequestLogger.Close(); err != nil { + bot.logger.Errorln(err) + } + if err := bot.logger.Close(); err != nil { + return err + } + return nil +} +func (bot *Bot[T]) initLoggers(opts *BotOpts) { level := slog.FATAL - if settings.Debug { + if opts.Debug { level = slog.DEBUG } bot.logger = slog.CreateLogger().Level(level).Prefix("BOT") bot.logger.AddWriter(bot.logger.CreateJsonStdoutWriter()) - if settings.WriteToFile { - path := fmt.Sprintf("%s/main.log", strings.TrimRight(settings.LoggerBasePath, "/")) + if opts.WriteToFile { + path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) fileWriter, err := bot.logger.CreateTextFileWriter(path) if err != nil { bot.logger.Fatal(err) @@ -109,11 +147,11 @@ func NewBot(settings *BotOpts) *Bot { bot.logger.AddWriter(fileWriter) } - if settings.UseRequestLogger { + if opts.UseRequestLogger { bot.RequestLogger = slog.CreateLogger().Level(level).Prefix("REQUESTS") bot.RequestLogger.AddWriter(bot.RequestLogger.CreateJsonStdoutWriter()) - if settings.WriteToFile { - path := fmt.Sprintf("%s/requests.log", strings.TrimRight(settings.LoggerBasePath, "/")) + if opts.WriteToFile { + path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) fileWriter, err := bot.RequestLogger.CreateTextFileWriter(path) if err != nil { bot.logger.Fatal(err) @@ -121,141 +159,112 @@ func NewBot(settings *BotOpts) *Bot { bot.RequestLogger.AddWriter(fileWriter) } } +} - u, err := api.GetMe() - if err != nil { - bot.logger.Fatal(err) +func (bot *Bot[T]) GetUpdateOffset() int { return bot.updateOffset } +func (bot *Bot[T]) SetUpdateOffset(offset int) { bot.updateOffset = offset } +func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return bot.updateTypes } +func (bot *Bot[T]) GetQueue() *extypes.Queue[*tgapi.Update] { return bot.updateQueue } +func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } +func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } +func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } + +func (bot *Bot[T]) AddDatabaseLogger(writer func(db *T) slog.LoggerWriter) *Bot[T] { + w := writer(bot.dbContext) + bot.logger.AddWriter(w) + if bot.RequestLogger != nil { + bot.RequestLogger.AddWriter(w) + } + for _, l := range bot.dbWriterRequested { + l.AddWriter(w) } - bot.logger.Infof("Authorized as %s\n", u.FirstName) - return bot } -func (b *Bot) Close() error { - err := b.logger.Close() - if err != nil { - return err - } - err = b.RequestLogger.Close() - return err +func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] { + bot.dbContext = ctx + return bot } - -func (b *Bot) GetUpdateOffset() int { return b.updateOffset } -func (b *Bot) SetUpdateOffset(offset int) { b.updateOffset = offset } -func (b *Bot) GetUpdateTypes() []tgapi.UpdateType { return b.updateTypes } -func (b *Bot) GetQueue() *extypes.Queue[*tgapi.Update] { return b.updateQueue } - -type DatabaseContext struct { - PostgresSQL *sqlx.DB - MongoDB *mongo.Client - Redis *redis.Client +func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { + bot.updateTypes = make([]tgapi.UpdateType, 0) + bot.updateTypes = append(bot.updateTypes, t...) + return bot } - -func (b *Bot) AddDatabaseLogger(writer func(db *DatabaseContext) slog.LoggerWriter) *Bot { - w := writer(b.dbContext) - b.logger.AddWriter(w) - if b.RequestLogger != nil { - b.RequestLogger.AddWriter(w) - } - for _, l := range b.dbWriterRequested { - l.AddWriter(w) - } - return b +func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { + bot.updateTypes = append(bot.updateTypes, t...) + return bot } - -func (b *Bot) DatabaseContext(ctx *DatabaseContext) *Bot { - b.dbContext = ctx - return b +func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { + bot.prefixes = append(bot.prefixes, prefixes...) + return bot } -func (b *Bot) UpdateTypes(t ...tgapi.UpdateType) *Bot { - b.updateTypes = make([]tgapi.UpdateType, 0) - b.updateTypes = append(b.updateTypes, t...) - return b +func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { + bot.errorTemplate = s + return bot } -func (b *Bot) AddUpdateType(t ...tgapi.UpdateType) *Bot { - b.updateTypes = append(b.updateTypes, t...) - return b +func (bot *Bot[T]) Debug(debug bool) *Bot[T] { + bot.debug = debug + return bot } -func (b *Bot) AddPrefixes(prefixes ...string) *Bot { - b.prefixes = append(b.prefixes, prefixes...) - return b -} -func (b *Bot) ErrorTemplate(s string) *Bot { - b.errorTemplate = s - return b -} -func (b *Bot) Debug(debug bool) *Bot { - b.debug = debug - return b -} -func (b *Bot) AddPlugins(plugin ...*Plugin) *Bot { +func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { for _, p := range plugin { - b.plugins = append(b.plugins, *p) - b.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.Name)) + bot.plugins = append(bot.plugins, *p) + bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.Name)) } - return b + return bot } -func (b *Bot) AddMiddleware(middleware ...Middleware) *Bot { - b.middlewares = append(b.middlewares, middleware...) +func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { + bot.middlewares = append(bot.middlewares, middleware...) for _, m := range middleware { - b.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) + bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) } - sort.Slice(b.middlewares, func(i, j int) bool { - first := b.middlewares[i] - second := b.middlewares[j] + sort.Slice(bot.middlewares, func(i, j int) bool { + first := bot.middlewares[i] + second := bot.middlewares[j] if first.order == second.order { return first.name < second.name } return first.order < second.order }) - return b + return bot } -func (b *Bot) AddRunner(runner Runner) *Bot { - b.runners = append(b.runners, runner) - b.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.Name)) - return b +func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { + bot.runners = append(bot.runners, runner) + bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) + return bot } -func (b *Bot) AddL10n(l L10n) *Bot { - b.l10n = l - return b -} -func (b *Bot) L10n(lang, key string) string { - return b.l10n.Translate(lang, key) -} -func (b *Bot) Logger() *slog.Logger { - return b.logger -} -func (b *Bot) GetDBContext() *DatabaseContext { - return b.dbContext +func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { + bot.l10n = l + return bot } -func (b *Bot) Run() { - if len(b.prefixes) == 0 { - b.logger.Fatalln("no prefixes defined") +func (bot *Bot[T]) Run() { + if len(bot.prefixes) == 0 { + bot.logger.Fatalln("no prefixes defined") return } - if len(b.plugins) == 0 { - b.logger.Fatalln("no plugins defined") + if len(bot.plugins) == 0 { + bot.logger.Fatalln("no plugins defined") return } - b.ExecRunners() + bot.ExecRunners() - b.logger.Infoln("Bot running. Press CTRL+C to exit.") + bot.logger.Infoln("Bot running. Press CTRL+C to exit.") go func() { for { - _, err := b.Updates() + _, err := bot.Updates() if err != nil { - b.logger.Errorln(err) + bot.logger.Errorln(err) } } }() for { - queue := b.updateQueue + queue := bot.updateQueue if queue.IsEmpty() { time.Sleep(time.Millisecond * 25) continue @@ -263,19 +272,10 @@ func (b *Bot) Run() { u := queue.Dequeue() if u == nil { - b.logger.Errorln("update is nil") + bot.logger.Errorln("update is nil") continue } - ctx := &MsgContext{Bot: b, Update: *u, Api: b.api} - for _, middleware := range b.middlewares { - middleware.Execute(ctx, b.dbContext) - } - - if u.CallbackQuery != nil { - b.handleCallback(u, ctx) - } else { - b.handleMessage(u, ctx) - } + bot.handle(u) } } diff --git a/cmd_generator.go b/cmd_generator.go index b4f8c35..4e6153b 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -8,7 +8,7 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) -func generateBotCommand(cmd Command) tgapi.BotCommand { +func generateBotCommand[T any](cmd Command[T]) tgapi.BotCommand { desc := cmd.command if len(cmd.description) > 0 { desc = cmd.description @@ -25,7 +25,7 @@ func generateBotCommand(cmd Command) tgapi.BotCommand { return tgapi.BotCommand{Command: cmd.command, Description: desc} } -func generateBotCommandForPlugin(pl Plugin) []tgapi.BotCommand { +func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, cmd := range pl.Commands { if cmd.skipAutoCmd { @@ -38,14 +38,14 @@ func generateBotCommandForPlugin(pl Plugin) []tgapi.BotCommand { var ErrTooManyCommands = errors.New("too many commands. max 100") -func (b *Bot) AutoGenerateCommands() error { - _, err := b.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) +func (bot *Bot[T]) AutoGenerateCommands() error { + _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) if err != nil { return err } commands := make([]tgapi.BotCommand, 0) - for _, pl := range b.plugins { + for _, pl := range bot.plugins { commands = append(commands, generateBotCommandForPlugin(pl)...) } if len(commands) > 100 { @@ -55,14 +55,14 @@ func (b *Bot) AutoGenerateCommands() error { privateChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType} groupChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeGroupType} chatAdminsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeAllChatAdministratorsType} - _, err = b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: privateChatsScope}) + _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: privateChatsScope}) if err != nil { return err } - _, err = b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: groupChatsScope}) + _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: groupChatsScope}) if err != nil { return err } - _, err = b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: chatAdminsScope}) + _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: chatAdminsScope}) return err } diff --git a/go.mod b/go.mod index bf03cd1..30d247f 100644 --- a/go.mod +++ b/go.mod @@ -5,27 +5,11 @@ go 1.26 require ( git.nix13.pw/scuroneko/extypes v1.2.0 git.nix13.pw/scuroneko/slog v1.0.2 - github.com/redis/go-redis/v9 v9.18.0 - github.com/vinovest/sqlx v1.7.1 - go.mongodb.org/mongo-driver/v2 v2.5.0 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fatih/color v1.18.0 // indirect - github.com/klauspost/compress v1.18.4 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/muir/list v1.2.1 // indirect - github.com/muir/sqltoken v0.3.0 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.2.0 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 2368236..6e6f7ae 100644 --- a/go.sum +++ b/go.sum @@ -1,95 +1,13 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= git.nix13.pw/scuroneko/extypes v1.2.0 h1:2n2hD6KsMAted+6MGhAyeWyli2Qzc9G2y+pQNB7C1dM= git.nix13.pw/scuroneko/extypes v1.2.0/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= -github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/muir/list v1.2.1 h1:lmF8fz2B1WbXkzHr/Eh0oWPJArDBzWqIifOwbA4gWSo= -github.com/muir/list v1.2.1/go.mod h1:v0l2f997MxCohQlD7PTejJqyYKwFVz/i3mTpDl4LAf0= -github.com/muir/sqltoken v0.3.0 h1:3xbcqr80f3IA4OlwkOpdIHC4DTu6gsi1TwMqgYL4Dpg= -github.com/muir/sqltoken v0.3.0/go.mod h1:+OSmbGI22QcVZ6DCzlHT8EAzEq/mqtqedtPP91Le+3A= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/vinovest/sqlx v1.7.1 h1:kdq4v0N9kRLpytWGSWOw4aulOGdQPmIoMR6Y+cTBxow= -github.com/vinovest/sqlx v1.7.1/go.mod h1:3fAv74r4iDMv2PpFomADb+vex5ukzfYn4GseC9KngD8= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= -github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler.go b/handler.go index b774770..7fadb3e 100644 --- a/handler.go +++ b/handler.go @@ -8,20 +8,20 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) -func (b *Bot) handle(u *tgapi.Update) { - ctx := &MsgContext{Bot: b, Update: *u, Api: b.api} - for _, middleware := range b.middlewares { - middleware.Execute(ctx, b.dbContext) +func (bot *Bot[T]) handle(u *tgapi.Update) { + ctx := &MsgContext{Update: *u, Api: bot.api, botLogger: bot.logger, errorTemplate: bot.errorTemplate, l10n: bot.l10n} + for _, middleware := range bot.middlewares { + middleware.Execute(ctx, bot.dbContext) } if u.CallbackQuery != nil { - b.handleCallback(u, ctx) + bot.handleCallback(u, ctx) } else { - b.handleMessage(u, ctx) + bot.handleMessage(u, ctx) } } -func (b *Bot) handleMessage(update *tgapi.Update, ctx *MsgContext) { +func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { if update.Message == nil { return } @@ -34,7 +34,7 @@ func (b *Bot) handleMessage(update *tgapi.Update, ctx *MsgContext) { } text = strings.TrimSpace(text) - prefix, hasPrefix := b.checkPrefixes(text) + prefix, hasPrefix := bot.checkPrefixes(text) if !hasPrefix { return } @@ -45,7 +45,7 @@ func (b *Bot) handleMessage(update *tgapi.Update, ctx *MsgContext) { text = strings.TrimSpace(text[len(prefix):]) - for _, plugin := range b.plugins { + for _, plugin := range bot.plugins { for cmd := range plugin.Commands { if !strings.HasPrefix(text, cmd) { continue @@ -71,20 +71,20 @@ func (b *Bot) handleMessage(update *tgapi.Update, ctx *MsgContext) { ctx.Args = strings.Split(ctx.Text, " ") } - if !plugin.executeMiddlewares(ctx, b.dbContext) { + if !plugin.executeMiddlewares(ctx, bot.dbContext) { return } - go plugin.executeCmd(cmd, ctx, b.dbContext) + go plugin.executeCmd(cmd, ctx, bot.dbContext) return } } } -func (b *Bot) handleCallback(update *tgapi.Update, ctx *MsgContext) { +func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { data := new(CallbackData) err := json.Unmarshal([]byte(update.CallbackQuery.Data), data) if err != nil { - b.logger.Errorln(err) + bot.logger.Errorln(err) return } @@ -95,22 +95,22 @@ func (b *Bot) handleCallback(update *tgapi.Update, ctx *MsgContext) { ctx.CallbackQueryId = update.CallbackQuery.ID ctx.Args = data.Args - for _, plugin := range b.plugins { + for _, plugin := range bot.plugins { _, ok := plugin.Payloads[data.Command] if !ok { continue } - if !plugin.executeMiddlewares(ctx, b.dbContext) { + if !plugin.executeMiddlewares(ctx, bot.dbContext) { return } - go plugin.executePayload(data.Command, ctx, b.dbContext) + go plugin.executePayload(data.Command, ctx, bot.dbContext) return } } -func (b *Bot) checkPrefixes(text string) (string, bool) { - for _, prefix := range b.prefixes { +func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { + for _, prefix := range bot.prefixes { if strings.HasPrefix(text, prefix) { return prefix, true } diff --git a/l10n.go b/l10n.go index a74f32f..48d7fbb 100644 --- a/l10n.go +++ b/l10n.go @@ -17,7 +17,6 @@ func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n { func (l *L10n) GetFallbackLanguage() string { return l.fallbackLang } - func (l *L10n) Translate(lang, key string) string { s, ok := l.entries[key] if !ok { diff --git a/methods.go b/methods.go index d2a1f10..f6bf661 100644 --- a/methods.go +++ b/methods.go @@ -9,39 +9,39 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) -func (b *Bot) Updates() ([]tgapi.Update, error) { - offset := b.GetUpdateOffset() +func (bot *Bot[T]) Updates() ([]tgapi.Update, error) { + offset := bot.GetUpdateOffset() params := tgapi.UpdateParams{ Offset: Ptr(offset), Timeout: Ptr(30), - AllowedUpdates: b.GetUpdateTypes(), + AllowedUpdates: bot.GetUpdateTypes(), } - updates, err := b.api.GetUpdates(params) + updates, err := bot.api.GetUpdates(params) if err != nil { return nil, err } for _, u := range updates { - b.SetUpdateOffset(u.UpdateID + 1) - err = b.GetQueue().Enqueue(&u) + bot.SetUpdateOffset(u.UpdateID + 1) + err = bot.GetQueue().Enqueue(&u) if err != nil { return nil, err } - if b.RequestLogger != nil { + if bot.RequestLogger != nil { j, err := json.Marshal(u) if err != nil { - b.Logger().Error(err) + bot.GetLogger().Error(err) } - b.RequestLogger.Debugf("UPDATE %s\n", j) + bot.RequestLogger.Debugf("UPDATE %s\n", j) } } return updates, err } -func (b *Bot) GetFileByLink(link string) ([]byte, error) { - u := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", b.token, link) +func (bot *Bot[T]) GetFileByLink(link string) ([]byte, error) { + u := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.token, link) res, err := http.Get(u) if err != nil { return nil, err diff --git a/msg_context.go b/msg_context.go index c20c249..b0cfaf4 100644 --- a/msg_context.go +++ b/msg_context.go @@ -5,10 +5,10 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/laniakea/utils" + "git.nix13.pw/scuroneko/slog" ) type MsgContext struct { - Bot *Bot Api *tgapi.API Msg *tgapi.Message @@ -20,6 +20,10 @@ type MsgContext struct { Prefix string Text string Args []string + + errorTemplate string + botLogger *slog.Logger + l10n *L10n } type AnswerMessage struct { @@ -41,7 +45,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard } msg, _, err := ctx.Api.EditMessageText(params) if err != nil { - ctx.Api.Logger.Errorln(err) + ctx.botLogger.Errorln(err) return nil } return &AnswerMessage{ @@ -53,7 +57,7 @@ func (m *AnswerMessage) Edit(text string) *AnswerMessage { } func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage { if ctx.CallbackMsgId == 0 { - ctx.Api.Logger.Errorln("Can't edit non-callback update message") + ctx.botLogger.Errorln("Can't edit non-callback update message") return nil } @@ -75,7 +79,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo } msg, _, err := ctx.Api.EditMessageCaption(params) if err != nil { - ctx.Api.Logger.Errorln(err) + ctx.botLogger.Errorln(err) } return &AnswerMessage{ MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true, @@ -83,7 +87,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo } func (m *AnswerMessage) EditCaption(text string) *AnswerMessage { if m.MessageID == 0 { - m.ctx.Api.Logger.Errorln("Can't edit caption message, message id is zero") + m.ctx.botLogger.Errorln("Can't edit caption message, message id is zero") return m } return m.ctx.editPhotoText(m.MessageID, text, nil) @@ -104,7 +108,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard) *AnswerMess msg, err := ctx.Api.SendMessage(params) if err != nil { - ctx.Api.Logger.Errorln(err) + ctx.botLogger.Errorln(err) return nil } return &AnswerMessage{ @@ -133,7 +137,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard) *An } msg, err := ctx.Api.SendPhoto(params) if err != nil { - ctx.Api.Logger.Errorln(err) + ctx.botLogger.Errorln(err) return &AnswerMessage{ ctx: ctx, Text: text, IsMedia: true, } @@ -155,7 +159,7 @@ func (ctx *MsgContext) delete(messageId int) { MessageID: messageId, }) if err != nil { - ctx.Api.Logger.Errorln(err) + ctx.botLogger.Errorln(err) } } func (m *AnswerMessage) Delete() { @@ -174,7 +178,7 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { Text: text, ShowAlert: showAlert, URL: url, }) if err != nil { - ctx.Api.Logger.Errorln(err) + ctx.botLogger.Errorln(err) } } func (ctx *MsgContext) AnswerCbQuery() { @@ -195,19 +199,19 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { ChatID: ctx.Msg.Chat.ID, Action: action, }) if err != nil { - ctx.Api.Logger.Errorln(err) + ctx.botLogger.Errorln(err) } } func (ctx *MsgContext) error(err error) { - text := fmt.Sprintf(ctx.Bot.errorTemplate, utils.EscapeMarkdown(err.Error())) + text := fmt.Sprintf(ctx.errorTemplate, utils.EscapeMarkdown(err.Error())) if ctx.CallbackQueryId != "" { ctx.answerCallbackQuery("", text, false) } else { ctx.answer(text, nil) } - ctx.Bot.Logger().Errorln(err) + ctx.botLogger.Errorln(err) } func (ctx *MsgContext) Error(err error) { ctx.error(err) @@ -217,6 +221,6 @@ func (ctx *MsgContext) Translate(key string) string { if ctx.From == nil { return key } - lang := Val(ctx.From.LanguageCode, ctx.Bot.l10n.GetFallbackLanguage()) - return ctx.Bot.L10n(lang, key) + lang := Val(ctx.From.LanguageCode, ctx.l10n.GetFallbackLanguage()) + return ctx.l10n.Translate(lang, key) } diff --git a/plugins.go b/plugins.go index c01b6f4..312c386 100644 --- a/plugins.go +++ b/plugins.go @@ -45,32 +45,33 @@ func (c *CommandArg) SetRequired() *CommandArg { return c } -type CommandExecutor func(ctx *MsgContext, dbContext *DatabaseContext) -type Command struct { +type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext *T) + +type Command[T DbContext] struct { command string description string - exec CommandExecutor + exec CommandExecutor[T] args extypes.Slice[CommandArg] - middlewares extypes.Slice[Middleware] + middlewares extypes.Slice[Middleware[T]] skipAutoCmd bool } -func NewCommand(exec CommandExecutor, command string, args ...CommandArg) *Command { - return &Command{command, "", exec, args, make(extypes.Slice[Middleware], 0), false} +func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { + return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} } -func (c *Command) Use(m Middleware) *Command { +func (c *Command[T]) Use(m Middleware[T]) *Command[T] { c.middlewares = c.middlewares.Push(m) return c } -func (c *Command) SetDescription(desc string) *Command { +func (c *Command[T]) SetDescription(desc string) *Command[T] { c.description = desc return c } -func (c *Command) SkipCommandAutoGen() *Command { +func (c *Command[T]) SkipCommandAutoGen() *Command[T] { c.skipAutoCmd = true return c } -func (c *Command) validateArgs(args []string) error { +func (c *Command[T]) validateArgs(args []string) error { cmdArgs := c.args.Filter(func(e CommandArg) bool { return !e.required }) if len(args) < cmdArgs.Len() { return ErrCmdArgCountMismatch @@ -91,37 +92,37 @@ func (c *Command) validateArgs(args []string) error { return nil } -type Plugin struct { +type Plugin[T DbContext] struct { Name string - Commands map[string]Command - Payloads map[string]Command - Middlewares extypes.Slice[Middleware] + Commands map[string]Command[T] + Payloads map[string]Command[T] + Middlewares extypes.Slice[Middleware[T]] } -func NewPlugin(name string) *Plugin { - return &Plugin{ - name, map[string]Command{}, - map[string]Command{}, extypes.Slice[Middleware]{}, +func NewPlugin[T DbContext](name string) *Plugin[T] { + return &Plugin[T]{ + name, map[string]Command[T]{}, + map[string]Command[T]{}, extypes.Slice[Middleware[T]]{}, } } -func (p *Plugin) AddCommand(command *Command) *Plugin { +func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { p.Commands[command.command] = *command return p } -func (p *Plugin) NewCommand(exec CommandExecutor, command string, args ...CommandArg) *Command { +func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { return NewCommand(exec, command, args...) } -func (p *Plugin) AddPayload(command *Command) *Plugin { +func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { p.Payloads[command.command] = *command return p } -func (p *Plugin) AddMiddleware(middleware Middleware) *Plugin { +func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] { p.Middlewares = p.Middlewares.Push(middleware) return p } -func (p *Plugin) executeCmd(cmd string, ctx *MsgContext, dbContext *DatabaseContext) { +func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { command := p.Commands[cmd] if err := command.validateArgs(ctx.Args); err != nil { ctx.error(err) @@ -129,7 +130,7 @@ func (p *Plugin) executeCmd(cmd string, ctx *MsgContext, dbContext *DatabaseCont } command.exec(ctx, dbContext) } -func (p *Plugin) executePayload(payload string, ctx *MsgContext, dbContext *DatabaseContext) { +func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) { pl := p.Payloads[payload] if err := pl.validateArgs(ctx.Args); err != nil { ctx.error(err) @@ -137,7 +138,7 @@ func (p *Plugin) executePayload(payload string, ctx *MsgContext, dbContext *Data } pl.exec(ctx, dbContext) } -func (p *Plugin) executeMiddlewares(ctx *MsgContext, db *DatabaseContext) bool { +func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { for _, m := range p.Middlewares { if !m.Execute(ctx, db) { return false @@ -146,29 +147,29 @@ func (p *Plugin) executeMiddlewares(ctx *MsgContext, db *DatabaseContext) bool { return true } -type MiddlewareExecutor func(ctx *MsgContext, db *DatabaseContext) bool +type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db *T) bool // Middleware // When async, returned value ignored -type Middleware struct { +type Middleware[T DbContext] struct { name string - executor MiddlewareExecutor + executor MiddlewareExecutor[T] order int async bool } -func NewMiddleware(name string, executor MiddlewareExecutor) *Middleware { - return &Middleware{name, executor, 0, false} +func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] { + return &Middleware[T]{name, executor, 0, false} } -func (m *Middleware) SetOrder(order int) *Middleware { +func (m *Middleware[T]) SetOrder(order int) *Middleware[T] { m.order = order return m } -func (m *Middleware) SetAsync(async bool) *Middleware { +func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { m.async = async return m } -func (m *Middleware) Execute(ctx *MsgContext, db *DatabaseContext) bool { +func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool { if m.async { go m.executor(ctx, db) return true diff --git a/runners.go b/runners.go index 488a008..6f273c7 100644 --- a/runners.go +++ b/runners.go @@ -4,81 +4,69 @@ import ( "time" ) -type RunnerFn func(*Bot) error -type RunnerBuilder struct { +type RunnerFn[T DbContext] func(*Bot[T]) error +type Runner[T DbContext] struct { name string onetime bool async bool timeout time.Duration - fn RunnerFn -} -type Runner struct { - Name string - Onetime bool - Async bool - Timeout time.Duration - Fn RunnerFn + fn RunnerFn[T] } -func NewRunner(name string, fn RunnerFn) *RunnerBuilder { - return &RunnerBuilder{ +func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { + return &Runner[T]{ name: name, fn: fn, async: true, } } -func (b *RunnerBuilder) Onetime(onetime bool) *RunnerBuilder { +func (b *Runner[T]) Onetime(onetime bool) *Runner[T] { b.onetime = onetime return b } -func (b *RunnerBuilder) Async(async bool) *RunnerBuilder { +func (b *Runner[T]) Async(async bool) *Runner[T] { b.async = async return b } -func (b *RunnerBuilder) Timeout(timeout time.Duration) *RunnerBuilder { +func (b *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { b.timeout = timeout return b } -func (b *RunnerBuilder) Build() Runner { - return Runner{ - Name: b.name, Onetime: b.onetime, Async: b.async, Fn: b.fn, Timeout: b.timeout, - } -} -func (b *Bot) ExecRunners() { - b.logger.Infoln("Executing runners...") - for _, runner := range b.runners { - if !runner.Onetime && !runner.Async { - b.logger.Warnf("Runner %s not onetime, but sync\n", runner.Name) +func (bot *Bot[T]) ExecRunners() { + bot.logger.Infoln("Executing runners...") + for _, runner := range bot.runners { + if !runner.onetime && !runner.async { + bot.logger.Warnf("Runner %s not onetime, but sync\n", runner.name) continue } - if !runner.Onetime && runner.Async && runner.Timeout == (time.Second*0) { - b.logger.Warnf("Background runner \"%s\" should have timeout", runner.Name) + if !runner.onetime && runner.async && runner.timeout == (time.Second*0) { + bot.logger.Warnf("Background runner \"%s\" should have timeout", runner.name) } - if runner.Async && runner.Onetime { + if runner.async && runner.onetime { go func() { - err := runner.Fn(b) + err := runner.fn(bot) if err != nil { - b.logger.Warnf("Runner %s failed: %s\n", runner.Name, err) + bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) } }() - } else if !runner.Async && runner.Onetime { + } else if !runner.async && runner.onetime { t := time.Now() - err := runner.Fn(b) + err := runner.fn(bot) if err != nil { - b.logger.Warnf("Runner %s failed: %s\n", runner.Name, err) + bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) } elapsed := time.Since(t) if elapsed > time.Second*2 { - b.logger.Warnf("Runner %s too slow. Elapsed time %s>=2s", runner.Name, elapsed) + bot.logger.Warnf("Runner %s too slow. Elapsed time %s>=2s", runner.name, elapsed) } - } else if !runner.Onetime { + } else if !runner.onetime { go func() { for { - err := runner.Fn(b) + err := runner.fn(bot) if err != nil { - b.logger.Warnf("Runner %s failed: %s\n", runner.Name, err) + bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) } - time.Sleep(runner.Timeout) + time.Sleep(runner.timeout) } }() } diff --git a/tgapi/api.go b/tgapi/api.go index cafa5b8..78876b6 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -43,7 +43,7 @@ func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts { type API struct { token string client *http.Client - Logger *slog.Logger + logger *slog.Logger useTestServer bool apiUrl string } @@ -57,9 +57,8 @@ func NewAPI(opts *APIOpts) *API { } return &API{opts.token, client, l, opts.useTestServer, opts.apiUrl} } -func (api *API) CloseApi() error { - return api.Logger.Close() -} +func (api *API) CloseApi() error { return api.logger.Close() } +func (api *API) GetLogger() *slog.Logger { return api.logger } type ApiResponse[R any] struct { Ok bool `json:"ok"` @@ -67,7 +66,6 @@ type ApiResponse[R any] struct { Result R `json:"result,omitempty"` ErrorCode int `json:"error_code,omitempty"` } - type TelegramRequest[R, P any] struct { method string params P @@ -97,7 +95,7 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) - api.Logger.Debugln("REQ", api.apiUrl, r.method, buf.String()) + api.logger.Debugln("REQ", api.apiUrl, r.method, buf.String()) res, err := api.client.Do(req) if err != nil { return zero, err @@ -110,7 +108,7 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, if err != nil { return zero, err } - api.Logger.Debugln("RES", r.method, string(data)) + api.logger.Debugln("RES", r.method, string(data)) if res.StatusCode != http.StatusOK { return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(data)) } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index ec0f9d7..23424c0 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -51,7 +51,8 @@ func NewUploader(api *API) *Uploader { logger.AddWriter(logger.CreateJsonStdoutWriter()) return &Uploader{api, logger} } -func (u *Uploader) Close() error { return u.logger.Close() } +func (u *Uploader) Close() error { return u.logger.Close() } +func (u *Uploader) GetLogger() *slog.Logger { return u.logger } type UploaderRequest[R, P any] struct { method string diff --git a/utils.go b/utils.go index debe5f1..0afbcfc 100644 --- a/utils.go +++ b/utils.go @@ -3,14 +3,13 @@ package laniakea import "git.nix13.pw/scuroneko/laniakea/utils" func Ptr[T any](v T) *T { return &v } - func Val[T any](p *T, def T) T { if p != nil { return *p } return def } +func EscapeMarkdown(s string) string { return utils.EscapeMarkdown(s) } +func EscapeMarkdownV2(s string) string { return utils.EscapeMarkdownV2(s) } const VersionString = utils.VersionString - -var EscapeMarkdown = utils.EscapeMarkdown diff --git a/utils/version.go b/utils/version.go index 6c265e9..04a2bef 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,8 +1,9 @@ package utils const ( - VersionString = "0.7.1" + VersionString = "0.8.0-beta.1" VersionMajor = 0 - VersionMinor = 7 - VersionPatch = 1 + VersionMinor = 8 + VersionPatch = 0 + Beta = 1 ) -- 2.54.0 From 1bf749949649ca1cdefadef24e8d8fe4af6b4e6b Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 19 Feb 2026 13:33:27 +0300 Subject: [PATCH 13/99] 0.8.0 beta 2 --- methods.go | 13 ------------- tgapi/methods.go | 16 ++++++++++++++++ utils/version.go | 4 ++-- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/methods.go b/methods.go index f6bf661..80280b9 100644 --- a/methods.go +++ b/methods.go @@ -2,9 +2,6 @@ package laniakea import ( "encoding/json" - "fmt" - "io" - "net/http" "git.nix13.pw/scuroneko/laniakea/tgapi" ) @@ -39,13 +36,3 @@ func (bot *Bot[T]) Updates() ([]tgapi.Update, error) { } return updates, err } - -func (bot *Bot[T]) GetFileByLink(link string) ([]byte, error) { - u := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.token, link) - res, err := http.Get(u) - if err != nil { - return nil, err - } - defer res.Body.Close() - return io.ReadAll(res.Body) -} diff --git a/tgapi/methods.go b/tgapi/methods.go index bf0635f..95956d8 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -1,5 +1,11 @@ package tgapi +import ( + "fmt" + "io" + "net/http" +) + type ParseMode string const ( @@ -44,3 +50,13 @@ func (api *API) GetFile(params GetFileP) (File, error) { req := NewRequest[File]("getFile", params) return req.Do(api) } + +func (api *API) GetFileByLink(link string) ([]byte, error) { + u := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", api.token, link) + res, err := http.Get(u) + if err != nil { + return nil, err + } + defer res.Body.Close() + return io.ReadAll(res.Body) +} diff --git a/utils/version.go b/utils/version.go index 04a2bef..fcbae7f 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "0.8.0-beta.1" + VersionString = "0.8.0-beta.2" VersionMajor = 0 VersionMinor = 8 VersionPatch = 0 - Beta = 1 + Beta = 2 ) -- 2.54.0 From da122a3be476254553d7e02e8a9d3b50ffcdba2a Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 19 Feb 2026 13:58:34 +0300 Subject: [PATCH 14/99] 0.8.0 beta 3 --- bot.go | 37 ++++++++++++++++++++----------------- utils/version.go | 4 ++-- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/bot.go b/bot.go index 2d91cfa..efb9296 100644 --- a/bot.go +++ b/bot.go @@ -47,6 +47,7 @@ func LoadPrefixesFromEnv() []string { } type DbContext interface{} +type NoDB struct{ DbContext } type Bot[T DbContext] struct { token string debug bool @@ -65,7 +66,7 @@ type Bot[T DbContext] struct { dbContext *T l10n *L10n - dbWriterRequested extypes.Slice[*slog.Logger] + extraLoggers extypes.Slice[*slog.Logger] updateOffset int updateTypes []tgapi.UpdateType @@ -81,21 +82,21 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { uploader := tgapi.NewUploader(api) bot := &Bot[T]{ - updateOffset: 0, - errorTemplate: "%s", - updateQueue: updateQueue, - api: api, - uploader: uploader, - debug: opts.Debug, - prefixes: opts.Prefixes, - token: opts.Token, - plugins: make([]Plugin[T], 0), - updateTypes: make([]tgapi.UpdateType, 0), - runners: make([]Runner[T], 0), - dbWriterRequested: make([]*slog.Logger, 0), - l10n: &L10n{}, + updateOffset: 0, + errorTemplate: "%s", + updateQueue: updateQueue, + api: api, + uploader: uploader, + debug: opts.Debug, + prefixes: opts.Prefixes, + token: opts.Token, + plugins: make([]Plugin[T], 0), + updateTypes: make([]tgapi.UpdateType, 0), + runners: make([]Runner[T], 0), + extraLoggers: make([]*slog.Logger, 0), + l10n: &L10n{}, } - bot.dbWriterRequested = bot.dbWriterRequested.Push(api.GetLogger()).Push(uploader.GetLogger()) + bot.extraLoggers = bot.extraLoggers.Push(api.GetLogger()).Push(uploader.GetLogger()) if len(opts.ErrorTemplate) > 0 { bot.errorTemplate = opts.ErrorTemplate @@ -169,13 +170,15 @@ func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } -func (bot *Bot[T]) AddDatabaseLogger(writer func(db *T) slog.LoggerWriter) *Bot[T] { +type DbLogger[T DbContext] func(db *T) slog.LoggerWriter + +func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { w := writer(bot.dbContext) bot.logger.AddWriter(w) if bot.RequestLogger != nil { bot.RequestLogger.AddWriter(w) } - for _, l := range bot.dbWriterRequested { + for _, l := range bot.extraLoggers { l.AddWriter(w) } return bot diff --git a/utils/version.go b/utils/version.go index fcbae7f..0630841 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "0.8.0-beta.2" + VersionString = "0.8.0-beta.3" VersionMajor = 0 VersionMinor = 8 VersionPatch = 0 - Beta = 2 + Beta = 3 ) -- 2.54.0 From 28ec2b7ca9ea91bd1d3f1ae4ba28b99be3db613e Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 26 Feb 2026 14:31:03 +0300 Subject: [PATCH 15/99] 0.8.0 beta 4 --- bot.go | 61 +++++++++++++++++++--------- go.mod | 2 + go.sum | 4 ++ tgapi/api.go | 67 +++++++++++++++++++++++++++++-- tgapi/pool.go | 92 +++++++++++++++++++++++++++++++++++++++++++ tgapi/uploader_api.go | 49 +++++++++++++++++++---- utils/version.go | 4 +- 7 files changed, 248 insertions(+), 31 deletions(-) create mode 100644 tgapi/pool.go diff --git a/bot.go b/bot.go index efb9296..8a1a8e6 100644 --- a/bot.go +++ b/bot.go @@ -4,38 +4,58 @@ import ( "fmt" "os" "sort" + "strconv" "strings" "time" "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/slog" + "golang.org/x/time/rate" ) type BotOpts struct { - Token string - Debug bool - ErrorTemplate string - Prefixes []string - UpdateTypes []string + Token string + UpdateTypes []string + + Debug bool + ErrorTemplate string + Prefixes []string + LoggerBasePath string UseRequestLogger bool WriteToFile bool - UseTestServer bool - APIUrl string + + UseTestServer bool + APIUrl string + + RateLimit int + DropRLOverflow bool } +func NewOpts() *BotOpts { return new(BotOpts) } func LoadOptsFromEnv() *BotOpts { + rateLimit := 30 + if rl := os.Getenv("RATE_LIMIT"); rl != "" { + rateLimit, _ = strconv.Atoi(rl) + } + return &BotOpts{ - Token: os.Getenv("TG_TOKEN"), - Debug: os.Getenv("DEBUG") == "true", - ErrorTemplate: os.Getenv("ERROR_TEMPLATE"), - Prefixes: LoadPrefixesFromEnv(), - UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"), + Token: os.Getenv("TG_TOKEN"), + UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"), + + Debug: os.Getenv("DEBUG") == "true", + ErrorTemplate: os.Getenv("ERROR_TEMPLATE"), + Prefixes: LoadPrefixesFromEnv(), + UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true", WriteToFile: os.Getenv("WRITE_TO_FILE") == "true", - UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", - APIUrl: os.Getenv("API_URL"), + + UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", + APIUrl: os.Getenv("API_URL"), + + RateLimit: rateLimit, + DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", } } func LoadPrefixesFromEnv() []string { @@ -55,6 +75,7 @@ type Bot[T DbContext] struct { logger *slog.Logger RequestLogger *slog.Logger + extraLoggers extypes.Slice[*slog.Logger] plugins []Plugin[T] middlewares []Middleware[T] @@ -66,8 +87,6 @@ type Bot[T DbContext] struct { dbContext *T l10n *L10n - extraLoggers extypes.Slice[*slog.Logger] - updateOffset int updateTypes []tgapi.UpdateType updateQueue *extypes.Queue[*tgapi.Update] @@ -76,7 +95,12 @@ type Bot[T DbContext] struct { func NewBot[T any](opts *BotOpts) *Bot[T] { updateQueue := extypes.CreateQueue[*tgapi.Update](512) - apiOpts := tgapi.NewAPIOpts(opts.Token).SetAPIUrl(opts.APIUrl).UseTestServer(opts.UseTestServer) + var limiter *rate.Limiter + if opts.RateLimit > 0 { + limiter = rate.NewLimiter(rate.Limit(opts.RateLimit), opts.RateLimit) + } + + apiOpts := tgapi.NewAPIOpts(opts.Token).SetAPIUrl(opts.APIUrl).UseTestServer(opts.UseTestServer).SetLimiter(limiter) api := tgapi.NewAPI(apiOpts) uploader := tgapi.NewUploader(api) @@ -108,8 +132,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { u, err := api.GetMe() if err != nil { - _ = api.CloseApi() - _ = uploader.Close() + _ = bot.Close() bot.logger.Fatal(err) } bot.logger.Infof("Authorized as %s\n", u.FirstName) diff --git a/go.mod b/go.mod index 30d247f..f6a0218 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.26 require ( git.nix13.pw/scuroneko/extypes v1.2.0 git.nix13.pw/scuroneko/slog v1.0.2 + golang.org/x/time v0.14.0 ) require ( + github.com/alitto/pond/v2 v2.6.2 // indirect github.com/fatih/color v1.18.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 6e6f7ae..ac69590 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ git.nix13.pw/scuroneko/extypes v1.2.0 h1:2n2hD6KsMAted+6MGhAyeWyli2Qzc9G2y+pQNB7 git.nix13.pw/scuroneko/extypes v1.2.0/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= +github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw= +github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -11,3 +13,5 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/tgapi/api.go b/tgapi/api.go index 78876b6..8422b20 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -11,6 +12,7 @@ import ( "git.nix13.pw/scuroneko/laniakea/utils" "git.nix13.pw/scuroneko/slog" + "golang.org/x/time/rate" ) type APIOpts struct { @@ -18,8 +20,13 @@ type APIOpts struct { client *http.Client useTestServer bool apiUrl string + + limiter *rate.Limiter + dropOverflowLimit bool } +var ErrPoolUnexpected = errors.New("unexpected response from pool") + func NewAPIOpts(token string) *APIOpts { return &APIOpts{token: token, client: nil, useTestServer: false, apiUrl: "https://api.telegram.org"} } @@ -39,6 +46,14 @@ func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts { } return opts } +func (opts *APIOpts) SetLimiter(limiter *rate.Limiter) *APIOpts { + opts.limiter = limiter + return opts +} +func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts { + opts.dropOverflowLimit = b + return opts +} type API struct { token string @@ -46,6 +61,10 @@ type API struct { logger *slog.Logger useTestServer bool apiUrl string + + pool *WorkerPool + limiter *rate.Limiter + dropOverflowLimit bool } func NewAPI(opts *APIOpts) *API { @@ -55,9 +74,18 @@ func NewAPI(opts *APIOpts) *API { if client == nil { client = &http.Client{Timeout: time.Second * 45} } - return &API{opts.token, client, l, opts.useTestServer, opts.apiUrl} + pool := NewWorkerPool(16, 256) + pool.Start(context.Background()) + return &API{ + opts.token, client, l, + opts.useTestServer, opts.apiUrl, + pool, opts.limiter, opts.dropOverflowLimit, + } +} +func (api *API) CloseApi() error { + api.pool.Stop() + return api.logger.Close() } -func (api *API) CloseApi() error { return api.logger.Close() } func (api *API) GetLogger() *slog.Logger { return api.logger } type ApiResponse[R any] struct { @@ -74,8 +102,20 @@ type TelegramRequest[R, P any] struct { func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { return TelegramRequest[R, P]{method: method, params: params} } -func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, error) { +func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { var zero R + if api.limiter != nil { + if api.dropOverflowLimit { + if !api.limiter.Allow() { + return zero, errors.New("rate limited") + } + } else { + if err := api.limiter.Wait(ctx); err != nil { + return zero, err + } + } + } + data, err := json.Marshal(r.params) if err != nil { return zero, err @@ -113,7 +153,28 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(data)) } return parseBody[R](data) +} +func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, error) { + var zero R + result, err := api.pool.Submit(ctx, func(ctx context.Context) (any, error) { + return r.doRequest(ctx, api) + }) + if err != nil { + return zero, err + } + select { + case <-ctx.Done(): + return zero, ctx.Err() + case res := <-result: + if res.Err != nil { + return zero, res.Err + } + if val, ok := res.Value.(R); ok { + return val, nil + } + return zero, ErrPoolUnexpected + } } func (r TelegramRequest[R, P]) Do(api *API) (R, error) { return r.DoWithContext(context.Background(), api) diff --git a/tgapi/pool.go b/tgapi/pool.go new file mode 100644 index 0000000..79a126e --- /dev/null +++ b/tgapi/pool.go @@ -0,0 +1,92 @@ +package tgapi + +import ( + "context" + "errors" + "sync" +) + +var ErrPoolQueueFull = errors.New("worker pool queue full") + +type RequestEnvelope struct { + DoFunc func(context.Context) (any, error) // функция, которая выполнит запрос и вернет any + ResultCh chan RequestResult // канал для результата +} +type RequestResult struct { + Value any + Err error +} + +// WorkerPool управляет воркерами и очередью +type WorkerPool struct { + taskCh chan RequestEnvelope + queueSize int + workers int + wg sync.WaitGroup + quit chan struct{} + started bool + startedMu sync.Mutex +} + +func NewWorkerPool(workers int, queueSize int) *WorkerPool { + return &WorkerPool{ + taskCh: make(chan RequestEnvelope, queueSize), + queueSize: queueSize, + workers: workers, + quit: make(chan struct{}), + } +} + +// Start запускает воркеров +func (p *WorkerPool) Start(ctx context.Context) { + p.startedMu.Lock() + defer p.startedMu.Unlock() + if p.started { + return + } + p.started = true + for i := 0; i < p.workers; i++ { + p.wg.Add(1) + go p.worker(ctx) + } +} + +// Stop останавливает пул (ждет завершения текущих задач) +func (p *WorkerPool) Stop() { + close(p.quit) + p.wg.Wait() +} + +// Submit отправляет задачу в очередь и возвращает канал для результата +func (p *WorkerPool) Submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan RequestResult, error) { + if len(p.taskCh) >= p.queueSize { + return nil, ErrPoolQueueFull + } + + resultCh := make(chan RequestResult, 1) // буфер 1, чтобы не блокировать воркера + envelope := RequestEnvelope{do, resultCh} + select { + case <-ctx.Done(): + return nil, ctx.Err() + case p.taskCh <- envelope: + return resultCh, nil + default: + return nil, ErrPoolQueueFull + } +} + +// worker выполняет задачи +func (p *WorkerPool) worker(ctx context.Context) { + defer p.wg.Done() + for { + select { + case <-p.quit: + return + case envelope := <-p.taskCh: + // Выполняем задачу с переданным контекстом (или можно использовать свой) + val, err := envelope.DoFunc(ctx) + envelope.ResultCh <- RequestResult{Value: val, Err: err} + close(envelope.ResultCh) + } + } +} diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 23424c0..1641162 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -3,6 +3,7 @@ package tgapi import ( "bytes" "context" + "errors" "fmt" "mime/multipart" "net/http" @@ -63,10 +64,21 @@ type UploaderRequest[R, P any] struct { func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { return UploaderRequest[R, P]{method, files, params} } -func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { +func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { var zero R + if up.api.limiter != nil { + if up.api.dropOverflowLimit { + if !up.api.limiter.Allow() { + return zero, errors.New("rate limited") + } + } else { + if err := up.api.limiter.Wait(ctx); err != nil { + return zero, err + } + } + } - buf, contentType, err := prepareMultipart(u.files, u.params) + buf, contentType, err := prepareMultipart(r.files, r.params) if err != nil { return zero, err } @@ -75,7 +87,7 @@ func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) if up.api.useTestServer { methodPrefix = "/test" } - url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, u.method) + url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, r.method) req, err := http.NewRequestWithContext(ctx, "POST", url, buf) if err != nil { return zero, err @@ -84,7 +96,7 @@ func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) - up.logger.Debugln("UPLOADER REQ", u.method) + up.logger.Debugln("UPLOADER REQ", r.method) res, err := up.api.client.Do(req) if err != nil { return zero, err @@ -92,15 +104,38 @@ func (u UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) defer res.Body.Close() body, err := readBody(res.Body) - up.logger.Debugln("UPLOADER RES", u.method, string(body)) + up.logger.Debugln("UPLOADER RES", r.method, string(body)) if res.StatusCode != http.StatusOK { return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(body)) } return parseBody[R](body) } -func (u UploaderRequest[R, P]) Do(up *Uploader) (R, error) { - return u.DoWithContext(context.Background(), up) +func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { + var zero R + + result, err := up.api.pool.Submit(ctx, func(ctx context.Context) (any, error) { + return r.doRequest(ctx, up) + }) + if err != nil { + return zero, err + } + + select { + case <-ctx.Done(): + return zero, ctx.Err() + case res := <-result: + if res.Err != nil { + return zero, res.Err + } + if val, ok := res.Value.(R); ok { + return val, nil + } + return zero, ErrPoolUnexpected + } +} +func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) { + return r.DoWithContext(context.Background(), up) } func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { diff --git a/utils/version.go b/utils/version.go index 0630841..fb99be2 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "0.8.0-beta.3" + VersionString = "0.8.0-beta.4" VersionMajor = 0 VersionMinor = 8 VersionPatch = 0 - Beta = 3 + Beta = 4 ) -- 2.54.0 From 786da652e67d1876b4eb4761f956684191ebfff9 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 26 Feb 2026 15:12:36 +0300 Subject: [PATCH 16/99] v1.0.0 beta 1 --- bot.go | 91 +++++++++++++++++++++++++++++++----------------- cmd_generator.go | 6 +++- go.mod | 4 +-- go.sum | 1 + handler.go | 4 +-- methods.go | 13 +++---- plugins.go | 27 ++++++++------ utils/version.go | 8 ++--- 8 files changed, 95 insertions(+), 59 deletions(-) diff --git a/bot.go b/bot.go index 8a1a8e6..8864452 100644 --- a/bot.go +++ b/bot.go @@ -1,16 +1,19 @@ package laniakea import ( + "context" "fmt" + "log" "os" "sort" "strconv" "strings" - "time" + "sync" "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/slog" + "github.com/alitto/pond/v2" "golang.org/x/time/rate" ) @@ -87,13 +90,14 @@ type Bot[T DbContext] struct { dbContext *T l10n *L10n - updateOffset int - updateTypes []tgapi.UpdateType - updateQueue *extypes.Queue[*tgapi.Update] + updateOffsetMu sync.Mutex + updateOffset int + updateTypes []tgapi.UpdateType + updateQueue chan *tgapi.Update } func NewBot[T any](opts *BotOpts) *Bot[T] { - updateQueue := extypes.CreateQueue[*tgapi.Update](512) + updateQueue := make(chan *tgapi.Update, 512) var limiter *rate.Limiter if opts.RateLimit > 0 { @@ -185,13 +189,20 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { } } -func (bot *Bot[T]) GetUpdateOffset() int { return bot.updateOffset } -func (bot *Bot[T]) SetUpdateOffset(offset int) { bot.updateOffset = offset } -func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return bot.updateTypes } -func (bot *Bot[T]) GetQueue() *extypes.Queue[*tgapi.Update] { return bot.updateQueue } -func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } -func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } -func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } +func (bot *Bot[T]) GetUpdateOffset() int { + bot.updateOffsetMu.Lock() + defer bot.updateOffsetMu.Unlock() + return bot.updateOffset +} +func (bot *Bot[T]) SetUpdateOffset(offset int) { + bot.updateOffsetMu.Lock() + defer bot.updateOffsetMu.Unlock() + bot.updateOffset = offset +} +func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return bot.updateTypes } +func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } +func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } +func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } type DbLogger[T DbContext] func(db *T) slog.LoggerWriter @@ -235,7 +246,7 @@ func (bot *Bot[T]) Debug(debug bool) *Bot[T] { func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { for _, p := range plugin { bot.plugins = append(bot.plugins, *p) - bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.Name)) + bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.name)) } return bot } @@ -266,7 +277,15 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { return bot } -func (bot *Bot[T]) Run() { +func (bot *Bot[T]) enqueueUpdate(u *tgapi.Update) error { + select { + case bot.updateQueue <- u: + return nil + default: + return extypes.QueueFullErr + } +} +func (bot *Bot[T]) RunWithContext(ctx context.Context) { if len(bot.prefixes) == 0 { bot.logger.Fatalln("no prefixes defined") return @@ -282,26 +301,36 @@ func (bot *Bot[T]) Run() { bot.logger.Infoln("Bot running. Press CTRL+C to exit.") go func() { for { - _, err := bot.Updates() - if err != nil { - bot.logger.Errorln(err) + select { + case <-ctx.Done(): + return + default: + updates, err := bot.Updates() + if err != nil { + bot.logger.Errorln(err) + continue + } + + for _, u := range updates { + select { + case bot.updateQueue <- new(u): + case <-ctx.Done(): + return + } + } } } }() - for { - queue := bot.updateQueue - if queue.IsEmpty() { - time.Sleep(time.Millisecond * 25) - continue - } - - u := queue.Dequeue() - if u == nil { - bot.logger.Errorln("update is nil") - continue - } - - bot.handle(u) + pool := pond.NewPool(16) + for update := range bot.updateQueue { + update := update + log.Println(update) + pool.Submit(func() { + bot.handle(update) + }) } } +func (bot *Bot[T]) Run() { + bot.RunWithContext(context.Background()) +} diff --git a/cmd_generator.go b/cmd_generator.go index 4e6153b..45da59d 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -27,7 +27,7 @@ func generateBotCommand[T any](cmd Command[T]) tgapi.BotCommand { func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) - for _, cmd := range pl.Commands { + for _, cmd := range pl.commands { if cmd.skipAutoCmd { continue } @@ -46,6 +46,10 @@ func (bot *Bot[T]) AutoGenerateCommands() error { commands := make([]tgapi.BotCommand, 0) for _, pl := range bot.plugins { + if pl.skipAutoCmd { + continue + } + commands = append(commands, generateBotCommandForPlugin(pl)...) } if len(commands) > 100 { diff --git a/go.mod b/go.mod index f6a0218..72a0376 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,13 @@ module git.nix13.pw/scuroneko/laniakea go 1.26 require ( - git.nix13.pw/scuroneko/extypes v1.2.0 + git.nix13.pw/scuroneko/extypes v1.2.1 git.nix13.pw/scuroneko/slog v1.0.2 golang.org/x/time v0.14.0 + github.com/alitto/pond/v2 v2.6.2 ) require ( - github.com/alitto/pond/v2 v2.6.2 // indirect github.com/fatih/color v1.18.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index ac69590..66fa4ef 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ git.nix13.pw/scuroneko/extypes v1.2.0 h1:2n2hD6KsMAted+6MGhAyeWyli2Qzc9G2y+pQNB7C1dM= git.nix13.pw/scuroneko/extypes v1.2.0/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= +git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw= diff --git a/handler.go b/handler.go index 7fadb3e..2d975c4 100644 --- a/handler.go +++ b/handler.go @@ -46,7 +46,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { text = strings.TrimSpace(text[len(prefix):]) for _, plugin := range bot.plugins { - for cmd := range plugin.Commands { + for cmd := range plugin.commands { if !strings.HasPrefix(text, cmd) { continue } @@ -96,7 +96,7 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { ctx.Args = data.Args for _, plugin := range bot.plugins { - _, ok := plugin.Payloads[data.Command] + _, ok := plugin.payloads[data.Command] if !ok { continue } diff --git a/methods.go b/methods.go index 80280b9..21f9e46 100644 --- a/methods.go +++ b/methods.go @@ -19,14 +19,8 @@ func (bot *Bot[T]) Updates() ([]tgapi.Update, error) { return nil, err } - for _, u := range updates { - bot.SetUpdateOffset(u.UpdateID + 1) - err = bot.GetQueue().Enqueue(&u) - if err != nil { - return nil, err - } - - if bot.RequestLogger != nil { + if bot.RequestLogger != nil { + for _, u := range updates { j, err := json.Marshal(u) if err != nil { bot.GetLogger().Error(err) @@ -34,5 +28,8 @@ func (bot *Bot[T]) Updates() ([]tgapi.Update, error) { bot.RequestLogger.Debugf("UPDATE %s\n", j) } } + if len(updates) > 0 { + bot.SetUpdateOffset(updates[len(updates)-1].UpdateID + 1) + } return updates, err } diff --git a/plugins.go b/plugins.go index 312c386..a30514b 100644 --- a/plugins.go +++ b/plugins.go @@ -93,37 +93,42 @@ func (c *Command[T]) validateArgs(args []string) error { } type Plugin[T DbContext] struct { - Name string - Commands map[string]Command[T] - Payloads map[string]Command[T] - Middlewares extypes.Slice[Middleware[T]] + name string + commands map[string]Command[T] + payloads map[string]Command[T] + middlewares extypes.Slice[Middleware[T]] + skipAutoCmd bool } func NewPlugin[T DbContext](name string) *Plugin[T] { return &Plugin[T]{ name, map[string]Command[T]{}, - map[string]Command[T]{}, extypes.Slice[Middleware[T]]{}, + map[string]Command[T]{}, extypes.Slice[Middleware[T]]{}, false, } } func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { - p.Commands[command.command] = *command + p.commands[command.command] = *command return p } func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { return NewCommand(exec, command, args...) } func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { - p.Payloads[command.command] = *command + p.payloads[command.command] = *command return p } func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] { - p.Middlewares = p.Middlewares.Push(middleware) + p.middlewares = p.middlewares.Push(middleware) + return p +} +func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] { + p.skipAutoCmd = true return p } func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { - command := p.Commands[cmd] + command := p.commands[cmd] if err := command.validateArgs(ctx.Args); err != nil { ctx.error(err) return @@ -131,7 +136,7 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { command.exec(ctx, dbContext) } func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) { - pl := p.Payloads[payload] + pl := p.payloads[payload] if err := pl.validateArgs(ctx.Args); err != nil { ctx.error(err) return @@ -139,7 +144,7 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T pl.exec(ctx, dbContext) } func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { - for _, m := range p.Middlewares { + for _, m := range p.middlewares { if !m.Execute(ctx, db) { return false } diff --git a/utils/version.go b/utils/version.go index fb99be2..b825859 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "0.8.0-beta.4" - VersionMajor = 0 - VersionMinor = 8 + VersionString = "1.0.0-beta.1" + VersionMajor = 1 + VersionMinor = 0 VersionPatch = 0 - Beta = 4 + Beta = 1 ) -- 2.54.0 From c0a26024f4cfb8a3a8a9ee83f34e639d40aef647 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 26 Feb 2026 15:15:35 +0300 Subject: [PATCH 17/99] v1.0.0 beta 2 --- go.mod | 2 +- go.sum | 3 +-- utils/version.go | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 72a0376..31c38e0 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.26 require ( git.nix13.pw/scuroneko/extypes v1.2.1 git.nix13.pw/scuroneko/slog v1.0.2 - golang.org/x/time v0.14.0 github.com/alitto/pond/v2 v2.6.2 + golang.org/x/time v0.14.0 ) require ( diff --git a/go.sum b/go.sum index 66fa4ef..744f128 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,4 @@ -git.nix13.pw/scuroneko/extypes v1.2.0 h1:2n2hD6KsMAted+6MGhAyeWyli2Qzc9G2y+pQNB7C1dM= -git.nix13.pw/scuroneko/extypes v1.2.0/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= +git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5/RpQ= git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= diff --git a/utils/version.go b/utils/version.go index b825859..2ca41f7 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.1" + VersionString = "1.0.0-beta.2" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 1 + Beta = 2 ) -- 2.54.0 From a84e24ff25dce4f86c9f2bceed35be1c4bc756fa Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 27 Feb 2026 13:53:00 +0300 Subject: [PATCH 18/99] small fix --- bot.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot.go b/bot.go index 8864452..8ad242e 100644 --- a/bot.go +++ b/bot.go @@ -3,7 +3,6 @@ package laniakea import ( "context" "fmt" - "log" "os" "sort" "strconv" @@ -325,7 +324,6 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { pool := pond.NewPool(16) for update := range bot.updateQueue { update := update - log.Println(update) pool.Submit(func() { bot.handle(update) }) -- 2.54.0 From 61562e8a3be204d4e73b9fc759fe8ee0043df7c6 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Sun, 1 Mar 2026 23:01:06 +0300 Subject: [PATCH 19/99] 1.0.0 beta 3 --- bot.go | 14 +++++-- drafts.go | 82 +++++++++++++++++++++++++++++++++++++++ handler.go | 8 +++- msg_context.go | 46 +++++++++++----------- tgapi/chat_methods.go | 12 ++++++ tgapi/chat_types.go | 5 +++ tgapi/messages_methods.go | 2 +- tgapi/messages_types.go | 13 +++++-- utils/version.go | 4 +- 9 files changed, 151 insertions(+), 35 deletions(-) create mode 100644 drafts.go diff --git a/bot.go b/bot.go index 8ad242e..900145d 100644 --- a/bot.go +++ b/bot.go @@ -84,10 +84,11 @@ type Bot[T DbContext] struct { prefixes []string runners []Runner[T] - api *tgapi.API - uploader *tgapi.Uploader - dbContext *T - l10n *L10n + api *tgapi.API + uploader *tgapi.Uploader + dbContext *T + l10n *L10n + draftProvider *DraftProvider updateOffsetMu sync.Mutex updateOffset int @@ -122,6 +123,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { runners: make([]Runner[T], 0), extraLoggers: make([]*slog.Logger, 0), l10n: &L10n{}, + draftProvider: NewRandomDraftProvider(api), } bot.extraLoggers = bot.extraLoggers.Push(api.GetLogger()).Push(uploader.GetLogger()) @@ -202,6 +204,10 @@ func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return bot.updateTypes func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } +func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { + bot.draftProvider = p + return bot +} type DbLogger[T DbContext] func(db *T) slog.LoggerWriter diff --git a/drafts.go b/drafts.go new file mode 100644 index 0000000..f170f64 --- /dev/null +++ b/drafts.go @@ -0,0 +1,82 @@ +package laniakea + +import ( + "math" + "math/rand/v2" + "sync/atomic" + + "git.nix13.pw/scuroneko/laniakea/tgapi" +) + +type draftIdGenerator interface { + Next() uint64 +} + +type RandomDraftIdGenerator struct { + draftIdGenerator +} + +func (g *RandomDraftIdGenerator) Next() uint64 { + return rand.Uint64N(math.MaxUint64) +} + +type LinearDraftIdGenerator struct { + draftIdGenerator + lastId uint64 +} + +func (g *LinearDraftIdGenerator) Next() uint64 { + return atomic.AddUint64(&g.lastId, 1) +} + +type DraftProvider struct { + api *tgapi.API + + chatID int + messageThreadID int + parseMode tgapi.ParseMode + entities []tgapi.MessageEntity + + drafts map[uint64]*Draft + generator draftIdGenerator +} +type Draft struct { + api *tgapi.API + + chatID int + messageThreadID int + parseMode tgapi.ParseMode + entities []tgapi.MessageEntity + + ID uint64 + Message string +} + +func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { + return &DraftProvider{api: api, generator: &RandomDraftIdGenerator{}} +} +func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { + return &DraftProvider{api: api, generator: &LinearDraftIdGenerator{lastId: startValue}} +} +func (d *DraftProvider) NewDraft() *Draft { + id := d.generator.Next() + draft := &Draft{d.api, d.chatID, d.messageThreadID, d.parseMode, d.entities, id, ""} + d.drafts[id] = draft + return draft +} + +func (d *Draft) Push(draftId uint64, newText string) error { + d.Message += newText + params := tgapi.SendMessageDraftP{ + ChatID: d.chatID, + DraftID: draftId, + Text: d.Message, + ParseMode: d.parseMode, + Entities: d.entities, + } + if d.messageThreadID > 0 { + params.MessageThreadID = d.messageThreadID + } + _, err := d.api.SendMessageDraft(params) + return err +} diff --git a/handler.go b/handler.go index 2d975c4..27ba991 100644 --- a/handler.go +++ b/handler.go @@ -9,7 +9,13 @@ import ( ) func (bot *Bot[T]) handle(u *tgapi.Update) { - ctx := &MsgContext{Update: *u, Api: bot.api, botLogger: bot.logger, errorTemplate: bot.errorTemplate, l10n: bot.l10n} + ctx := &MsgContext{ + Update: *u, Api: bot.api, + botLogger: bot.logger, + errorTemplate: bot.errorTemplate, + l10n: bot.l10n, + draftProvider: bot.draftProvider, + } for _, middleware := range bot.middlewares { middleware.Execute(ctx, bot.dbContext) } diff --git a/msg_context.go b/msg_context.go index b0cfaf4..400e3b7 100644 --- a/msg_context.go +++ b/msg_context.go @@ -24,6 +24,7 @@ type MsgContext struct { errorTemplate string botLogger *slog.Logger l10n *L10n + draftProvider *DraftProvider } type AnswerMessage struct { @@ -77,6 +78,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo if kb != nil { params.ReplyMarkup = kb.Get() } + msg, _, err := ctx.Api.EditMessageCaption(params) if err != nil { ctx.botLogger.Errorln(err) @@ -105,6 +107,9 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard) *AnswerMess if keyboard != nil { params.ReplyMarkup = keyboard.Get() } + if ctx.Msg.MessageThreadID > 0 { + params.MessageThreadID = ctx.Msg.MessageThreadID + } msg, err := ctx.Api.SendMessage(params) if err != nil { @@ -135,6 +140,10 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard) *An if kb != nil { params.ReplyMarkup = kb.Get() } + if ctx.Msg.MessageThreadID > 0 { + params.MessageThreadID = ctx.Msg.MessageThreadID + } + msg, err := ctx.Api.SendPhoto(params) if err != nil { ctx.botLogger.Errorln(err) @@ -162,12 +171,8 @@ func (ctx *MsgContext) delete(messageId int) { ctx.botLogger.Errorln(err) } } -func (m *AnswerMessage) Delete() { - m.ctx.delete(m.MessageID) -} -func (ctx *MsgContext) CallbackDelete() { - ctx.delete(ctx.CallbackMsgId) -} +func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) } +func (ctx *MsgContext) CallbackDelete() { ctx.delete(ctx.CallbackMsgId) } func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryId) == 0 { @@ -181,23 +186,19 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { ctx.botLogger.Errorln(err) } } -func (ctx *MsgContext) AnswerCbQuery() { - ctx.answerCallbackQuery("", "", false) -} -func (ctx *MsgContext) AnswerCbQueryText(text string) { - ctx.answerCallbackQuery("", text, false) -} -func (ctx *MsgContext) AnswerCbQueryAlert(text string) { - ctx.answerCallbackQuery("", text, true) -} -func (ctx *MsgContext) AnswerCbQueryUrl(u string) { - ctx.answerCallbackQuery(u, "", false) -} +func (ctx *MsgContext) AnswerCbQuery() { ctx.answerCallbackQuery("", "", false) } +func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery("", text, false) } +func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) } +func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "", false) } func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { - _, err := ctx.Api.SendChatAction(tgapi.SendChatActionP{ + 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) } @@ -213,10 +214,9 @@ func (ctx *MsgContext) error(err error) { } ctx.botLogger.Errorln(err) } -func (ctx *MsgContext) Error(err error) { - ctx.error(err) -} +func (ctx *MsgContext) Error(err error) { ctx.error(err) } +func (ctx *MsgContext) NewDraft() *Draft { return ctx.draftProvider.NewDraft() } func (ctx *MsgContext) Translate(key string) string { if ctx.From == nil { return key diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index fc48a72..8294258 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -56,6 +56,7 @@ type PromoteChatMember struct { CanPinMessages bool `json:"can_pin_messages,omitempty"` CanManageTopics bool `json:"can_manage_topics,omitempty"` CanManageDirectMessages bool `json:"can_manage_direct_messages,omitempty"` + CanManageTags bool `json:"can_manage_tags,omitempty"` } func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) { @@ -74,6 +75,17 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto return req.Do(api) } +type SetChatMemberTagP struct { + ChatID int `json:"chat_id"` + UserID int `json:"user_id"` + Tag string `json:"tag,omitempty"` +} + +func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) { + req := NewRequest[bool]("setChatMemberTag", params) + return req.Do(api) +} + type BanChatSenderChatP struct { ChatID int `json:"chat_id"` SenderChatID int `json:"sender_chat_id"` diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index 7b1ea6e..96f5ac7 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -99,6 +99,7 @@ type ChatPermissions struct { CanSendPolls bool `json:"can_send_polls"` CanSendOtherMessages bool `json:"can_send_other_messages"` CanAddWebPagePreview bool `json:"can_add_web_page_preview"` + CatEditTag bool `json:"cat_edit_tag"` CanChangeInfo bool `json:"can_change_info"` CanInviteUsers bool `json:"can_invite_users"` CanPinMessages bool `json:"can_pin_messages"` @@ -137,6 +138,7 @@ const ( type ChatMember struct { Status ChatMemberStatusType `json:"status"` User User `json:"user"` + Tag string `json:"tag,omitempty"` // Owner IsAnonymous *bool `json:"is_anonymous"` @@ -160,6 +162,7 @@ type ChatMember struct { CanPinMessages *bool `json:"can_pin_messages,omitempty"` CanManageTopics *bool `json:"can_manage_topics,omitempty"` CanManageDirectMessages *bool `json:"can_manage_direct_messages,omitempty"` + CanManageTags *bool `json:"can_manage_tags,omitempty"` // Member UntilDate *int `json:"until_date,omitempty"` @@ -175,6 +178,7 @@ type ChatMember struct { CanSendPolls *bool `json:"can_send_polls,omitempty"` CanSendOtherMessages *bool `json:"can_send_other_messages,omitempty"` CanAddWebPagePreview *bool `json:"can_add_web_page_preview,omitempty"` + CanEditTag *bool `json:"can_edit_tag,omitempty"` } type ChatBoostSource struct { @@ -215,6 +219,7 @@ type ChatAdministratorRights struct { CanPinMessages *bool `json:"can_pin_messages,omitempty"` CanManageTopics *bool `json:"can_manage_topics,omitempty"` CanManageDirectMessages *bool `json:"can_manage_direct_messages,omitempty"` + CanManageTags *bool `json:"can_manage_tags,omitempty"` } type ChatBoostUpdated struct { diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index c17495e..d7264db 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -268,7 +268,7 @@ func (api *API) SendDice(params SendDiceP) (Message, error) { type SendMessageDraftP struct { ChatID int `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` - DraftID int `json:"draft_id"` + DraftID uint64 `json:"draft_id"` Text string `json:"text"` ParseMode ParseMode `json:"parse_mode,omitempty"` Entities []MessageEntity `json:"entities,omitempty"` diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 3662b20..24605cc 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -12,10 +12,11 @@ type Message struct { BusinessConnectionId string `json:"business_connection_id,omitempty"` From *User `json:"from,omitempty"` - SenderChat *Chat `json:"sender_chat,omitempty"` - SenderBoostCount int `json:"sender_boost_count,omitempty"` - SenderBusinessBot *User `json:"sender_business_bot,omitempty"` - Chat *Chat `json:"chat,omitempty"` + SenderChat *Chat `json:"sender_chat,omitempty"` + SenderBoostCount int `json:"sender_boost_count,omitempty"` + SenderBusinessBot *User `json:"sender_business_bot,omitempty"` + SenderTag string `json:"sender_tag,omitempty"` + Chat *Chat `json:"chat,omitempty"` IsTopicMessage bool `json:"is_topic_message,omitempty"` IsAutomaticForward bool `json:"is_automatic_forward,omitempty"` @@ -74,6 +75,7 @@ const ( MessageEntityTextLink MessageEntityType = "text_link" MessageEntityTextMention MessageEntityType = "text_mention" MessageEntityCustomEmoji MessageEntityType = "custom_emoji" + MessageEntityDateTime MessageEntityType = "date_time" ) type MessageEntity struct { @@ -85,6 +87,9 @@ type MessageEntity struct { User *User `json:"user,omitempty"` Language string `json:"language,omitempty"` CustomEmojiID string `json:"custom_emoji_id,omitempty"` + + UnixTime int `json:"unix_time,omitempty"` + DateTimeFormat string `json:"date_time_format,omitempty"` } type ReplyParameters struct { diff --git a/utils/version.go b/utils/version.go index 2ca41f7..5cf975f 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.2" + VersionString = "1.0.0-beta.3" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 2 + Beta = 3 ) -- 2.54.0 From ae7426c36a4458af5a62aac84c3e9269d42b10c5 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Sun, 1 Mar 2026 23:08:22 +0300 Subject: [PATCH 20/99] 1.0.0 beta 4 --- drafts.go | 15 +++++++++++---- msg_context.go | 7 ++++++- utils/version.go | 4 ++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/drafts.go b/drafts.go index f170f64..3bd2aa6 100644 --- a/drafts.go +++ b/drafts.go @@ -53,10 +53,17 @@ type Draft struct { } func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { - return &DraftProvider{api: api, generator: &RandomDraftIdGenerator{}} + return &DraftProvider{ + api: api, generator: &RandomDraftIdGenerator{}, + drafts: make(map[uint64]*Draft), + } } func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { - return &DraftProvider{api: api, generator: &LinearDraftIdGenerator{lastId: startValue}} + return &DraftProvider{ + api: api, + generator: &LinearDraftIdGenerator{lastId: startValue}, + drafts: make(map[uint64]*Draft), + } } func (d *DraftProvider) NewDraft() *Draft { id := d.generator.Next() @@ -65,11 +72,11 @@ func (d *DraftProvider) NewDraft() *Draft { return draft } -func (d *Draft) Push(draftId uint64, newText string) error { +func (d *Draft) Push(newText string) error { d.Message += newText params := tgapi.SendMessageDraftP{ ChatID: d.chatID, - DraftID: draftId, + DraftID: d.ID, Text: d.Message, ParseMode: d.parseMode, Entities: d.entities, diff --git a/msg_context.go b/msg_context.go index 400e3b7..7c353a5 100644 --- a/msg_context.go +++ b/msg_context.go @@ -216,7 +216,12 @@ func (ctx *MsgContext) error(err error) { } func (ctx *MsgContext) Error(err error) { ctx.error(err) } -func (ctx *MsgContext) NewDraft() *Draft { return ctx.draftProvider.NewDraft() } +func (ctx *MsgContext) NewDraft() *Draft { + draft := ctx.draftProvider.NewDraft() + draft.chatID = ctx.Msg.Chat.ID + draft.messageThreadID = ctx.Msg.MessageThreadID + return draft +} func (ctx *MsgContext) Translate(key string) string { if ctx.From == nil { return key diff --git a/utils/version.go b/utils/version.go index 5cf975f..27bbd6f 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.3" + VersionString = "1.0.0-beta.4" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 3 + Beta = 4 ) -- 2.54.0 From 2de46a27c8f17ac9285f908c79c0d8acebd0fe69 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Sun, 1 Mar 2026 23:40:27 +0300 Subject: [PATCH 21/99] v1.0.0 beta 5 --- drafts.go | 12 ++++++++++++ utils/version.go | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/drafts.go b/drafts.go index 3bd2aa6..b4f0391 100644 --- a/drafts.go +++ b/drafts.go @@ -87,3 +87,15 @@ func (d *Draft) Push(newText string) error { _, err := d.api.SendMessageDraft(params) return err } +func (d *Draft) Flush() error { + params := tgapi.SendMessageP{ + ChatID: d.chatID, + ParseMode: d.parseMode, + Entities: d.entities, + } + if d.messageThreadID > 0 { + params.MessageThreadID = d.messageThreadID + } + _, err := d.api.SendMessage(params) + return err +} diff --git a/utils/version.go b/utils/version.go index 27bbd6f..1450f99 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.4" + VersionString = "1.0.0-beta.5" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 4 + Beta = 5 ) -- 2.54.0 From 7101aba54805082c467c6c0c71f8fa4bfb7ce4d2 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 2 Mar 2026 00:08:26 +0300 Subject: [PATCH 22/99] v1.0.0 beta 6 --- drafts.go | 5 +++++ msg_context.go | 3 +++ tgapi/messages_methods.go | 2 +- tgapi/messages_types.go | 14 ++++++++++---- utils/version.go | 4 ++-- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/drafts.go b/drafts.go index b4f0391..9b95411 100644 --- a/drafts.go +++ b/drafts.go @@ -88,10 +88,15 @@ func (d *Draft) Push(newText string) error { return err } func (d *Draft) Flush() error { + if d.Message == "" { + return nil + } + params := tgapi.SendMessageP{ ChatID: d.chatID, ParseMode: d.parseMode, Entities: d.entities, + Text: d.Message, } if d.messageThreadID > 0 { params.MessageThreadID = d.messageThreadID diff --git a/msg_context.go b/msg_context.go index 7c353a5..1d2eb19 100644 --- a/msg_context.go +++ b/msg_context.go @@ -110,6 +110,9 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard) *AnswerMess 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 { diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index d7264db..7ec41e5 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -4,7 +4,7 @@ type SendMessageP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + DirectMessagesTopicID int64 `json:"direct_messages_topic_id,omitempty"` Text string `json:"text"` ParseMode ParseMode `json:"parse_mode,omitempty"` diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 24605cc..5b0eba1 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -6,11 +6,17 @@ type MessageReplyMarkup struct { InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"` } +type DirectMessageTopic struct { + TopicID int64 `json:"topic_id"` + User *User `json:"user,omitempty"` +} + type Message struct { - MessageID int `json:"message_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - BusinessConnectionId string `json:"business_connection_id,omitempty"` - From *User `json:"from,omitempty"` + MessageID int `json:"message_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"` + BusinessConnectionId string `json:"business_connection_id,omitempty"` + From *User `json:"from,omitempty"` SenderChat *Chat `json:"sender_chat,omitempty"` SenderBoostCount int `json:"sender_boost_count,omitempty"` diff --git a/utils/version.go b/utils/version.go index 1450f99..080dc3f 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.5" + VersionString = "1.0.0-beta.6" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 5 + Beta = 6 ) -- 2.54.0 From fa7a296a664757558e8a616c9d0f875d489554f1 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 2 Mar 2026 16:49:00 +0300 Subject: [PATCH 23/99] v1.0.0 beta 7; ratelimt war --- bot.go | 6 +- drafts.go | 4 +- msg_context.go | 12 ++++ tgapi/api.go | 48 +++++++++---- tgapi/attachments_methods.go | 2 +- tgapi/chat_types.go | 2 +- tgapi/errors.go | 5 ++ tgapi/messages_methods.go | 14 ++-- tgapi/uploader_api.go | 12 ++-- tgapi/uploader_methods.go | 2 +- utils/limiter.go | 129 +++++++++++++++++++++++++++++++++++ utils/version.go | 4 +- 12 files changed, 205 insertions(+), 35 deletions(-) create mode 100644 tgapi/errors.go create mode 100644 utils/limiter.go diff --git a/bot.go b/bot.go index 900145d..6abec8f 100644 --- a/bot.go +++ b/bot.go @@ -11,9 +11,9 @@ import ( "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/laniakea/utils" "git.nix13.pw/scuroneko/slog" "github.com/alitto/pond/v2" - "golang.org/x/time/rate" ) type BotOpts struct { @@ -99,9 +99,9 @@ type Bot[T DbContext] struct { func NewBot[T any](opts *BotOpts) *Bot[T] { updateQueue := make(chan *tgapi.Update, 512) - var limiter *rate.Limiter + var limiter *utils.RateLimiter if opts.RateLimit > 0 { - limiter = rate.NewLimiter(rate.Limit(opts.RateLimit), opts.RateLimit) + limiter = utils.NewRateLimiter() } apiOpts := tgapi.NewAPIOpts(opts.Token).SetAPIUrl(opts.APIUrl).UseTestServer(opts.UseTestServer).SetLimiter(limiter) diff --git a/drafts.go b/drafts.go index 9b95411..fb37088 100644 --- a/drafts.go +++ b/drafts.go @@ -32,7 +32,7 @@ func (g *LinearDraftIdGenerator) Next() uint64 { type DraftProvider struct { api *tgapi.API - chatID int + chatID int64 messageThreadID int parseMode tgapi.ParseMode entities []tgapi.MessageEntity @@ -43,7 +43,7 @@ type DraftProvider struct { type Draft struct { api *tgapi.API - chatID int + chatID int64 messageThreadID int parseMode tgapi.ParseMode entities []tgapi.MessageEntity diff --git a/msg_context.go b/msg_context.go index 1d2eb19..817a630 100644 --- a/msg_context.go +++ b/msg_context.go @@ -1,6 +1,7 @@ package laniakea import ( + "context" "fmt" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -114,6 +115,11 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard) *AnswerMess params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID } + cont := context.Background() + if err := ctx.Api.Limiter.Wait(cont, ctx.Msg.Chat.ID); err != nil { + ctx.botLogger.Errorln(err) + return nil + } msg, err := ctx.Api.SendMessage(params) if err != nil { ctx.botLogger.Errorln(err) @@ -220,6 +226,12 @@ func (ctx *MsgContext) error(err error) { func (ctx *MsgContext) Error(err error) { ctx.error(err) } func (ctx *MsgContext) NewDraft() *Draft { + c := context.Background() + if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { + ctx.botLogger.Errorln(err) + return nil + } + draft := ctx.draftProvider.NewDraft() draft.chatID = ctx.Msg.Chat.ID draft.messageThreadID = ctx.Msg.MessageThreadID diff --git a/tgapi/api.go b/tgapi/api.go index 8422b20..0004e6c 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -12,7 +12,6 @@ import ( "git.nix13.pw/scuroneko/laniakea/utils" "git.nix13.pw/scuroneko/slog" - "golang.org/x/time/rate" ) type APIOpts struct { @@ -21,7 +20,7 @@ type APIOpts struct { useTestServer bool apiUrl string - limiter *rate.Limiter + limiter *utils.RateLimiter dropOverflowLimit bool } @@ -46,7 +45,7 @@ func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts { } return opts } -func (opts *APIOpts) SetLimiter(limiter *rate.Limiter) *APIOpts { +func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts { opts.limiter = limiter return opts } @@ -63,7 +62,7 @@ type API struct { apiUrl string pool *WorkerPool - limiter *rate.Limiter + Limiter *utils.RateLimiter dropOverflowLimit bool } @@ -88,11 +87,17 @@ func (api *API) CloseApi() error { } func (api *API) GetLogger() *slog.Logger { return api.logger } +type ResponseParameters struct { + MigrateToChatID *int64 `json:"migrate_to_chat_id,omitempty"` + RetryAfter *int `json:"retry_after,omitempty"` +} type ApiResponse[R any] struct { Ok bool `json:"ok"` Description string `json:"description,omitempty"` Result R `json:"result,omitempty"` ErrorCode int `json:"error_code,omitempty"` + + Parameters *ResponseParameters `json:"parameters,omitempty"` } type TelegramRequest[R, P any] struct { method string @@ -104,13 +109,13 @@ func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { } func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { var zero R - if api.limiter != nil { + if api.Limiter != nil { if api.dropOverflowLimit { - if !api.limiter.Allow() { + if !api.Limiter.GlobalAllow() { return zero, errors.New("rate limited") } } else { - if err := api.limiter.Wait(ctx); err != nil { + if err := api.Limiter.GlobalWait(ctx); err != nil { return zero, err } } @@ -149,10 +154,23 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro return zero, err } api.logger.Debugln("RES", r.method, string(data)) - if res.StatusCode != http.StatusOK { + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusTooManyRequests { return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(data)) } - return parseBody[R](data) + + responseData, err := parseBody[R](data) + if errors.Is(err, ErrRateLimit) { + if responseData.Parameters != nil { + after := 0 + if responseData.Parameters.RetryAfter != nil { + after = *responseData.Parameters.RetryAfter + } + api.Limiter.SetGlobalLock(after) + return r.doRequest(ctx, api) + } + return zero, ErrRateLimit + } + return responseData.Result, err } func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, error) { var zero R @@ -184,15 +202,17 @@ func readBody(body io.ReadCloser) ([]byte, error) { reader := io.LimitReader(body, 10<<20) return io.ReadAll(reader) } -func parseBody[R any](data []byte) (R, error) { - var zero R +func parseBody[R any](data []byte) (ApiResponse[R], error) { var resp ApiResponse[R] err := json.Unmarshal(data, &resp) if err != nil { - return zero, err + return resp, err } if !resp.Ok { - return zero, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description) + if resp.ErrorCode == 429 { + return resp, ErrRateLimit + } + return resp, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description) } - return resp.Result, nil + return resp, nil } diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 7545e33..455cd8c 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -2,7 +2,7 @@ package tgapi type SendPhotoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index 96f5ac7..42bf188 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -1,7 +1,7 @@ package tgapi type Chat struct { - ID int `json:"id"` + ID int64 `json:"id"` Type string `json:"type"` Title *string `json:"title,omitempty"` Username *string `json:"username,omitempty"` diff --git a/tgapi/errors.go b/tgapi/errors.go new file mode 100644 index 0000000..48a368b --- /dev/null +++ b/tgapi/errors.go @@ -0,0 +1,5 @@ +package tgapi + +import "errors" + +var ErrRateLimit = errors.New("rate limit exceeded") diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index 7ec41e5..2ef6bc2 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -2,7 +2,7 @@ package tgapi type SendMessageP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int64 `json:"direct_messages_topic_id,omitempty"` @@ -266,7 +266,7 @@ func (api *API) SendDice(params SendDiceP) (Message, error) { } type SendMessageDraftP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DraftID uint64 `json:"draft_id"` Text string `json:"text"` @@ -281,7 +281,7 @@ func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) { type SendChatActionP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` Action ChatActionType `json:"action"` } @@ -307,7 +307,7 @@ func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) { type EditMessageTextP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"` Text string `json:"text"` @@ -331,7 +331,7 @@ func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error) type EditMessageCaptionP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"` Caption string `json:"caption"` @@ -495,8 +495,8 @@ func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error) } type DeleteMessageP struct { - ChatID int `json:"chat_id"` - MessageID int `json:"message_id"` + ChatID int64 `json:"chat_id"` + MessageID int `json:"message_id"` } func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) { diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 1641162..0b4d2a5 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -66,13 +66,13 @@ func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile } func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { var zero R - if up.api.limiter != nil { + if up.api.Limiter != nil { if up.api.dropOverflowLimit { - if !up.api.limiter.Allow() { + if !up.api.Limiter.GlobalAllow() { return zero, errors.New("rate limited") } } else { - if err := up.api.limiter.Wait(ctx); err != nil { + if err := up.api.Limiter.GlobalWait(ctx); err != nil { return zero, err } } @@ -109,7 +109,11 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(body)) } - return parseBody[R](body) + respBody, err := parseBody[R](body) + if err != nil { + return zero, err + } + return respBody.Result, nil } func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { var zero R diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index 835faf4..8a9520e 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -2,7 +2,7 @@ package tgapi type UploadPhotoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` diff --git a/utils/limiter.go b/utils/limiter.go new file mode 100644 index 0000000..23ed999 --- /dev/null +++ b/utils/limiter.go @@ -0,0 +1,129 @@ +package utils + +import ( + "context" + "sync" + "time" + + "golang.org/x/time/rate" +) + +type RateLimiter struct { + globalLockUntil time.Time + globalLimiter *rate.Limiter + globalMu sync.RWMutex + + chatLocks map[int64]time.Time + chatLimiters map[int64]*rate.Limiter + chatMu sync.Mutex +} + +func NewRateLimiter() *RateLimiter { + return &RateLimiter{ + // 30 запросов в секунду (burst=30) + globalLimiter: rate.NewLimiter(rate.Limit(30), 30), + chatLimiters: make(map[int64]*rate.Limiter), + } +} + +func (rl *RateLimiter) SetGlobalLock(retryAfter int) { + if retryAfter <= 0 { + return + } + rl.globalMu.Lock() + defer rl.globalMu.Unlock() + rl.globalLockUntil = time.Now().Add(time.Duration(retryAfter) * time.Second) +} +func (rl *RateLimiter) SetChatLock(chatID int64, retryAfter int) { + rl.chatMu.Lock() + defer rl.chatMu.Unlock() + rl.chatLocks[chatID] = time.Now().Add(time.Duration(retryAfter) * time.Second) +} + +func (rl *RateLimiter) GlobalWait(ctx context.Context) error { + rl.globalMu.RLock() + until := rl.globalLockUntil + rl.globalMu.RUnlock() + + if !until.IsZero() { + if time.Now().Before(until) { + // Ждём до окончания блокировки или отмены контекста + select { + case <-time.After(time.Until(until)): + // блокировка снята + case <-ctx.Done(): + return ctx.Err() + } + } + } + // Теперь ждём разрешения rate limiter'а + return rl.globalLimiter.Wait(ctx) +} +func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { + rl.chatMu.Lock() + until, ok := rl.chatLocks[chatID] + rl.chatMu.Unlock() + if ok && !until.IsZero() { + if time.Now().Before(until) { + select { + case <-time.After(time.Until(until)): + // блокировка снята + case <-ctx.Done(): + return ctx.Err() + } + } + } + + if err := rl.GlobalWait(ctx); err != nil { + return err + } + rl.chatMu.Lock() + chatLimiter, ok := rl.chatLimiters[chatID] + if !ok { + chatLimiter = rate.NewLimiter(rate.Limit(1), 1) + rl.chatLimiters[chatID] = chatLimiter + } + rl.chatMu.Unlock() + return chatLimiter.Wait(ctx) +} + +func (rl *RateLimiter) GlobalAllow() bool { + rl.globalMu.RLock() + until := rl.globalLockUntil + rl.globalMu.RUnlock() + + if !until.IsZero() { + if time.Now().Before(until) { + // Ждём до окончания блокировки или отмены контекста + select { + case <-time.After(time.Until(until)): + rl.globalLimiter.Allow() + } + } + } + return rl.globalLimiter.Allow() +} +func (rl *RateLimiter) Allow(chatID int64) bool { + rl.chatMu.Lock() + until, ok := rl.chatLocks[chatID] + rl.chatMu.Unlock() + if ok && !until.IsZero() { + if time.Now().Before(until) { + select { + case <-time.After(time.Until(until)): + } + } + } + + if !rl.globalLimiter.Allow() { + return false + } + rl.chatMu.Lock() + chatLimiter, ok := rl.chatLimiters[chatID] + if !ok { + chatLimiter = rate.NewLimiter(rate.Limit(1), 1) + rl.chatLimiters[chatID] = chatLimiter + } + rl.chatMu.Unlock() + return chatLimiter.Allow() +} diff --git a/utils/version.go b/utils/version.go index 080dc3f..bf1a8b0 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.6" + VersionString = "1.0.0-beta.7" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 6 + Beta = 7 ) -- 2.54.0 From 6cf3355a368197b30042c5bd42f31bd3aa4f994c Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 2 Mar 2026 17:06:13 +0300 Subject: [PATCH 24/99] v1.0.0 beta 8; ratelimt war --- tgapi/api.go | 13 +++- tgapi/messages_methods.go | 4 +- utils/limiter.go | 160 ++++++++++++++++++++++---------------- utils/version.go | 4 +- 4 files changed, 108 insertions(+), 73 deletions(-) diff --git a/tgapi/api.go b/tgapi/api.go index 0004e6c..fd2647a 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -102,10 +102,14 @@ type ApiResponse[R any] struct { type TelegramRequest[R, P any] struct { method string params P + chatId int64 } func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { - return TelegramRequest[R, P]{method: method, params: params} + return TelegramRequest[R, P]{method, params, 0} +} +func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { + return TelegramRequest[R, P]{method, params, chatId} } func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { var zero R @@ -165,7 +169,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro if responseData.Parameters.RetryAfter != nil { after = *responseData.Parameters.RetryAfter } - api.Limiter.SetGlobalLock(after) + if r.chatId > 0 { + api.Limiter.SetChatLock(r.chatId, after) + } else { + api.Limiter.SetGlobalLock(after) + } + time.Sleep(time.Duration(after) * time.Second) return r.doRequest(ctx, api) } return zero, ErrRateLimit diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index 2ef6bc2..2e8d0bb 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -21,7 +21,7 @@ type SendMessageP struct { } func (api *API) SendMessage(params SendMessageP) (Message, error) { - req := NewRequest[Message, SendMessageP]("sendMessage", params) + req := NewRequestWithChatID[Message, SendMessageP]("sendMessage", params, params.ChatID) return req.Do(api) } @@ -275,7 +275,7 @@ type SendMessageDraftP struct { } func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) { - req := NewRequest[bool]("sendMessageDraft", params) + req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID) return req.Do(api) } diff --git a/utils/limiter.go b/utils/limiter.go index 23ed999..50cf27a 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -20,9 +20,9 @@ type RateLimiter struct { func NewRateLimiter() *RateLimiter { return &RateLimiter{ - // 30 запросов в секунду (burst=30) - globalLimiter: rate.NewLimiter(rate.Limit(30), 30), + globalLimiter: rate.NewLimiter(30, 30), chatLimiters: make(map[int64]*rate.Limiter), + chatLocks: make(map[int64]time.Time), // инициализация! } } @@ -34,96 +34,122 @@ func (rl *RateLimiter) SetGlobalLock(retryAfter int) { defer rl.globalMu.Unlock() rl.globalLockUntil = time.Now().Add(time.Duration(retryAfter) * time.Second) } + func (rl *RateLimiter) SetChatLock(chatID int64, retryAfter int) { + if retryAfter <= 0 { + return + } rl.chatMu.Lock() defer rl.chatMu.Unlock() rl.chatLocks[chatID] = time.Now().Add(time.Duration(retryAfter) * time.Second) } +// GlobalWait блокирует до возможности сделать глобальный запрос. func (rl *RateLimiter) GlobalWait(ctx context.Context) error { - rl.globalMu.RLock() - until := rl.globalLockUntil - rl.globalMu.RUnlock() - - if !until.IsZero() { - if time.Now().Before(until) { - // Ждём до окончания блокировки или отмены контекста - select { - case <-time.After(time.Until(until)): - // блокировка снята - case <-ctx.Done(): - return ctx.Err() - } - } - } - // Теперь ждём разрешения rate limiter'а - return rl.globalLimiter.Wait(ctx) -} -func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { - rl.chatMu.Lock() - until, ok := rl.chatLocks[chatID] - rl.chatMu.Unlock() - if ok && !until.IsZero() { - if time.Now().Before(until) { - select { - case <-time.After(time.Until(until)): - // блокировка снята - case <-ctx.Done(): - return ctx.Err() - } - } - } - - if err := rl.GlobalWait(ctx); err != nil { + // Ждём окончания глобальной блокировки, если она есть + if err := rl.waitForGlobalUnlock(ctx); err != nil { return err } - rl.chatMu.Lock() - chatLimiter, ok := rl.chatLimiters[chatID] - if !ok { - chatLimiter = rate.NewLimiter(rate.Limit(1), 1) - rl.chatLimiters[chatID] = chatLimiter - } - rl.chatMu.Unlock() - return chatLimiter.Wait(ctx) + // Ждём разрешения rate limiter'а + return rl.globalLimiter.Wait(ctx) } +// Wait блокирует до возможности сделать запрос для конкретного чата. +func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { + // Ждём окончания блокировки чата + if err := rl.waitForChatUnlock(ctx, chatID); err != nil { + return err + } + // Затем глобальной блокировки + if err := rl.waitForGlobalUnlock(ctx); err != nil { + return err + } + // Получаем или создаём лимитер для чата + limiter := rl.getChatLimiter(chatID) + return limiter.Wait(ctx) +} + +// GlobalAllow неблокирующая проверка глобального запроса. func (rl *RateLimiter) GlobalAllow() bool { rl.globalMu.RLock() until := rl.globalLockUntil rl.globalMu.RUnlock() - if !until.IsZero() { - if time.Now().Before(until) { - // Ждём до окончания блокировки или отмены контекста - select { - case <-time.After(time.Until(until)): - rl.globalLimiter.Allow() - } - } + if !until.IsZero() && time.Now().Before(until) { + return false } return rl.globalLimiter.Allow() } + +// Allow неблокирующая проверка запроса для чата. func (rl *RateLimiter) Allow(chatID int64) bool { - rl.chatMu.Lock() - until, ok := rl.chatLocks[chatID] - rl.chatMu.Unlock() - if ok && !until.IsZero() { - if time.Now().Before(until) { - select { - case <-time.After(time.Until(until)): - } - } + // Проверяем глобальную блокировку + rl.globalMu.RLock() + globalUntil := rl.globalLockUntil + rl.globalMu.RUnlock() + if !globalUntil.IsZero() && time.Now().Before(globalUntil) { + return false } + // Проверяем блокировку чата + rl.chatMu.Lock() + chatUntil, ok := rl.chatLocks[chatID] + rl.chatMu.Unlock() + if ok && !chatUntil.IsZero() && time.Now().Before(chatUntil) { + return false + } + + // Проверяем глобальный лимитер if !rl.globalLimiter.Allow() { return false } + + // Проверяем лимитер чата + limiter := rl.getChatLimiter(chatID) + return limiter.Allow() +} + +// Внутренние вспомогательные методы + +func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { + rl.globalMu.RLock() + until := rl.globalLockUntil + rl.globalMu.RUnlock() + + if until.IsZero() || time.Now().After(until) { + return nil + } + select { + case <-time.After(time.Until(until)): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { rl.chatMu.Lock() - chatLimiter, ok := rl.chatLimiters[chatID] - if !ok { - chatLimiter = rate.NewLimiter(rate.Limit(1), 1) - rl.chatLimiters[chatID] = chatLimiter + until, ok := rl.chatLocks[chatID] + rl.chatMu.Unlock() + + if !ok || until.IsZero() || time.Now().After(until) { + return nil + } + select { + case <-time.After(time.Until(until)): + return nil + case <-ctx.Done(): + return ctx.Err() } - rl.chatMu.Unlock() - return chatLimiter.Allow() +} + +func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { + rl.chatMu.Lock() + defer rl.chatMu.Unlock() + if lim, ok := rl.chatLimiters[chatID]; ok { + return lim + } + lim := rate.NewLimiter(1, 1) // 1 запрос/сек + rl.chatLimiters[chatID] = lim + return lim } diff --git a/utils/version.go b/utils/version.go index bf1a8b0..ab7dbf0 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.7" + VersionString = "1.0.0-beta.8" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 7 + Beta = 8 ) -- 2.54.0 From 9895edf966ac08b7952f40feba6d3f2cb8e6c120 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 6 Mar 2026 11:59:17 +0300 Subject: [PATCH 25/99] v1.0.0 beta 9 --- bot.go | 5 + drafts.go | 34 ++++- handler.go | 50 +++---- msg_context.go | 2 +- runners.go | 3 + tgapi/api.go | 249 +++++++++++++++++++++++++---------- tgapi/attachments_methods.go | 34 ++--- tgapi/chat_methods.go | 154 +++++++++++----------- tgapi/errors.go | 2 + tgapi/forum_methods.go | 38 +++--- tgapi/messages_methods.go | 164 +++++++++++------------ tgapi/pool.go | 132 +++++++++++++------ tgapi/stickers_methods.go | 4 +- tgapi/uploader_api.go | 100 +++++++++----- tgapi/uploader_methods.go | 18 +-- utils/limiter.go | 99 ++++++++++---- utils/multipart.go | 79 ++++++----- utils/utils.go | 16 ++- utils/version.go | 4 +- 19 files changed, 731 insertions(+), 456 deletions(-) diff --git a/bot.go b/bot.go index 6abec8f..f7439de 100644 --- a/bot.go +++ b/bot.go @@ -74,6 +74,7 @@ type Bot[T DbContext] struct { token string debug bool errorTemplate string + username string logger *slog.Logger RequestLogger *slog.Logger @@ -140,6 +141,10 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { _ = bot.Close() bot.logger.Fatal(err) } + bot.username = Val(u.Username, "") + if bot.username == "" { + bot.logger.Warn("Can't get bot username. Named command wouldn't work!") + } bot.logger.Infof("Authorized as %s\n", u.FirstName) return bot diff --git a/drafts.go b/drafts.go index fb37088..5fa2384 100644 --- a/drafts.go +++ b/drafts.go @@ -1,7 +1,6 @@ package laniakea import ( - "math" "math/rand/v2" "sync/atomic" @@ -17,16 +16,16 @@ type RandomDraftIdGenerator struct { } func (g *RandomDraftIdGenerator) Next() uint64 { - return rand.Uint64N(math.MaxUint64) + return rand.Uint64() } type LinearDraftIdGenerator struct { draftIdGenerator - lastId uint64 + lastId atomic.Uint64 } func (g *LinearDraftIdGenerator) Next() uint64 { - return atomic.AddUint64(&g.lastId, 1) + return g.lastId.Add(1) } type DraftProvider struct { @@ -41,7 +40,8 @@ type DraftProvider struct { generator draftIdGenerator } type Draft struct { - api *tgapi.API + api *tgapi.API + provider *DraftProvider chatID int64 messageThreadID int @@ -59,15 +59,28 @@ func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { } } func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { + g := &LinearDraftIdGenerator{} + g.lastId.Store(startValue) return &DraftProvider{ api: api, - generator: &LinearDraftIdGenerator{lastId: startValue}, + generator: g, drafts: make(map[uint64]*Draft), } } func (d *DraftProvider) NewDraft() *Draft { id := d.generator.Next() - draft := &Draft{d.api, d.chatID, d.messageThreadID, d.parseMode, d.entities, id, ""} + entitiesCopy := make([]tgapi.MessageEntity, 0) + copy(entitiesCopy, d.entities) + draft := &Draft{ + api: d.api, + provider: d, + chatID: d.chatID, + messageThreadID: d.messageThreadID, + parseMode: d.parseMode, + entities: entitiesCopy, + ID: id, + Message: "", + } d.drafts[id] = draft return draft } @@ -87,6 +100,9 @@ func (d *Draft) Push(newText string) error { _, err := d.api.SendMessageDraft(params) return err } +func (d *Draft) Clear() { + d.Message = "" +} func (d *Draft) Flush() error { if d.Message == "" { return nil @@ -102,5 +118,9 @@ func (d *Draft) Flush() error { params.MessageThreadID = d.messageThreadID } _, err := d.api.SendMessage(params) + if err == nil { + d.Clear() + delete(d.provider.drafts, d.ID) + } return err } diff --git a/handler.go b/handler.go index 27ba991..bdf0133 100644 --- a/handler.go +++ b/handler.go @@ -49,34 +49,34 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { ctx.From = update.Message.From ctx.Msg = update.Message + // Убираем префикс text = strings.TrimSpace(text[len(prefix):]) + // Извлекаем команду как первое слово + spaceIndex := strings.Index(text, " ") + var cmd string + var args string + + if spaceIndex == -1 { + cmd = text + args = "" + } else { + cmd = text[:spaceIndex] + args = strings.TrimSpace(text[spaceIndex:]) + } + + if strings.Contains(cmd, "@") { + botUsername := bot.username + if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) { + cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname + } + } + + // Ищем команду по точному совпадению for _, plugin := range bot.plugins { - for cmd := range plugin.commands { - if !strings.HasPrefix(text, cmd) { - continue - } - requestParts := strings.Split(text, " ") - cmdParts := strings.Split(cmd, " ") - isValid := true - for i, part := range cmdParts { - if part != requestParts[i] { - isValid = false - break - } - } - - if !isValid { - continue - } - - ctx.Text = strings.TrimSpace(text[len(cmd):]) - if ctx.Text == "" { - ctx.Args = []string{} - } else { - ctx.Args = strings.Split(ctx.Text, " ") - } - + if _, exists := plugin.commands[cmd]; exists { + ctx.Text = args + ctx.Args = strings.Fields(args) // Убирает лишние пробелы if !plugin.executeMiddlewares(ctx, bot.dbContext) { return } diff --git a/msg_context.go b/msg_context.go index 817a630..5ad0f40 100644 --- a/msg_context.go +++ b/msg_context.go @@ -103,7 +103,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard) *AnswerMess params := tgapi.SendMessageP{ ChatID: ctx.Msg.Chat.ID, Text: text, - ParseMode: tgapi.ParseMD, + ParseMode: tgapi.ParseMDV2, } if keyboard != nil { params.ReplyMarkup = keyboard.Get() diff --git a/runners.go b/runners.go index 6f273c7..29c0ed5 100644 --- a/runners.go +++ b/runners.go @@ -13,6 +13,9 @@ type Runner[T DbContext] struct { fn RunnerFn[T] } +// NewRunner creates a new Runner with async=true by default. +// Builder methods (Onetime, Async, Timeout) modify the Runner in-place. +// DO NOT call builder methods concurrently or after Execute(). func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { return &Runner[T]{ name: name, fn: fn, async: true, diff --git a/tgapi/api.go b/tgapi/api.go index fd2647a..8b994ab 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -14,6 +13,8 @@ import ( "git.nix13.pw/scuroneko/slog" ) +// APIOpts holds configuration options for initializing the Telegram API client. +// Use the provided setter methods to build options — do not construct directly. type APIOpts struct { token string client *http.Client @@ -24,36 +25,59 @@ type APIOpts struct { dropOverflowLimit bool } -var ErrPoolUnexpected = errors.New("unexpected response from pool") - +// NewAPIOpts creates a new APIOpts with default values. +// Use setter methods to customize behavior. func NewAPIOpts(token string) *APIOpts { - return &APIOpts{token: token, client: nil, useTestServer: false, apiUrl: "https://api.telegram.org"} + return &APIOpts{ + token: token, + client: nil, + useTestServer: false, + apiUrl: "https://api.telegram.org", + } } + +// SetHTTPClient sets a custom HTTP client. Use this for timeouts, proxies, or custom transport. +// If not set, a default client with 45s timeout is used. func (opts *APIOpts) SetHTTPClient(client *http.Client) *APIOpts { if client != nil { opts.client = client } return opts } + +// UseTestServer enables use of Telegram's test server (https://api.test.telegram.org). +// Only for development/testing. func (opts *APIOpts) UseTestServer(use bool) *APIOpts { opts.useTestServer = use return opts } + +// SetAPIUrl overrides the default Telegram API URL. +// Useful for self-hosted bots or proxies. func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts { if apiUrl != "" { opts.apiUrl = apiUrl } return opts } + +// SetLimiter sets a rate limiter to enforce Telegram's API limits. +// Recommended: use utils.NewRateLimiter() for correct per-chat and global throttling. func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts { opts.limiter = limiter return opts } + +// SetLimiterDrop enables "drop mode" for rate limiting. +// If true, requests exceeding limits return ErrDropOverflow immediately. +// If false, requests block until capacity is available. func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts { opts.dropOverflowLimit = b return opts } +// API is the main Telegram Bot API client. +// It manages HTTP requests, rate limiting, retries, and connection pooling. type API struct { token string client *http.Client @@ -61,73 +85,116 @@ type API struct { useTestServer bool apiUrl string - pool *WorkerPool + pool *workerPool Limiter *utils.RateLimiter dropOverflowLimit bool } +// NewAPI creates a new API client from options. +// Always call CloseApi() when done to release resources. func NewAPI(opts *APIOpts) *API { l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API") l.AddWriter(l.CreateJsonStdoutWriter()) + client := opts.client if client == nil { client = &http.Client{Timeout: time.Second * 45} } - pool := NewWorkerPool(16, 256) - pool.Start(context.Background()) + + pool := newWorkerPool(16, 256) + pool.start(context.Background()) + return &API{ - opts.token, client, l, - opts.useTestServer, opts.apiUrl, - pool, opts.limiter, opts.dropOverflowLimit, + token: opts.token, + client: client, + logger: l, + useTestServer: opts.useTestServer, + apiUrl: opts.apiUrl, + pool: pool, + Limiter: opts.limiter, + dropOverflowLimit: opts.dropOverflowLimit, } } + +// CloseApi shuts down the internal worker pool and closes the logger. +// Must be called to avoid resource leaks. func (api *API) CloseApi() error { - api.pool.Stop() + api.pool.stop() return api.logger.Close() } -func (api *API) GetLogger() *slog.Logger { return api.logger } +// GetLogger returns the internal logger for custom logging. +func (api *API) GetLogger() *slog.Logger { + return api.logger +} + +// ResponseParameters contains Telegram API response metadata (e.g., retry_after, migrate_to_chat_id). type ResponseParameters struct { MigrateToChatID *int64 `json:"migrate_to_chat_id,omitempty"` RetryAfter *int `json:"retry_after,omitempty"` } -type ApiResponse[R any] struct { - Ok bool `json:"ok"` - Description string `json:"description,omitempty"` - Result R `json:"result,omitempty"` - ErrorCode int `json:"error_code,omitempty"` - Parameters *ResponseParameters `json:"parameters,omitempty"` +// ApiResponse is the standard Telegram Bot API response structure. +// Generic over Result type R. +type ApiResponse[R any] struct { + Ok bool `json:"ok"` + Description string `json:"description,omitempty"` + Result R `json:"result,omitempty"` + ErrorCode int `json:"error_code,omitempty"` + Parameters *ResponseParameters `json:"parameters,omitempty"` } + +// TelegramRequest is an internal helper struct. +// DO NOT USE NewRequest or NewRequestWithChatID — they are unsafe and discouraged. +// Instead, use explicit methods like SendMessage, GetUpdates, etc. +// +// Why? Because using generics with arbitrary types P and R leads to: +// - No compile-time validation of parameters +// - No IDE autocompletion +// - Runtime panics on malformed JSON +// - Hard-to-debug errors +// +// Recommended: Define specific methods for each Telegram method (see below). type TelegramRequest[R, P any] struct { method string params P chatId int64 } +// NewRequest and NewRequestWithChatID are DEPRECATED. +// They encourage unsafe, untyped usage and bypass Go's type safety. +// Instead, define explicit, type-safe methods for each Telegram API endpoint. +// +// Example: +// +// func (api *API) SendMessage(ctx context.Context, chatID int64, text string) (Message, error) { ... } +// +// This provides: +// +// ✅ Compile-time validation +// ✅ IDE autocompletion +// ✅ Clear API surface +// ✅ Better error messages +// +// DO NOT use these constructors in production code. +// This can be used ONLY for testing or if you NEED method, that wasn't added as function. func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { return TelegramRequest[R, P]{method, params, 0} } + func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { return TelegramRequest[R, P]{method, params, chatId} } + +// doRequest performs a single HTTP request to Telegram API. +// Handles rate limiting, retries on 429, and parses responses. +// Must be called within a worker pool context if using DoWithContext. func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { var zero R - if api.Limiter != nil { - if api.dropOverflowLimit { - if !api.Limiter.GlobalAllow() { - return zero, errors.New("rate limited") - } - } else { - if err := api.Limiter.GlobalWait(ctx); err != nil { - return zero, err - } - } - } data, err := json.Marshal(r.params) if err != nil { - return zero, err + return zero, fmt.Errorf("failed to marshal request: %w", err) } buf := bytes.NewBuffer(data) @@ -136,54 +203,82 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro methodPrefix = "/test" } url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method) + req, err := http.NewRequestWithContext(ctx, "POST", url, buf) if err != nil { - return zero, err + return zero, fmt.Errorf("failed to create request: %w", err) } + req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) + req.Header.Set("Accept-Encoding", "gzip") + req.ContentLength = int64(len(data)) - api.logger.Debugln("REQ", api.apiUrl, r.method, buf.String()) - res, err := api.client.Do(req) - if err != nil { - return zero, err - } - defer func(Body io.ReadCloser) { - _ = Body.Close() - }(res.Body) - - data, err = readBody(res.Body) - if err != nil { - return zero, err - } - api.logger.Debugln("RES", r.method, string(data)) - if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusTooManyRequests { - return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(data)) - } - - responseData, err := parseBody[R](data) - if errors.Is(err, ErrRateLimit) { - if responseData.Parameters != nil { - after := 0 - if responseData.Parameters.RetryAfter != nil { - after = *responseData.Parameters.RetryAfter + for { + // Apply rate limiting before making the request + if api.Limiter != nil { + if err := api.Limiter.Check(ctx, api.dropOverflowLimit, r.chatId); err != nil { + return zero, err } - if r.chatId > 0 { - api.Limiter.SetChatLock(r.chatId, after) - } else { - api.Limiter.SetGlobalLock(after) - } - time.Sleep(time.Duration(after) * time.Second) - return r.doRequest(ctx, api) } - return zero, ErrRateLimit + + api.logger.Debugln("REQ", url, string(data)) + + resp, err := api.client.Do(req) + if err != nil { + return zero, fmt.Errorf("HTTP request failed: %w", err) + } + + data, err = readBody(resp.Body) + _ = resp.Body.Close() // ensure body is closed + if err != nil { + return zero, fmt.Errorf("failed to read response body: %w", err) + } + + api.logger.Debugln("RES", r.method, string(data)) + + response, err := parseBody[R](data) + if err != nil { + return zero, fmt.Errorf("failed to parse response: %w", err) + } + + if !response.Ok { + // Handle rate limiting (429) + if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { + after := *response.Parameters.RetryAfter + api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatId) + + // Apply cooldown to global or chat-specific limiter + if r.chatId > 0 { + api.Limiter.SetChatLock(r.chatId, after) + } else { + api.Limiter.SetGlobalLock(after) + } + + // Wait and retry + select { + case <-ctx.Done(): + return zero, ctx.Err() + case <-time.After(time.Duration(after) * time.Second): + continue // retry request + } + } + + // Other API errors + return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) + } + + return response.Result, nil } - return responseData.Result, err } + +// DoWithContext executes the request asynchronously via the worker pool. +// Returns result or error via channel. Respects context cancellation. func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, error) { var zero R - result, err := api.pool.Submit(ctx, func(ctx context.Context) (any, error) { + + resultChan, err := api.pool.submit(ctx, func(ctx context.Context) (any, error) { return r.doRequest(ctx, api) }) if err != nil { @@ -193,35 +288,45 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R, select { case <-ctx.Done(): return zero, ctx.Err() - case res := <-result: - if res.Err != nil { - return zero, res.Err + case res := <-resultChan: + if res.err != nil { + return zero, res.err } - if val, ok := res.Value.(R); ok { + if val, ok := res.value.(R); ok { return val, nil } return zero, ErrPoolUnexpected } } + +// Do executes the request synchronously with a background context. +// Use only for simple, non-critical calls. func (r TelegramRequest[R, P]) Do(api *API) (R, error) { return r.DoWithContext(context.Background(), api) } +// readBody reads and limits response body to prevent memory exhaustion. +// Telegram responses are typically small (<1MB), but we cap at 10MB. func readBody(body io.ReadCloser) ([]byte, error) { - reader := io.LimitReader(body, 10<<20) + reader := io.LimitReader(body, 10<<20) // 10 MB return io.ReadAll(reader) } + +// parseBody unmarshals Telegram API response and returns structured result. +// Returns ErrRateLimit internally if error_code == 429 — caller must handle via response.Ok check. func parseBody[R any](data []byte) (ApiResponse[R], error) { var resp ApiResponse[R] err := json.Unmarshal(data, &resp) if err != nil { - return resp, err + return resp, fmt.Errorf("failed to unmarshal JSON: %w", err) } + if !resp.Ok { if resp.ErrorCode == 429 { - return resp, ErrRateLimit + return resp, ErrRateLimit // internal use only } return resp, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description) } + return resp, nil } diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 455cd8c..1a1bfb1 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -24,13 +24,13 @@ type SendPhotoP struct { } func (api *API) SendPhoto(params SendPhotoP) (Message, error) { - req := NewRequest[Message]("sendPhoto", params) + req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID) return req.Do(api) } type SendAudioP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -53,13 +53,13 @@ type SendAudioP struct { } func (api *API) SendAudio(params SendAudioP) (Message, error) { - req := NewRequest[Message]("sendAudio", params) + req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID) return req.Do(api) } type SendDocumentP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -79,13 +79,13 @@ type SendDocumentP struct { } func (api *API) SendDocument(params SendDocumentP) (Message, error) { - req := NewRequest[Message]("sendDocument", params) + req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID) return req.Do(api) } type SendVideoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -114,13 +114,13 @@ type SendVideoP struct { } func (api *API) SendVideo(params SendVideoP) (Message, error) { - req := NewRequest[Message]("sendVideo", params) + req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID) return req.Do(api) } type SendAnimationP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -145,13 +145,13 @@ type SendAnimationP struct { } func (api *API) SendAnimation(params SendAnimationP) (Message, error) { - req := NewRequest[Message]("sendAnimation", params) + req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID) return req.Do(api) } type SendVoiceP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -171,13 +171,13 @@ type SendVoiceP struct { } func (api *API) SendVoice(params *SendVoiceP) (Message, error) { - req := NewRequest[Message]("sendVoice", params) + req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) return req.Do(api) } type SendVideoNoteP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -195,13 +195,13 @@ type SendVideoNoteP struct { } func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) { - req := NewRequest[Message]("sendVideoNote", params) + req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID) return req.Do(api) } type SendPaidMediaP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` StarCount int `json:"star_count,omitempty"` @@ -222,13 +222,13 @@ type SendPaidMediaP struct { } func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) { - req := NewRequest[Message]("sendPaidMedia", params) + req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID) return req.Do(api) } type SendMediaGroupP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -241,6 +241,6 @@ type SendMediaGroupP struct { } func (api *API) SendMediaGroup(params SendMediaGroupP) (Message, error) { - req := NewRequest[Message]("sendMediaGroup", params) + req := NewRequestWithChatID[Message]("sendMediaGroup", params, params.ChatID) return req.Do(api) } diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index 8294258..ad1fc33 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -1,30 +1,30 @@ package tgapi type BanChatMemberP struct { - ChatID int `json:"chat_id"` - UserID int `json:"user_id"` - UntilDate int `json:"until_date,omitempty"` - RevokeMessages bool `json:"revoke_messages,omitempty"` + ChatID int64 `json:"chat_id"` + UserID int `json:"user_id"` + UntilDate int `json:"until_date,omitempty"` + RevokeMessages bool `json:"revoke_messages,omitempty"` } func (api *API) BanChatMember(params BanChatMemberP) (bool, error) { - req := NewRequest[bool]("banChatMember", params) + req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID) return req.Do(api) } type UnbanChatMemberP struct { - ChatID int `json:"chat_id"` - UserID int `json:"user_id"` - OnlyIfBanned bool `json:"only_if_banned"` + ChatID int64 `json:"chat_id"` + UserID int `json:"user_id"` + OnlyIfBanned bool `json:"only_if_banned"` } func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) { - req := NewRequest[bool]("unbanChatMember", params) + req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID) return req.Do(api) } type RestrictChatMemberP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` Permissions ChatPermissions `json:"permissions"` UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"` @@ -32,14 +32,14 @@ type RestrictChatMemberP struct { } func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) { - req := NewRequest[bool]("restrictChatMember", params) + req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID) return req.Do(api) } type PromoteChatMember struct { - ChatID int `json:"chat_id"` - UserID int `json:"user_id"` - IsAnonymous bool `json:"is_anonymous,omitempty"` + ChatID int64 `json:"chat_id"` + UserID int `json:"user_id"` + IsAnonymous bool `json:"is_anonymous,omitempty"` CanManageChat bool `json:"can_manage_chat,omitempty"` CanDeleteMessages bool `json:"can_delete_messages,omitempty"` @@ -60,74 +60,74 @@ type PromoteChatMember struct { } func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) { - req := NewRequest[bool]("promoteChatMember", params) + req := NewRequestWithChatID[bool]("promoteChatMember", params, params.ChatID) return req.Do(api) } type SetChatAdministratorCustomTitleP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` CustomTitle string `json:"custom_title"` } func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCustomTitleP) (bool, error) { - req := NewRequest[bool]("setChatAdministratorCustomTitle", params) + req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID) return req.Do(api) } type SetChatMemberTagP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` Tag string `json:"tag,omitempty"` } func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) { - req := NewRequest[bool]("setChatMemberTag", params) + req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID) return req.Do(api) } type BanChatSenderChatP struct { - ChatID int `json:"chat_id"` - SenderChatID int `json:"sender_chat_id"` + ChatID int64 `json:"chat_id"` + SenderChatID int64 `json:"sender_chat_id"` } func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) { - req := NewRequest[bool]("banChatSenderChat", params) + req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID) return req.Do(api) } type UnbanChatSenderChatP struct { - ChatID int `json:"chat_id"` - SenderChatID int `json:"sender_chat_id"` + ChatID int64 `json:"chat_id"` + SenderChatID int64 `json:"sender_chat_id"` } func (api *API) UnbanChatSenderChat(params BanChatSenderChatP) (bool, error) { - req := NewRequest[bool]("unbanChatSenderChat", params) + req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID) return req.Do(api) } type SetChatPermissionsP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` Permissions ChatPermissions `json:"permissions"` UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"` } func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) { - req := NewRequest[bool]("setChatPermissions", params) + req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID) return req.Do(api) } type ExportChatInviteLinkP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, error) { - req := NewRequest[string]("exportChatInviteLink", params) + req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID) return req.Do(api) } type CreateChatInviteLinkP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` Name *string `json:"name,omitempty"` ExpireDate int `json:"expire_date,omitempty"` MemberLimit int `json:"member_limit,omitempty"` @@ -135,12 +135,12 @@ type CreateChatInviteLinkP struct { } func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLink, error) { - req := NewRequest[ChatInviteLink]("createChatInviteLink", params) + req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID) return req.Do(api) } type EditChatInviteLinkP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` InviteLink string `json:"invite_link"` Name string `json:"name,omitempty"` @@ -150,60 +150,60 @@ type EditChatInviteLinkP struct { } func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink, error) { - req := NewRequest[ChatInviteLink]("editChatInviteLink", params) + req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID) return req.Do(api) } type CreateChatSubscriptionInviteLinkP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` Name string `json:"name,omitempty"` SubscriptionPeriod int `json:"subscription_period,omitempty"` SubscriptionPrice int `json:"subscription_price,omitempty"` } func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) { - req := NewRequest[ChatInviteLink]("createChatSubscriptionInviteLink", params) + req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID) return req.Do(api) } type EditChatSubscriptionInviteLinkP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` InviteLink string `json:"invite_link"` Name string `json:"name,omitempty"` } func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) { - req := NewRequest[ChatInviteLink]("editChatSubscriptionInviteLink", params) + req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID) return req.Do(api) } type RevokeChatInviteLinkP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` InviteLink string `json:"invite_link"` } func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLink, error) { - req := NewRequest[ChatInviteLink]("revokeChatInviteLink", params) + req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID) return req.Do(api) } type ApproveChatJoinRequestP struct { - ChatID int `json:"chat_id"` - UserID int `json:"user_id"` + ChatID int64 `json:"chat_id"` + UserID int `json:"user_id"` } func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, error) { - req := NewRequest[bool]("approveChatJoinRequest", params) + req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID) return req.Do(api) } type DeclineChatJoinRequestP struct { - ChatID int `json:"chat_id"` - UserID int `json:"user_id"` + ChatID int64 `json:"chat_id"` + UserID int `json:"user_id"` } func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, error) { - req := NewRequest[bool]("declineChatJoinRequest", params) + req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID) return req.Do(api) } @@ -213,143 +213,143 @@ func (api *API) SetChatPhoto() { } type DeleteChatPhotoP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) { - req := NewRequest[bool]("deleteChatPhoto", params) + req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID) return req.Do(api) } type SetChatTitleP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` Title string `json:"title"` } func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) { - req := NewRequest[bool]("setChatTitle", params) + req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID) return req.Do(api) } type SetChatDescriptionP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` Description string `json:"description"` } func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) { - req := NewRequest[bool]("setChatDescription", params) + req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID) return req.Do(api) } type PinChatMessageP struct { BusinessConnectionID *string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` DisableNotification bool `json:"disable_notification,omitempty"` } func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) { - req := NewRequest[bool]("pinChatMessage", params) + req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID) return req.Do(api) } type UnpinChatMessageP struct { BusinessConnectionID *string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` } func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) { - req := NewRequest[bool]("unpinChatMessage", params) + req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID) return req.Do(api) } type UnpinAllChatMessagesP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error) { - req := NewRequest[bool]("unpinAllChatMessages", params) + req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID) return req.Do(api) } type LeaveChatP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } func (api *API) LeaveChat(params LeaveChatP) (bool, error) { - req := NewRequest[bool]("leaveChatP", params) + req := NewRequestWithChatID[bool]("leaveChatP", params, params.ChatID) return req.Do(api) } type GetChatP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } func (api *API) GetChatP(params GetChatP) (ChatFullInfo, error) { - req := NewRequest[ChatFullInfo]("getChatP", params) + req := NewRequestWithChatID[ChatFullInfo]("getChatP", params, params.ChatID) return req.Do(api) } type GetChatAdministratorsP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMember, error) { - req := NewRequest[[]ChatMember]("getChatAdministrators", params) + req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID) return req.Do(api) } type GetChatMembersCountP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) { - req := NewRequest[int]("getChatMemberCount", params) + req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) return req.Do(api) } type GetChatMemberP struct { - ChatID int `json:"chat_id"` - UserID int `json:"user_id"` + ChatID int64 `json:"chat_id"` + UserID int `json:"user_id"` } func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) { - req := NewRequest[ChatMember]("getChatMember", params) + req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID) return req.Do(api) } type SetChatStickerSetP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` StickerSetName string `json:"sticker_set_name"` } func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) { - req := NewRequest[bool]("setChatStickerSet", params) + req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID) return req.Do(api) } type DeleteChatStickerSetP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) { - req := NewRequest[bool]("deleteChatStickerSet", params) + req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID) return req.Do(api) } type GetUserChatBoostsP struct { - ChatID int `json:"chat_id"` - UserID int `json:"user_id"` + ChatID int64 `json:"chat_id"` + UserID int `json:"user_id"` } func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, error) { - req := NewRequest[UserChatBoosts]("getUserChatBoosts", params) + req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID) return req.Do(api) } type GetChatGiftsP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"` ExcludeSaved bool `json:"exclude_saved,omitempty"` ExcludeUnlimited bool `json:"exclude_unlimited,omitempty"` @@ -363,6 +363,6 @@ type GetChatGiftsP struct { } func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) { - req := NewRequest[OwnedGifts]("getChatGifts", params) + req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID) return req.Do(api) } diff --git a/tgapi/errors.go b/tgapi/errors.go index 48a368b..fa1d205 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -3,3 +3,5 @@ package tgapi import "errors" var ErrRateLimit = errors.New("rate limit exceeded") +var ErrPoolUnexpected = errors.New("unexpected response from pool") +var ErrPoolQueueFull = errors.New("worker pool queue full") diff --git a/tgapi/forum_methods.go b/tgapi/forum_methods.go index e098983..137f87d 100644 --- a/tgapi/forum_methods.go +++ b/tgapi/forum_methods.go @@ -1,24 +1,24 @@ package tgapi type BaseForumTopicP struct { - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id"` } -func (api *API) GetForumTopicIconSet() ([]Sticker, error) { - req := NewRequest[[]Sticker]("getForumTopicIconSet", NoParams) +func (api *API) GetForumTopicIconStickers() ([]Sticker, error) { + req := NewRequest[[]Sticker]("getForumTopicIconStickers", NoParams) return req.Do(api) } type CreateForumTopicP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` Name string `json:"name"` IconColor ForumTopicIconColor `json:"icon_color"` IconCustomEmojiID string `json:"icon_custom_emoji_id"` } func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) { - req := NewRequest[ForumTopic]("createForumTopic", params) + req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID) return req.Do(api) } @@ -29,58 +29,58 @@ type EditForumTopicP struct { } func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) { - req := NewRequest[bool]("editForumTopic", params) + req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID) return req.Do(api) } func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) { - req := NewRequest[bool]("closeForumTopic", params) + req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID) return req.Do(api) } func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) { - req := NewRequest[bool]("reopenForumTopic", params) + req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID) return req.Do(api) } func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) { - req := NewRequest[bool]("deleteForumTopic", params) + req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID) return req.Do(api) } func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error) { - req := NewRequest[bool]("unpinAllForumTopicMessages", params) + req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID) return req.Do(api) } type BaseGeneralForumTopicP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } type EditGeneralForumTopicP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` Name string `json:"name"` } func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, error) { - req := NewRequest[bool]("editGeneralForumTopic", params) + req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID) return req.Do(api) } func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { - req := NewRequest[bool]("closeGeneralForumTopic", params) + req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID) return req.Do(api) } func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { - req := NewRequest[bool]("reopenGeneralForumTopic", params) + req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID) return req.Do(api) } func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { - req := NewRequest[bool]("hideGeneralForumTopic", params) + req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID) return req.Do(api) } func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { - req := NewRequest[bool]("unhideGeneralForumTopic", params) + req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID) return req.Do(api) } func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP) (bool, error) { - req := NewRequest[bool]("unpinAllGeneralForumTopicMessages", params) + req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID) return req.Do(api) } diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index 2e8d0bb..cbcd7ce 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -26,47 +26,47 @@ func (api *API) SendMessage(params SendMessageP) (Message, error) { } type ForwardMessageP struct { - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` - MessageID int `json:"message_id,omitempty"` - FromChatID int `json:"from_chat_id,omitempty"` - VideoStartTimestamp int `json:"video_start_timestamp,omitempty"` - DisableNotification bool `json:"disable_notification,omitempty"` - ProtectContent bool `json:"protect_content,omitempty"` + MessageID int `json:"message_id,omitempty"` + FromChatID int64 `json:"from_chat_id,omitempty"` + VideoStartTimestamp int `json:"video_start_timestamp,omitempty"` + DisableNotification bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"` SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` } func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) { - req := NewRequest[Message]("forwardMessage", params) + req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID) return req.Do(api) } type ForwardMessagesP struct { - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` - FromChatID int `json:"from_chat_id,omitempty"` + FromChatID int64 `json:"from_chat_id,omitempty"` MessageIDs []int `json:"message_ids,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` } func (api *API) ForwardMessages(params ForwardMessagesP) ([]int, error) { - req := NewRequest[[]int]("forwardMessages", params) + req := NewRequestWithChatID[[]int]("forwardMessages", params, params.ChatID) return req.Do(api) } type CopyMessageP struct { - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` - FromChatID int `json:"from_chat_id"` + FromChatID int64 `json:"from_chat_id"` MessageID int `json:"message_id"` VideoStartTimestamp int `json:"video_start_timestamp,omitempty"` Caption string `json:"caption,omitempty"` @@ -85,16 +85,16 @@ type CopyMessageP struct { } func (api *API) CopyMessage(params CopyMessageP) (int, error) { - req := NewRequest[int]("copyMessage", params) + req := NewRequestWithChatID[int]("copyMessage", params, params.ChatID) return req.Do(api) } type CopyMessagesP struct { - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` - FromChatID int `json:"from_chat_id,omitempty"` + FromChatID int64 `json:"from_chat_id,omitempty"` MessageIDs []int `json:"message_ids,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` @@ -102,15 +102,15 @@ type CopyMessagesP struct { } func (api *API) CopyMessages(params CopyMessagesP) ([]int, error) { - req := NewRequest[[]int]("copyMessages", params) + req := NewRequestWithChatID[[]int]("copyMessages", params, params.ChatID) return req.Do(api) } type SendLocationP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID int `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` @@ -130,15 +130,15 @@ type SendLocationP struct { } func (api *API) SendLocation(params SendLocationP) (Message, error) { - req := NewRequest[Message]("sendLocation", params) + req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID) return req.Do(api) } type SendVenueP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID int `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` @@ -160,15 +160,15 @@ type SendVenueP struct { } func (api *API) SendVenue(params SendVenueP) (Message, error) { - req := NewRequest[Message]("sendVenue", params) + req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID) return req.Do(api) } type SendContactP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID int `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` PhoneNumber string `json:"phone_number"` FirstName string `json:"first_name"` @@ -186,14 +186,14 @@ type SendContactP struct { } func (api *API) SendContact(params SendContactP) (Message, error) { - req := NewRequest[Message]("sendContact", params) + req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID) return req.Do(api) } type SendPollP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` + BusinessConnectionID int `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` Question string `json:"question"` QuestionParseMode ParseMode `json:"question_mode,omitempty"` @@ -220,13 +220,13 @@ type SendPollP struct { } func (api *API) SendPoll(params SendPollP) (Message, error) { - req := NewRequest[Message]("sendPoll", params) + req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID) return req.Do(api) } type SendChecklistP struct { BusinessConnectionID int `json:"business_connection_id"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` Checklist InputChecklist `json:"checklist"` DisableNotification bool `json:"disable_notification,omitempty"` @@ -238,15 +238,15 @@ type SendChecklistP struct { } func (api *API) SendChecklist(params SendChecklistP) (Message, error) { - req := NewRequest[Message]("sendChecklist", params) + req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID) return req.Do(api) } type SendDiceP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID int `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Emoji string `json:"emoji,omitempty"` @@ -261,7 +261,7 @@ type SendDiceP struct { } func (api *API) SendDice(params SendDiceP) (Message, error) { - req := NewRequest[Message]("sendDice", params) + req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID) return req.Do(api) } @@ -287,19 +287,19 @@ type SendChatActionP struct { } func (api *API) SendChatAction(params SendChatActionP) (bool, error) { - req := NewRequest[bool]("sendChatAction", params) + req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID) return req.Do(api) } type SetMessageReactionP struct { - ChatId int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageId int `json:"message_id"` Reaction []ReactionType `json:"reaction"` IsBig bool `json:"is_big,omitempty"` } func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) { - req := NewRequest[bool]("setMessageReaction", params) + req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID) return req.Do(api) } @@ -320,11 +320,11 @@ type EditMessageTextP struct { func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { - req := NewRequest[bool]("editMessageText", params) + req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID) res, err := req.Do(api) return zero, res, err } - req := NewRequest[Message]("editMessageText", params) + req := NewRequestWithChatID[Message]("editMessageText", params, params.ChatID) res, err := req.Do(api) return res, false, err } @@ -344,18 +344,18 @@ type EditMessageCaptionP struct { func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { - req := NewRequest[bool]("editMessageCaption", params) + req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID) res, err := req.Do(api) return zero, res, err } - req := NewRequest[Message]("editMessageCaption", params) + req := NewRequestWithChatID[Message]("editMessageCaption", params, params.ChatID) res, err := req.Do(api) return res, false, err } type EditMessageMediaP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"` Message InputMedia `json:"message"` @@ -367,18 +367,18 @@ type EditMessageMediaP struct { func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { - req := NewRequest[bool]("editMessageMedia", params) + req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID) res, err := req.Do(api) return zero, res, err } - req := NewRequest[Message]("editMessageMedia", params) + req := NewRequestWithChatID[Message]("editMessageMedia", params, params.ChatID) res, err := req.Do(api) return res, false, err } type EditMessageLiveLocationP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"` @@ -396,18 +396,18 @@ type EditMessageLiveLocationP struct { func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { - req := NewRequest[bool]("editMessageLiveLocation", params) + req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID) res, err := req.Do(api) return zero, res, err } - req := NewRequest[Message]("editMessageLiveLocation", params) + req := NewRequestWithChatID[Message]("editMessageLiveLocation", params, params.ChatID) res, err := req.Do(api) return res, false, err } type StopMessageLiveLocationP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` @@ -418,31 +418,31 @@ type StopMessageLiveLocationP struct { func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { - req := NewRequest[bool]("stopMessageLiveLocation", params) + req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID) res, err := req.Do(api) return zero, res, err } - req := NewRequest[Message]("stopMessageLiveLocation", params) + req := NewRequestWithChatID[Message]("stopMessageLiveLocation", params, params.ChatID) res, err := req.Do(api) return res, false, err } type EditMessageChecklistP struct { BusinessConnectionID string `json:"business_connection_id"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` Checklist InputChecklist `json:"checklist"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, error) { - req := NewRequest[Message]("editMessageChecklist", params) + req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID) return req.Do(api) } type EditMessageReplyMarkupP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` @@ -451,46 +451,46 @@ type EditMessageReplyMarkupP struct { func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { - req := NewRequest[bool]("editMessageReplyMarkup", params) + req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID) res, err := req.Do(api) return zero, res, err } - req := NewRequest[Message]("editMessageReplyMarkup", params) + req := NewRequestWithChatID[Message]("editMessageReplyMarkup", params, params.ChatID) res, err := req.Do(api) return res, false, err } type StopPollP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` InlineMessageID string `json:"inline_message_id,omitempty"` } func (api *API) StopPoll(params StopPollP) (Poll, error) { - req := NewRequest[Poll]("stopPoll", params) + req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID) return req.Do(api) } type ApproveSuggestedPostP struct { - ChatID int `json:"chat_id"` - MessageID int `json:"message_id"` - SendDate int `json:"send_date,omitempty"` + ChatID int64 `json:"chat_id"` + MessageID int `json:"message_id"` + SendDate int `json:"send_date,omitempty"` } func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error) { - req := NewRequest[bool]("approveSuggestedPost", params) + req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID) return req.Do(api) } type DeclineSuggestedPostP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` Comment string `json:"comment,omitempty"` } func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error) { - req := NewRequest[bool]("declineSuggestedPost", params) + req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID) return req.Do(api) } @@ -500,17 +500,17 @@ type DeleteMessageP struct { } func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) { - req := NewRequest[bool]("deleteMessage", params) + req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID) return req.Do(api) } type DeleteMessagesP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageIDs []int `json:"message_ids"` } func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) { - req := NewRequest[bool]("deleteMessages", params) + req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID) return req.Do(api) } diff --git a/tgapi/pool.go b/tgapi/pool.go index 79a126e..774938a 100644 --- a/tgapi/pool.go +++ b/tgapi/pool.go @@ -2,91 +2,141 @@ package tgapi import ( "context" - "errors" "sync" ) -var ErrPoolQueueFull = errors.New("worker pool queue full") - -type RequestEnvelope struct { - DoFunc func(context.Context) (any, error) // функция, которая выполнит запрос и вернет any - ResultCh chan RequestResult // канал для результата -} -type RequestResult struct { - Value any - Err error +// workerPool — приватная структура, управляющая пулом воркеров. +// Внешний код не может создавать или напрямую взаимодействовать с этой структурой. +// Используется только через экспортируемые методы newWorkerPool, start, stop, submit. +type workerPool struct { + taskCh chan requestEnvelope // канал для принятия задач (буферизованный) + queueSize int // максимальный размер очереди + workers int // количество воркеров (горутин) + wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке + quit chan struct{} // канал для сигнала остановки + started bool // флаг, указывающий, запущен ли пул + startedMu sync.Mutex // мьютекс для безопасного доступа к started } -// WorkerPool управляет воркерами и очередью -type WorkerPool struct { - taskCh chan RequestEnvelope - queueSize int - workers int - wg sync.WaitGroup - quit chan struct{} - started bool - startedMu sync.Mutex +// requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата. +// Используется только внутри пакета для передачи задач воркерам. +type requestEnvelope struct { + doFunc func(context.Context) (any, error) // функция, выполняющая запрос + resultCh chan requestResult // канал, через который воркер вернёт результат } -func NewWorkerPool(workers int, queueSize int) *WorkerPool { - return &WorkerPool{ - taskCh: make(chan RequestEnvelope, queueSize), +// requestResult — приватная структура, представляющая результат выполнения задачи. +// Внешний код получает его через канал, но не знает структуры — только через <-chan requestResult. +type requestResult struct { + value any // значение, возвращённое задачей + err error // ошибка, если возникла +} + +// newWorkerPool создаёт новый пул воркеров с заданным количеством горутин и размером очереди. +// Это единственный способ создать workerPool — внешний код не может создать его напрямую. +func newWorkerPool(workers int, queueSize int) *workerPool { + if workers <= 0 { + workers = 1 // защита от некорректных значений + } + if queueSize <= 0 { + queueSize = 100 // разумный дефолт + } + + return &workerPool{ + taskCh: make(chan requestEnvelope, queueSize), queueSize: queueSize, workers: workers, quit: make(chan struct{}), } } -// Start запускает воркеров -func (p *WorkerPool) Start(ctx context.Context) { +// start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди. +// Метод идемпотентен: если пул уже запущен — ничего не делает. +// Должен вызываться перед первым вызовом submit. +func (p *workerPool) start(ctx context.Context) { p.startedMu.Lock() defer p.startedMu.Unlock() if p.started { - return + return // уже запущен — ничего не делаем } p.started = true + + // Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле for i := 0; i < p.workers; i++ { p.wg.Add(1) - go p.worker(ctx) + go p.worker(ctx) // запускаем горутину с контекстом } } -// Stop останавливает пул (ждет завершения текущих задач) -func (p *WorkerPool) Stop() { - close(p.quit) - p.wg.Wait() +// stop останавливает пул воркеров. +// Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач. +// Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта. +func (p *workerPool) stop() { + close(p.quit) // сигнал для всех воркеров — выйти из цикла + p.wg.Wait() // ждём, пока все воркеры завершатся } -// Submit отправляет задачу в очередь и возвращает канал для результата -func (p *WorkerPool) Submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan RequestResult, error) { +// submit отправляет задачу в очередь и возвращает канал, через который будет получен результат. +// Если очередь переполнена — возвращает ErrPoolQueueFull. +// Канал результата имеет буфер 1, чтобы не блокировать воркера при записи. +// Контекст используется для отмены задачи, если клиент отменил запрос до отправки. +func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) { + // Проверяем, не превышена ли очередь if len(p.taskCh) >= p.queueSize { return nil, ErrPoolQueueFull } - resultCh := make(chan RequestResult, 1) // буфер 1, чтобы не блокировать воркера - envelope := RequestEnvelope{do, resultCh} + // Создаём канал для результата — буферизованный, чтобы не блокировать воркера + resultCh := make(chan requestResult, 1) + + // Создаём обёртку задачи + envelope := requestEnvelope{ + doFunc: do, + resultCh: resultCh, + } + + // Пытаемся отправить задачу в очередь select { case <-ctx.Done(): + // Клиент отменил операцию до отправки — возвращаем ошибку отмены return nil, ctx.Err() case p.taskCh <- envelope: + // Успешно отправлено — возвращаем канал для чтения результата return resultCh, nil default: + // Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай return nil, ErrPoolQueueFull } } -// worker выполняет задачи -func (p *WorkerPool) worker(ctx context.Context) { - defer p.wg.Done() +// worker — приватная горутина, выполняющая задачи из очереди. +// Каждый воркер работает в бесконечном цикле, пока не получит сигнал остановки. +// При получении задачи: +// - вызывает doFunc с контекстом +// - записывает результат в resultCh +// - закрывает канал, чтобы клиент мог прочитать и завершить +// +// После закрытия quit-канала — воркер завершает работу. +func (p *workerPool) worker(ctx context.Context) { + defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины + for { select { case <-p.quit: + // Получен сигнал остановки — выходим из цикла return + case envelope := <-p.taskCh: - // Выполняем задачу с переданным контекстом (или можно использовать свой) - val, err := envelope.DoFunc(ctx) - envelope.ResultCh <- RequestResult{Value: val, Err: err} - close(envelope.ResultCh) + // Выполняем задачу с переданным контекстом (клиентский или общий) + value, err := envelope.doFunc(ctx) + + // Записываем результат в канал — не блокируем, т.к. буфер 1 + envelope.resultCh <- requestResult{ + value: value, + err: err, + } + // Закрываем канал — клиент знает, что результат пришёл и больше не будет + close(envelope.resultCh) } } } diff --git a/tgapi/stickers_methods.go b/tgapi/stickers_methods.go index abf6390..152bf56 100644 --- a/tgapi/stickers_methods.go +++ b/tgapi/stickers_methods.go @@ -2,7 +2,7 @@ package tgapi type SendStickerP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -15,7 +15,7 @@ type SendStickerP struct { } func (api *API) SendSticker(params SendStickerP) (Message, error) { - req := NewRequest[Message]("sendSticker", params) + req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID) return req.Do(api) } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 0b4d2a5..e7184ff 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -8,6 +8,7 @@ import ( "mime/multipart" "net/http" "path/filepath" + "time" "git.nix13.pw/scuroneko/laniakea/utils" "git.nix13.pw/scuroneko/slog" @@ -59,24 +60,17 @@ type UploaderRequest[R, P any] struct { method string files []UploaderFile params P + chatId int64 } func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { - return UploaderRequest[R, P]{method, files, params} + return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0} +} +func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] { + return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId} } func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { var zero R - if up.api.Limiter != nil { - if up.api.dropOverflowLimit { - if !up.api.Limiter.GlobalAllow() { - return zero, errors.New("rate limited") - } - } else { - if err := up.api.Limiter.GlobalWait(ctx); err != nil { - return zero, err - } - } - } buf, contentType, err := prepareMultipart(r.files, r.params) if err != nil { @@ -95,25 +89,58 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) + req.Header.Set("Accept-Encoding", "gzip") + req.ContentLength = int64(buf.Len()) - up.logger.Debugln("UPLOADER REQ", r.method) - res, err := up.api.client.Do(req) - if err != nil { - return zero, err - } - defer res.Body.Close() + for { + if up.api.Limiter != nil { + if up.api.dropOverflowLimit { + if !up.api.Limiter.GlobalAllow() { + return zero, errors.New("rate limited") + } + } else { + if err := up.api.Limiter.GlobalWait(ctx); err != nil { + return zero, err + } + } + } - body, err := readBody(res.Body) - up.logger.Debugln("UPLOADER RES", r.method, string(body)) - if res.StatusCode != http.StatusOK { - return zero, fmt.Errorf("unexpected status code: %d, %s", res.StatusCode, string(body)) - } + up.logger.Debugln("UPLOADER REQ", r.method) + resp, err := up.api.client.Do(req) + if err != nil { + return zero, err + } - respBody, err := parseBody[R](body) - if err != nil { - return zero, err + body, err := readBody(resp.Body) + _ = resp.Body.Close() + up.logger.Debugln("UPLOADER RES", r.method, string(body)) + + response, err := parseBody[R](body) + if err != nil { + return zero, err + } + + if !response.Ok { + if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { + after := *response.Parameters.RetryAfter + up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatId) + if r.chatId > 0 { + up.api.Limiter.SetChatLock(r.chatId, after) + } else { + up.api.Limiter.SetGlobalLock(after) + } + + select { + case <-ctx.Done(): + return zero, ctx.Err() + case <-time.After(time.Duration(after) * time.Second): + continue // Повторяем запрос + } + } + return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) + } + return response.Result, nil } - return respBody.Result, nil } func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { var zero R @@ -149,24 +176,29 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str for _, file := range files { fw, err := w.CreateFormFile(string(file.field), file.filename) if err != nil { - _ = w.Close() - return buf, w.FormDataContentType(), err + _ = w.Close() // Закрываем, чтобы не было утечки + return nil, "", err } _, err = fw.Write(file.data) if err != nil { _ = w.Close() - return buf, w.FormDataContentType(), err + return nil, "", err } } - err := utils.Encode(w, params) + err := utils.Encode(w, params) // Предполагается, что это записывает в w if err != nil { _ = w.Close() - return buf, w.FormDataContentType(), err + return nil, "", err } - err = w.Close() - return buf, w.FormDataContentType(), err + + err = w.Close() // ✅ ОБЯЗАТЕЛЬНО вызвать в конце — иначе запрос битый! + if err != nil { + return nil, "", err + } + + return buf, w.FormDataContentType(), nil } func uploaderTypeByExt(filename string) UploaderFileType { diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index 8a9520e..30931fd 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -23,13 +23,13 @@ type UploadPhotoP struct { } func (u *Uploader) UploadPhoto(params UploadPhotoP, file UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendPhoto", params, file) + req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file) return req.Do(u) } type UploadAudioP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -52,13 +52,13 @@ type UploadAudioP struct { } func (u *Uploader) UploadAudio(params UploadAudioP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendAudio", params, files...) + req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...) return req.Do(u) } type UploadDocumentP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -84,7 +84,7 @@ func (u *Uploader) UploadDocument(params UploadDocumentP, files ...UploaderFile) type UploadVideoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -117,7 +117,7 @@ func (u *Uploader) UploadVideo(params UploadVideoP, files ...UploaderFile) (Mess type UploadAnimationP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -148,7 +148,7 @@ func (u *Uploader) UploadAnimation(params UploadAnimationP, files ...UploaderFil type UploadVoiceP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -174,7 +174,7 @@ func (u *Uploader) UploadVoice(params UploadVoiceP, files ...UploaderFile) (Mess type UploadVideoNoteP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -197,7 +197,7 @@ func (u *Uploader) UploadVideoNote(params UploadVideoNoteP, files ...UploaderFil } type UploadChatPhotoP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } func (u *Uploader) UploadChatPhoto(params UploadChatPhotoP, photo UploaderFile) (Message, error) { diff --git a/utils/limiter.go b/utils/limiter.go index 50cf27a..65ce2c6 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -2,30 +2,42 @@ package utils import ( "context" + "errors" "sync" "time" "golang.org/x/time/rate" ) -type RateLimiter struct { - globalLockUntil time.Time - globalLimiter *rate.Limiter - globalMu sync.RWMutex +var ErrDropOverflow = errors.New("drop overflow limit") - chatLocks map[int64]time.Time - chatLimiters map[int64]*rate.Limiter - chatMu sync.Mutex +// RateLimiter implements per-chat and global rate limiting with optional blocking. +// It supports two modes: +// - "drop" mode: immediately reject if limits are exceeded. +// - "wait" mode: block until capacity is available. +type RateLimiter struct { + globalLockUntil time.Time // global cooldown timestamp (set by API errors) + globalLimiter *rate.Limiter // global token bucket (30 req/sec) + globalMu sync.RWMutex // protects globalLockUntil and globalLimiter + + chatLocks map[int64]time.Time // per-chat cooldown timestamps + chatLimiters map[int64]*rate.Limiter // per-chat token buckets (1 req/sec) + chatMu sync.Mutex // protects chatLocks and chatLimiters } +// NewRateLimiter creates a new RateLimiter with default limits. +// Global: 30 requests per second, burst 30. +// Per-chat: 1 request per second, burst 1. func NewRateLimiter() *RateLimiter { return &RateLimiter{ globalLimiter: rate.NewLimiter(30, 30), chatLimiters: make(map[int64]*rate.Limiter), - chatLocks: make(map[int64]time.Time), // инициализация! + chatLocks: make(map[int64]time.Time), } } +// SetGlobalLock sets a global cooldown period (e.g., after receiving 429 from Telegram). +// If retryAfter <= 0, no lock is applied. func (rl *RateLimiter) SetGlobalLock(retryAfter int) { if retryAfter <= 0 { return @@ -35,6 +47,8 @@ func (rl *RateLimiter) SetGlobalLock(retryAfter int) { rl.globalLockUntil = time.Now().Add(time.Duration(retryAfter) * time.Second) } +// SetChatLock sets a cooldown for a specific chat (e.g., after 429 for that chat). +// If retryAfter <= 0, no lock is applied. func (rl *RateLimiter) SetChatLock(chatID int64, retryAfter int) { if retryAfter <= 0 { return @@ -44,32 +58,31 @@ func (rl *RateLimiter) SetChatLock(chatID int64, retryAfter int) { rl.chatLocks[chatID] = time.Now().Add(time.Duration(retryAfter) * time.Second) } -// GlobalWait блокирует до возможности сделать глобальный запрос. +// GlobalWait blocks until a global request can be made. +// Waits for both global cooldown and token bucket availability. func (rl *RateLimiter) GlobalWait(ctx context.Context) error { - // Ждём окончания глобальной блокировки, если она есть if err := rl.waitForGlobalUnlock(ctx); err != nil { return err } - // Ждём разрешения rate limiter'а return rl.globalLimiter.Wait(ctx) } -// Wait блокирует до возможности сделать запрос для конкретного чата. +// Wait blocks until a request for the given chat can be made. +// Waits for: chat cooldown → global cooldown → chat token bucket. +// Note: Global limit is checked *before* chat limit to avoid overloading upstream. func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { - // Ждём окончания блокировки чата if err := rl.waitForChatUnlock(ctx, chatID); err != nil { return err } - // Затем глобальной блокировки if err := rl.waitForGlobalUnlock(ctx); err != nil { return err } - // Получаем или создаём лимитер для чата limiter := rl.getChatLimiter(chatID) return limiter.Wait(ctx) } -// GlobalAllow неблокирующая проверка глобального запроса. +// GlobalAllow checks if a global request can be made without blocking. +// Returns false if either global cooldown is active or token bucket is exhausted. func (rl *RateLimiter) GlobalAllow() bool { rl.globalMu.RLock() until := rl.globalLockUntil @@ -81,9 +94,11 @@ func (rl *RateLimiter) GlobalAllow() bool { return rl.globalLimiter.Allow() } -// Allow неблокирующая проверка запроса для чата. +// Allow checks if a request for the given chat can be made without blocking. +// Returns false if: global cooldown, chat cooldown, global limiter, or chat limiter denies. +// Note: Global limiter is checked before chat limiter — upstream limits take priority. func (rl *RateLimiter) Allow(chatID int64) bool { - // Проверяем глобальную блокировку + // Check global cooldown rl.globalMu.RLock() globalUntil := rl.globalLockUntil rl.globalMu.RUnlock() @@ -91,7 +106,7 @@ func (rl *RateLimiter) Allow(chatID int64) bool { return false } - // Проверяем блокировку чата + // Check chat cooldown rl.chatMu.Lock() chatUntil, ok := rl.chatLocks[chatID] rl.chatMu.Unlock() @@ -99,18 +114,47 @@ func (rl *RateLimiter) Allow(chatID int64) bool { return false } - // Проверяем глобальный лимитер + // Check global token bucket if !rl.globalLimiter.Allow() { return false } - // Проверяем лимитер чата + // Check chat token bucket limiter := rl.getChatLimiter(chatID) return limiter.Allow() } -// Внутренние вспомогательные методы +// Check applies rate limiting based on configuration. +// If dropOverflow is true: +// - Immediately returns ErrDropOverflow if either global or chat limit is exceeded. +// +// Else: +// - If chatID != 0: waits for chat-specific capacity (including global limit). +// - If chatID == 0: waits for global capacity only. +// +// chatID == 0 means no specific chat context (e.g., inline query, webhook without chat). +func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int64) error { + if dropOverflow { + if chatID != 0 && !rl.Allow(chatID) { + return ErrDropOverflow + } + if !rl.GlobalAllow() { + return ErrDropOverflow + } + } else if chatID != 0 { + if err := rl.Wait(ctx, chatID); err != nil { + return err + } + } else { + if err := rl.GlobalWait(ctx); err != nil { + return err + } + } + return nil +} +// waitForGlobalUnlock blocks until global cooldown expires or context is done. +// Does not check token bucket — only cooldown. func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { rl.globalMu.RLock() until := rl.globalLockUntil @@ -119,6 +163,7 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { if until.IsZero() || time.Now().After(until) { return nil } + select { case <-time.After(time.Until(until)): return nil @@ -127,6 +172,8 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { } } +// waitForChatUnlock blocks until the specified chat's cooldown expires or context is done. +// Does not check token bucket — only cooldown. func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { rl.chatMu.Lock() until, ok := rl.chatLocks[chatID] @@ -135,6 +182,7 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro if !ok || until.IsZero() || time.Now().After(until) { return nil } + select { case <-time.After(time.Until(until)): return nil @@ -143,13 +191,14 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro } } +// getChatLimiter returns the rate limiter for the given chat, creating it if needed. +// Uses 1 request per second with burst of 1 — conservative for per-user limits. +// Must be called with rl.chatMu held. func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { - rl.chatMu.Lock() - defer rl.chatMu.Unlock() if lim, ok := rl.chatLimiters[chatID]; ok { return lim } - lim := rate.NewLimiter(1, 1) // 1 запрос/сек + lim := rate.NewLimiter(1, 1) rl.chatLimiters[chatID] = lim return lim } diff --git a/utils/multipart.go b/utils/multipart.go index 89d2fc7..0dfdac0 100644 --- a/utils/multipart.go +++ b/utils/multipart.go @@ -1,7 +1,6 @@ package utils import ( - "encoding/json" "fmt" "io" "mime/multipart" @@ -26,33 +25,31 @@ func Encode[T any](w *multipart.Writer, req T) error { field := v.Field(i) fieldType := t.Field(i) - formTags := strings.Split(fieldType.Tag.Get("json"), ",") - fieldName := "" - if len(formTags) == 0 { - formTags = strings.Split(fieldType.Tag.Get("json"), ",") + jsonTag := fieldType.Tag.Get("json") + if jsonTag == "" { + jsonTag = fieldType.Name } - if len(formTags) > 0 { - fieldName = formTags[0] - if fieldName == "-" { - continue - } - if slices.Index(formTags, "omitempty") >= 0 { - if field.IsZero() { - continue - } - } - } else { - fieldName = strings.ToLower(fieldType.Name) + parts := strings.Split(jsonTag, ",") + fieldName := parts[0] + if fieldName == "-" { + continue + } + + // Handle omitempty + isEmpty := field.IsZero() + if slices.Contains(parts, "omitempty") && isEmpty { + continue } var ( fw io.Writer err error ) + switch field.Kind() { case reflect.String: - if field.String() != "" { + if !isEmpty { fw, err = w.CreateFormField(fieldName) if err == nil { _, err = fw.Write([]byte(field.String())) @@ -80,45 +77,47 @@ func Encode[T any](w *multipart.Writer, req T) error { } case reflect.Slice: if field.Type().Elem().Kind() == reflect.Uint8 && !field.IsNil() { + // Handle []byte as file upload (e.g., thumbnail) filename := fieldType.Tag.Get("filename") if filename == "" { filename = fieldName } - - ext := "" - filename = filename + ext - fw, err = w.CreateFormFile(fieldName, filename) if err == nil { _, err = fw.Write(field.Bytes()) } } else if !field.IsNil() { - // Handle slice of primitive values (as multiple form fields with the same name) + // Handle []string, []int, etc. — send as multiple fields with same name for j := 0; j < field.Len(); j++ { elem := field.Index(j) fw, err = w.CreateFormField(fieldName) - if err == nil { - switch elem.Kind() { - case reflect.String: - _, err = fw.Write([]byte(elem.String())) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - _, err = fw.Write([]byte(strconv.FormatInt(elem.Int(), 10))) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - _, err = fw.Write([]byte(strconv.FormatUint(elem.Uint(), 10))) - } + if err != nil { + break + } + switch elem.Kind() { + case reflect.String: + _, err = fw.Write([]byte(elem.String())) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + _, err = fw.Write([]byte(strconv.FormatInt(elem.Int(), 10))) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + _, err = fw.Write([]byte(strconv.FormatUint(elem.Uint(), 10))) + case reflect.Bool: + _, err = fw.Write([]byte(strconv.FormatBool(elem.Bool()))) + case reflect.Float32, reflect.Float64: + _, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 64))) + } + if err != nil { + break } } } case reflect.Struct: - var jsonData []byte - jsonData, err = json.Marshal(field.Interface()) - if err == nil { - fw, err = w.CreateFormField(fieldName) - if err == nil { - _, err = fw.Write(jsonData) - } - } + // Don't serialize structs as JSON — flatten them! + // Telegram doesn't support nested JSON in form-data. + // If you need nested data, use separate fields (e.g., ParseMode, CaptionEntities) + // This is a design choice — you should avoid nested structs in params. + return fmt.Errorf("nested structs are not supported in params — use flat fields") } if err != nil { diff --git a/utils/utils.go b/utils/utils.go index 9d0cbac..2727c05 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,7 +1,6 @@ package utils import ( - "fmt" "os" "strings" @@ -16,6 +15,7 @@ func GetLoggerLevel() slog.LogLevel { return level } +// EscapeMarkdown Deprecated. Use MarkdownV2 func EscapeMarkdown(s string) string { s = strings.ReplaceAll(s, "_", `\_`) s = strings.ReplaceAll(s, "*", `\*`) @@ -23,10 +23,20 @@ func EscapeMarkdown(s string) string { return strings.ReplaceAll(s, "`", "\\`") } +// EscapeHTML escapes special characters for Telegram HTML parse mode. +func EscapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2. +// https://core.telegram.org/bots/api#markdownv2-style func EscapeMarkdownV2(s string) string { - symbols := []string{"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"} + symbols := []string{"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!", "\\"} for _, symbol := range symbols { - s = strings.ReplaceAll(s, symbol, fmt.Sprintf("\\%s", symbol)) + s = strings.ReplaceAll(s, symbol, "\\"+symbol) } return s } diff --git a/utils/version.go b/utils/version.go index ab7dbf0..5ad969a 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.8" + VersionString = "1.0.0-beta.9" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 8 + Beta = 9 ) -- 2.54.0 From f42d47af532cb87affd15d2f62594edd9fa71e18 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 6 Mar 2026 12:12:16 +0300 Subject: [PATCH 26/99] v1.0.0 beta 10 --- Makefile | 15 +++++++++++++++ cmd_generator.go | 2 +- plugins.go | 2 +- tgapi/chat_methods.go | 4 +++- tgapi/methods.go | 4 +++- tgapi/types.go | 25 +++++++++++-------------- tgapi/uploader_api.go | 11 +++++++---- utils/version.go | 4 ++-- 8 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef3b6eb --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +# Проверка наличия golangci-lint +GO_LINT := $(shell command -v golangci-lint 2>/dev/null) + +# Цель: запуск всех проверок кода +check: + @echo "🔍 Running code checks..." + @go mod tidy -v + @go vet ./... + @if [ -n "$(GO_LINT)" ]; then \ + echo "✅ golangci-lint found, running..." && \ + golangci-lint run --timeout=5m --verbose; \ + else \ + echo "⚠️ golangci-lint not installed. Install with: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.57.2"; \ + fi + @go test -race -v ./... 2>/dev/null || echo "⚠️ Tests skipped or failed (run manually with 'go test -race ./...')" diff --git a/cmd_generator.go b/cmd_generator.go index 45da59d..8723e51 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -16,7 +16,7 @@ func generateBotCommand[T any](cmd Command[T]) tgapi.BotCommand { var descArgs []string for _, a := range cmd.args { if a.required { - descArgs = append(descArgs, fmt.Sprintf("%s", a.text)) + descArgs = append(descArgs, a.text) } else { descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text)) } diff --git a/plugins.go b/plugins.go index a30514b..daf6f7f 100644 --- a/plugins.go +++ b/plugins.go @@ -15,7 +15,7 @@ const ( ) var ( - CommandRegexInt = regexp.MustCompile("\\d+") + CommandRegexInt = regexp.MustCompile(`\d+`) CommandRegexString = regexp.MustCompile(".+") ) diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index ad1fc33..a95f2c6 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -209,7 +209,9 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, er func (api *API) SetChatPhoto() { uploader := NewUploader(api) - defer uploader.Close() + defer func() { + _ = uploader.Close() + }() } type DeleteChatPhotoP struct { diff --git a/tgapi/methods.go b/tgapi/methods.go index 95956d8..3be5f8e 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -57,6 +57,8 @@ func (api *API) GetFileByLink(link string) ([]byte, error) { if err != nil { return nil, err } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() return io.ReadAll(res.Body) } diff --git a/tgapi/types.go b/tgapi/types.go index 6143a63..4fcf7fb 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -261,20 +261,20 @@ type Gifts struct { Gifts []Gift `json:"gifts"` } +type OwnedGiftType string + const ( OwnedGiftRegularType OwnedGiftType = "regular" OwnedGiftUniqueType OwnedGiftType = "unique" ) -type OwnedGiftType string -type BaseOwnedGift struct { +type OwnedGift struct { Type OwnedGiftType `json:"type"` OwnerGiftID *string `json:"owner_gift_id,omitempty"` SendDate *int `json:"send_date,omitempty"` IsSaved *bool `json:"is_saved,omitempty"` -} -type OwnedGiftRegular struct { - BaseOwnedGift + + // Поля, характерные для "regular" Gift Gift `json:"gift"` SenderUser User `json:"sender_user,omitempty"` Text string `json:"text,omitempty"` @@ -286,18 +286,15 @@ type OwnedGiftRegular struct { PrepaidUpgradeStarCount *int `json:"prepaid_upgrade_star_count,omitempty"` IsUpgradeSeparate *bool `json:"is_upgrade_separate,omitempty"` UniqueGiftNumber *int `json:"unique_gift_number,omitempty"` -} -type OwnedGiftUnique struct { - BaseOwnedGift + + // Поля, характерные для "unique" CanBeTransferred *bool `json:"can_be_transferred,omitempty"` TransferStarCount *int `json:"transfer_star_count,omitempty"` NextTransferDate *int `json:"next_transfer_date,omitempty"` } + type OwnedGifts struct { - TotalCount int `json:"total_count"` - Gifts []struct { - OwnedGiftRegular - OwnedGiftUnique - } `json:"gifts"` - NextOffset string `json:"next_offset"` + TotalCount int `json:"total_count"` + Gifts []OwnedGift `json:"gifts"` + NextOffset string `json:"next_offset"` } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index e7184ff..3d0a60f 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -113,6 +113,9 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, body, err := readBody(resp.Body) _ = resp.Body.Close() + if err != nil { + return zero, err + } up.logger.Debugln("UPLOADER RES", r.method, string(body)) response, err := parseBody[R](body) @@ -145,7 +148,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { var zero R - result, err := up.api.pool.Submit(ctx, func(ctx context.Context) (any, error) { + result, err := up.api.pool.submit(ctx, func(ctx context.Context) (any, error) { return r.doRequest(ctx, up) }) if err != nil { @@ -156,10 +159,10 @@ func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) case <-ctx.Done(): return zero, ctx.Err() case res := <-result: - if res.Err != nil { - return zero, res.Err + if res.err != nil { + return zero, res.err } - if val, ok := res.Value.(R); ok { + if val, ok := res.value.(R); ok { return val, nil } return zero, ErrPoolUnexpected diff --git a/utils/version.go b/utils/version.go index 5ad969a..da80125 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.9" + VersionString = "1.0.0-beta.10" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 9 + Beta = 10 ) -- 2.54.0 From 4dc172a3b5397af2bd8744d1a2c67e364d78d676 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 6 Mar 2026 12:46:30 +0300 Subject: [PATCH 27/99] v1.0.0 beta 11 --- drafts.go | 21 +++++++++++++++++---- msg_context.go | 49 ++++++++++++++++++++++++++++++++++++++---------- utils/version.go | 4 ++-- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/drafts.go b/drafts.go index 5fa2384..cb3492d 100644 --- a/drafts.go +++ b/drafts.go @@ -5,6 +5,7 @@ import ( "sync/atomic" "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/laniakea/utils" ) type draftIdGenerator interface { @@ -55,7 +56,8 @@ type Draft struct { func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { return &DraftProvider{ api: api, generator: &RandomDraftIdGenerator{}, - drafts: make(map[uint64]*Draft), + parseMode: tgapi.ParseMDV2, + drafts: make(map[uint64]*Draft), } } func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { @@ -84,9 +86,12 @@ func (d *DraftProvider) NewDraft() *Draft { d.drafts[id] = draft return draft } - -func (d *Draft) Push(newText string) error { - d.Message += newText +func (d *Draft) push(text string, escapeMd bool) error { + if escapeMd { + text += utils.EscapeMarkdownV2(text) + } else { + text += text + } params := tgapi.SendMessageDraftP{ ChatID: d.chatID, DraftID: d.ID, @@ -100,6 +105,14 @@ func (d *Draft) Push(newText string) error { _, err := d.api.SendMessageDraft(params) return err } + +func (d *Draft) Push(text string) error { + return d.push(text, true) +} +func (d *Draft) PushMarkdown(text string) error { + return d.push(text, false) +} + func (d *Draft) Clear() { d.Message = "" } diff --git a/msg_context.go b/msg_context.go index 5ad0f40..229c2a8 100644 --- a/msg_context.go +++ b/msg_context.go @@ -99,12 +99,15 @@ func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *An return m.ctx.editPhotoText(m.MessageID, text, kb) } -func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, escapeMd bool) *AnswerMessage { params := tgapi.SendMessageP{ ChatID: ctx.Msg.Chat.ID, Text: text, ParseMode: tgapi.ParseMDV2, } + if escapeMd { + params.Text = utils.EscapeMarkdownV2(text) + } if keyboard != nil { params.ReplyMarkup = keyboard.Get() } @@ -129,23 +132,35 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard) *AnswerMess MessageID: msg.MessageID, ctx: ctx, IsMedia: false, Text: text, } } +func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage { + return ctx.answer(text, nil, false) +} func (ctx *MsgContext) Answer(text string) *AnswerMessage { - return ctx.answer(text, nil) + return ctx.answer(text, nil, true) +} +func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { + return ctx.answer(fmt.Sprintf(template, args...), nil, false) } func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage { - return ctx.answer(fmt.Sprintf(template, args...), nil) + return ctx.answer(fmt.Sprintf(template, args...), nil, true) +} +func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { + return ctx.answer(text, keyboard, false) } func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage { - return ctx.answer(text, kb) + return ctx.answer(text, kb, true) } -func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard) *AnswerMessage { +func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, escapeMd bool) *AnswerMessage { params := tgapi.SendPhotoP{ ChatID: ctx.Msg.Chat.ID, Caption: text, - ParseMode: tgapi.ParseMD, + ParseMode: tgapi.ParseMDV2, Photo: photoId, } + if escapeMd { + params.Caption = utils.EscapeMarkdownV2(text) + } if kb != nil { params.ReplyMarkup = kb.Get() } @@ -165,10 +180,24 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard) *An } } func (ctx *MsgContext) AnswerPhoto(photoId, text string) *AnswerMessage { - return ctx.answerPhoto(photoId, text, nil) + return ctx.answerPhoto(photoId, text, nil, true) } +func (ctx *MsgContext) AnswerPhotoMarkdown(photoId, text string) *AnswerMessage { + return ctx.answerPhoto(photoId, text, nil, false) +} + func (ctx *MsgContext) AnswerPhotoKeyboard(photoId, text string, kb *InlineKeyboard) *AnswerMessage { - return ctx.answerPhoto(photoId, text, kb) + return ctx.answerPhoto(photoId, text, kb, true) +} +func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoId, text string, kb *InlineKeyboard) *AnswerMessage { + return ctx.answerPhoto(photoId, text, kb, false) +} + +func (ctx *MsgContext) AnswerPhotof(photoId, template string, args ...any) *AnswerMessage { + return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, true) +} +func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...any) *AnswerMessage { + return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, false) } func (ctx *MsgContext) delete(messageId int) { @@ -214,12 +243,12 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { } func (ctx *MsgContext) error(err error) { - text := fmt.Sprintf(ctx.errorTemplate, utils.EscapeMarkdown(err.Error())) + text := fmt.Sprintf(ctx.errorTemplate, err.Error()) if ctx.CallbackQueryId != "" { ctx.answerCallbackQuery("", text, false) } else { - ctx.answer(text, nil) + ctx.answer(text, nil, true) } ctx.botLogger.Errorln(err) } diff --git a/utils/version.go b/utils/version.go index da80125..50ea178 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.10" + VersionString = "1.0.0-beta.11" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 10 + Beta = 11 ) -- 2.54.0 From 8b9a974da96e774ffd062e48b0c75e48ced2f8e5 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 6 Mar 2026 14:31:45 +0300 Subject: [PATCH 28/99] some fixes and small changes --- drafts.go | 3 +-- msg_context.go | 27 ++++++++++++++++++++------- utils.go | 42 +++++++++++++++++++++++++++++++++++++++--- utils/utils.go | 27 --------------------------- 4 files changed, 60 insertions(+), 39 deletions(-) diff --git a/drafts.go b/drafts.go index cb3492d..64f5712 100644 --- a/drafts.go +++ b/drafts.go @@ -5,7 +5,6 @@ import ( "sync/atomic" "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/laniakea/utils" ) type draftIdGenerator interface { @@ -88,7 +87,7 @@ func (d *DraftProvider) NewDraft() *Draft { } func (d *Draft) push(text string, escapeMd bool) error { if escapeMd { - text += utils.EscapeMarkdownV2(text) + text += EscapeMarkdownV2(text) } else { text += text } diff --git a/msg_context.go b/msg_context.go index 229c2a8..2d2b3ac 100644 --- a/msg_context.go +++ b/msg_context.go @@ -5,7 +5,6 @@ import ( "fmt" "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/laniakea/utils" "git.nix13.pw/scuroneko/slog" ) @@ -35,12 +34,15 @@ type AnswerMessage struct { ctx *MsgContext } -func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, escapeMd bool) *AnswerMessage { params := tgapi.EditMessageTextP{ MessageID: messageId, ChatID: ctx.Msg.Chat.ID, Text: text, - ParseMode: tgapi.ParseMD, + ParseMode: tgapi.ParseMDV2, + } + if escapeMd { + params.Text = EscapeMarkdownV2(text) } if keyboard != nil { params.ReplyMarkup = keyboard.Get() @@ -55,7 +57,10 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard } } func (m *AnswerMessage) Edit(text string) *AnswerMessage { - return m.ctx.edit(m.MessageID, text, nil) + return m.ctx.edit(m.MessageID, text, nil, true) +} +func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { + return m.ctx.edit(m.MessageID, text, nil, false) } func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage { if ctx.CallbackMsgId == 0 { @@ -63,7 +68,15 @@ func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *Answ return nil } - return ctx.edit(ctx.CallbackMsgId, text, keyboard) + return ctx.edit(ctx.CallbackMsgId, text, keyboard, true) +} +func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { + if ctx.CallbackMsgId == 0 { + ctx.botLogger.Errorln("Can't edit non-callback update message") + return nil + } + + return ctx.edit(ctx.CallbackMsgId, text, keyboard, false) } func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { return ctx.EditCallback(fmt.Sprintf(format, args...), keyboard) @@ -106,7 +119,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, escapeMd bo ParseMode: tgapi.ParseMDV2, } if escapeMd { - params.Text = utils.EscapeMarkdownV2(text) + params.Text = EscapeMarkdownV2(text) } if keyboard != nil { params.ReplyMarkup = keyboard.Get() @@ -159,7 +172,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, esc Photo: photoId, } if escapeMd { - params.Caption = utils.EscapeMarkdownV2(text) + params.Caption = EscapeMarkdownV2(text) } if kb != nil { params.ReplyMarkup = kb.Get() diff --git a/utils.go b/utils.go index 0afbcfc..ed48cf6 100644 --- a/utils.go +++ b/utils.go @@ -1,6 +1,10 @@ package laniakea -import "git.nix13.pw/scuroneko/laniakea/utils" +import ( + "strings" + + "git.nix13.pw/scuroneko/laniakea/utils" +) func Ptr[T any](v T) *T { return &v } func Val[T any](p *T, def T) T { @@ -9,7 +13,39 @@ func Val[T any](p *T, def T) T { } return def } -func EscapeMarkdown(s string) string { return utils.EscapeMarkdown(s) } -func EscapeMarkdownV2(s string) string { return utils.EscapeMarkdownV2(s) } + +// EscapeMarkdown +// Deprecated. Use MarkdownV2 +func EscapeMarkdown(s string) string { + s = strings.ReplaceAll(s, "_", `\_`) + s = strings.ReplaceAll(s, "*", `\*`) + s = strings.ReplaceAll(s, "[", `\[`) + return strings.ReplaceAll(s, "`", "\\`") +} + +// EscapeHTML escapes special characters for Telegram HTML parse mode. +func EscapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2. +// https://core.telegram.org/bots/api#markdownv2-style +func EscapeMarkdownV2(s string) string { + symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"} + for _, symbol := range symbols { + s = strings.ReplaceAll(s, symbol, "\\"+symbol) + } + return s +} +func EscapePunctuation(s string) string { + symbols := []string{".", "!", "-"} + for _, symbol := range symbols { + s = strings.ReplaceAll(s, symbol, "\\"+symbol) + } + return s +} const VersionString = utils.VersionString diff --git a/utils/utils.go b/utils/utils.go index 2727c05..51ce3c6 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -2,7 +2,6 @@ package utils import ( "os" - "strings" "git.nix13.pw/scuroneko/slog" ) @@ -14,29 +13,3 @@ func GetLoggerLevel() slog.LogLevel { } return level } - -// EscapeMarkdown Deprecated. Use MarkdownV2 -func EscapeMarkdown(s string) string { - s = strings.ReplaceAll(s, "_", `\_`) - s = strings.ReplaceAll(s, "*", `\*`) - s = strings.ReplaceAll(s, "[", `\[`) - return strings.ReplaceAll(s, "`", "\\`") -} - -// EscapeHTML escapes special characters for Telegram HTML parse mode. -func EscapeHTML(s string) string { - s = strings.ReplaceAll(s, "&", "&") - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - return s -} - -// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2. -// https://core.telegram.org/bots/api#markdownv2-style -func EscapeMarkdownV2(s string) string { - symbols := []string{"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!", "\\"} - for _, symbol := range symbols { - s = strings.ReplaceAll(s, symbol, "\\"+symbol) - } - return s -} -- 2.54.0 From 7e12e0a9f99703328c6e6c20d54c72347d2b596b Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 6 Mar 2026 14:32:50 +0300 Subject: [PATCH 29/99] some fixes and small changes --- drafts.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drafts.go b/drafts.go index 64f5712..2362bdb 100644 --- a/drafts.go +++ b/drafts.go @@ -87,9 +87,9 @@ func (d *DraftProvider) NewDraft() *Draft { } func (d *Draft) push(text string, escapeMd bool) error { if escapeMd { - text += EscapeMarkdownV2(text) + d.Message += EscapeMarkdownV2(text) } else { - text += text + d.Message += text } params := tgapi.SendMessageDraftP{ ChatID: d.chatID, -- 2.54.0 From b1b0cbdfbd74645aaeac2af26107022f609a24b2 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 12 Mar 2026 14:02:32 +0300 Subject: [PATCH 30/99] v1.0.0 beta 12 --- bot.go | 372 ++++++++++++++++++++++++++++++++++---- cmd_generator.go | 97 ++++++++-- drafts.go | 289 +++++++++++++++++++++++------ examples/basic/.env | 7 + examples/basic/example.go | 28 +++ examples/basic/go.mod | 16 ++ examples/basic/go.sum | 19 ++ go.mod | 6 +- go.sum | 12 +- keyboard.go | 127 +++++++++++-- l10n.go | 76 +++++++- msg_context.go | 291 ++++++++++++++++++++--------- plugins.go | 228 ++++++++++++++++++----- runners.go | 137 ++++++++++---- tgapi/methods.go | 1 + utils.go | 8 +- utils/version.go | 4 +- 17 files changed, 1410 insertions(+), 308 deletions(-) create mode 100644 examples/basic/.env create mode 100644 examples/basic/example.go create mode 100644 examples/basic/go.mod create mode 100644 examples/basic/go.sum diff --git a/bot.go b/bot.go index f7439de..c791eda 100644 --- a/bot.go +++ b/bot.go @@ -1,3 +1,34 @@ +// Package laniakea provides a modular, extensible framework for building scalable +// Telegram bots with support for plugins, middleware, localization, draft messages, +// rate limiting, structured logging, and dependency injection. +// +// The framework is designed around a fluent API for configuration and separation of concerns: +// +// - Plugins: Handle specific commands or events (e.g., /start, /help) +// - Middleware: Intercept and modify updates before plugins run (auth, logging, validation) +// - Runners: Background goroutines for cleanup, cron jobs, or monitoring +// - DraftProvider: Safely build and resume multi-step messages +// - L10n: Multi-language support via key-based translation +// - RateLimiter: Enforces Telegram API limits to avoid bans +// - Structured Logging: JSON stdout + optional file output with request-level tracing +// - Dependency Injection: Inject custom database contexts (e.g., *gorm.DB, *sql.DB) +// +// Example usage: +// +// bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()). +// DatabaseContext(&myDB). +// AddUpdateType(tgapi.UpdateTypeMessage). +// AddPrefixes("/", "!"). +// AddPlugins(&startPlugin, &helpPlugin). +// AddMiddleware(&authMiddleware, &logMiddleware). +// AddRunner(&cleanupRunner). +// AddL10n(l10n.New()) +// +// go bot.Run() +// <-ctx.Done() // wait for shutdown signal +// +// All methods are thread-safe except direct field access. Use provided accessors +// (e.g., GetDBContext, SetUpdateOffset) for safe concurrent access. package laniakea import ( @@ -8,6 +39,7 @@ import ( "strconv" "strings" "sync" + "time" "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -16,30 +48,81 @@ import ( "github.com/alitto/pond/v2" ) +// BotOpts holds configuration options for initializing a Bot. +// +// Values are loaded from environment variables via LoadOptsFromEnv(). +// Use NewOpts() to create a zero-value struct and set fields manually. type BotOpts struct { - Token string + // Token is the Telegram bot token (required). + Token string + + // UpdateTypes is a semicolon-separated list of update types to listen for. + // Example: "message;edited_message;callback_query" + // Defaults to empty (Telegram will return all types). UpdateTypes []string - Debug bool + // Debug enables debug-level logging. + Debug bool + + // ErrorTemplate is the format string used to wrap error messages sent to users. + // Use "%s" to insert the actual error. Example: "❌ Error: %s" ErrorTemplate string - Prefixes []string - LoggerBasePath string + // Prefixes is a list of command prefixes (e.g., ["/", "!"]). + // Defaults to ["/"] if not set via environment. + Prefixes []string + + // LoggerBasePath is the directory where log files are written. + // Defaults to "./". + LoggerBasePath string + + // UseRequestLogger enables detailed logging of all Telegram API requests. UseRequestLogger bool - WriteToFile bool + // WriteToFile enables writing logs to files (main.log and requests.log). + WriteToFile bool + + // UseTestServer uses Telegram's test server (https://api.test.telegram.org). UseTestServer bool - APIUrl string - RateLimit int + // APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted). + APIUrl string + + // RateLimit is the maximum number of API requests per second. + // Telegram allows up to 30 req/s for most bots. Defaults to 30. + RateLimit int + + // DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing. + // Use this to prioritize responsiveness over reliability. DropRLOverflow bool } +// NewOpts returns a new BotOpts with zero values. func NewOpts() *BotOpts { return new(BotOpts) } + +// LoadOptsFromEnv loads BotOpts from environment variables. +// +// Environment variables: +// - TG_TOKEN: Bot token (required) +// - UPDATE_TYPES: semicolon-separated update types (e.g., "message;callback_query") +// - DEBUG: "true" to enable debug logging +// - ERROR_TEMPLATE: format string for error messages (e.g., "❌ %s") +// - PREFIXES: semicolon-separated prefixes (e.g., "/;!bot") +// - LOGGER_BASE_PATH: directory for log files (default: "./") +// - USE_REQ_LOG: "true" to enable request logging +// - WRITE_TO_FILE: "true" to write logs to files +// - USE_TEST_SERVER: "true" to use Telegram test server +// - API_URL: custom API endpoint +// - RATE_LIMIT: max requests per second (default: 30) +// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow +// +// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined. func LoadOptsFromEnv() *BotOpts { rateLimit := 30 if rl := os.Getenv("RATE_LIMIT"); rl != "" { - rateLimit, _ = strconv.Atoi(rl) + if n, err := strconv.Atoi(rl); err == nil { + rateLimit = n + } } return &BotOpts{ @@ -50,6 +133,7 @@ func LoadOptsFromEnv() *BotOpts { ErrorTemplate: os.Getenv("ERROR_TEMPLATE"), Prefixes: LoadPrefixesFromEnv(), + LoggerBasePath: os.Getenv("LOGGER_BASE_PATH"), UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true", WriteToFile: os.Getenv("WRITE_TO_FILE") == "true", @@ -60,6 +144,9 @@ func LoadOptsFromEnv() *BotOpts { DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", } } + +// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon. +// Defaults to ["/"] if not set. func LoadPrefixesFromEnv() []string { prefixesS, exists := os.LookupEnv("PREFIXES") if !exists { @@ -68,36 +155,75 @@ func LoadPrefixesFromEnv() []string { return strings.Split(prefixesS, ";") } +// DbContext is an interface representing the application's database context. +// It is injected into plugins and middleware via Bot.DatabaseContext(). +// +// Example: +// +// type MyDB struct { ... } +// bot := NewBot[MyDB](opts).DatabaseContext(&myDB) +// +// Use NoDB if no database is needed. type DbContext interface{} + +// NoDB is a placeholder type for bots that do not use a database. +// Use Bot[NoDB] to indicate no dependency injection is required. type NoDB struct{ DbContext } + +// Bot is the core Telegram bot instance. +// +// Manages: +// - API communication via tgapi +// - Update processing pipeline (middleware → plugins) +// - Background runners +// - Logging and rate limiting +// - Localization and draft message support +// +// All methods are safe for concurrent use. Direct field access is not recommended. type Bot[T DbContext] struct { token string debug bool errorTemplate string username string - logger *slog.Logger - RequestLogger *slog.Logger - extraLoggers extypes.Slice[*slog.Logger] + logger *slog.Logger // Main bot logger (JSON stdout + optional file) + RequestLogger *slog.Logger // Optional request-level API logging + extraLoggers extypes.Slice[*slog.Logger] // API, Uploader, and custom loggers - plugins []Plugin[T] - middlewares []Middleware[T] - prefixes []string - runners []Runner[T] + plugins []Plugin[T] // Command/event handlers + middlewares []Middleware[T] // Pre-processing filters (sorted by order) + prefixes []string // Command prefixes (e.g., "/", "!") + runners []Runner[T] // Background tasks (e.g., cleanup, cron) - api *tgapi.API - uploader *tgapi.Uploader - dbContext *T - l10n *L10n - draftProvider *DraftProvider + api *tgapi.API // Telegram API client + uploader *tgapi.Uploader // File uploader + dbContext *T // Injected database context + l10n *L10n // Localization manager + draftProvider *DraftProvider // Draft message builder updateOffsetMu sync.Mutex - updateOffset int - updateTypes []tgapi.UpdateType - updateQueue chan *tgapi.Update + updateOffset int // Last processed update ID + updateTypes []tgapi.UpdateType // Types of updates to fetch + updateQueue chan *tgapi.Update // Internal queue for processing updates } +// NewBot creates and initializes a new Bot instance using the provided BotOpts. +// +// Automatically: +// - Creates API and Uploader clients +// - Initializes structured logging (JSON stdout + optional file) +// - Fetches bot username via GetMe() +// - Sets up DraftProvider with random IDs +// - Adds API and Uploader loggers to extraLoggers +// +// Panics if: +// - Token is empty +// - GetMe() fails (invalid token or network error) func NewBot[T any](opts *BotOpts) *Bot[T] { + if opts.Token == "" { + panic("laniakea: BotOpts.Token is required") + } + updateQueue := make(chan *tgapi.Update, 512) var limiter *utils.RateLimiter @@ -105,7 +231,10 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { limiter = utils.NewRateLimiter() } - apiOpts := tgapi.NewAPIOpts(opts.Token).SetAPIUrl(opts.APIUrl).UseTestServer(opts.UseTestServer).SetLimiter(limiter) + apiOpts := tgapi.NewAPIOpts(opts.Token). + SetAPIUrl(opts.APIUrl). + UseTestServer(opts.UseTestServer). + SetLimiter(limiter) api := tgapi.NewAPI(apiOpts) uploader := tgapi.NewUploader(api) @@ -126,6 +255,8 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { l10n: &L10n{}, draftProvider: NewRandomDraftProvider(api), } + + // Add API and Uploader loggers to extraLoggers for unified output bot.extraLoggers = bot.extraLoggers.Push(api.GetLogger()).Push(uploader.GetLogger()) if len(opts.ErrorTemplate) > 0 { @@ -136,6 +267,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { } bot.initLoggers(opts) + // Fetch bot info to validate token and get username u, err := api.GetMe() if err != nil { _ = bot.Close() @@ -143,12 +275,22 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { } bot.username = Val(u.Username, "") if bot.username == "" { - bot.logger.Warn("Can't get bot username. Named command wouldn't work!") + bot.logger.Warn("Can't get bot username. Named command handlers won't work!") } - bot.logger.Infof("Authorized as %s\n", u.FirstName) + bot.logger.Infof("Authorized as %s (@%s)\n", u.FirstName, u.Username) return bot } + +// Close gracefully shuts down the bot. +// +// Closes: +// - Uploader (waits for pending uploads) +// - API client +// - RequestLogger (if enabled) +// - Main logger +// +// Returns the first error encountered, if any. func (bot *Bot[T]) Close() error { if err := bot.uploader.Close(); err != nil { bot.logger.Errorln(err) @@ -156,14 +298,22 @@ func (bot *Bot[T]) Close() error { if err := bot.api.CloseApi(); err != nil { bot.logger.Errorln(err) } - if err := bot.RequestLogger.Close(); err != nil { - bot.logger.Errorln(err) + if bot.RequestLogger != nil { + if err := bot.RequestLogger.Close(); err != nil { + bot.logger.Errorln(err) + } } if err := bot.logger.Close(); err != nil { return err } return nil } + +// initLoggers configures the main and optional request loggers. +// +// Uses DEBUG flag to set log level (DEBUG if true, FATAL otherwise). +// Writes to stdout in JSON format by default. +// If WriteToFile is true, writes to main.log and requests.log in LoggerBasePath. func (bot *Bot[T]) initLoggers(opts *BotOpts) { level := slog.FATAL if opts.Debug { @@ -195,27 +345,59 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { } } +// GetUpdateOffset returns the current update offset (thread-safe). func (bot *Bot[T]) GetUpdateOffset() int { bot.updateOffsetMu.Lock() defer bot.updateOffsetMu.Unlock() return bot.updateOffset } + +// SetUpdateOffset sets the update offset for next GetUpdates call (thread-safe). func (bot *Bot[T]) SetUpdateOffset(offset int) { bot.updateOffsetMu.Lock() defer bot.updateOffsetMu.Unlock() bot.updateOffset = offset } + +// GetUpdateTypes returns the list of update types the bot is configured to receive. func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return bot.updateTypes } -func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } -func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } -func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } + +// GetLogger returns the main bot logger. +func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } + +// GetDBContext returns the injected database context. +// Returns nil if not set via DatabaseContext(). +func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } + +// L10n translates a key in the given language. +// Returns empty string if translation not found. +func (bot *Bot[T]) L10n(lang, key string) string { + return bot.l10n.Translate(lang, key) +} + +// SetDraftProvider replaces the default DraftProvider with a custom one. +// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts. func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { bot.draftProvider = p return bot } +// DbLogger is a function type that returns a slog.LoggerWriter for database logging. +// Used to inject database-specific log output (e.g., SQL queries, ORM events). type DbLogger[T DbContext] func(db *T) slog.LoggerWriter +// AddDatabaseLoggerWriter adds a database logger writer to all loggers. +// +// The writer will receive logs from: +// - Main bot logger +// - Request logger (if enabled) +// - API and Uploader loggers +// +// Example: +// +// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter { +// return db.QueryLogger() +// }) func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { w := writer(bot.dbContext) bot.logger.AddWriter(w) @@ -228,31 +410,51 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { return bot } +// DatabaseContext injects a database context into the bot. +// This context is accessible to plugins and middleware via GetDBContext(). func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] { bot.dbContext = ctx return bot } + +// UpdateTypes sets the list of update types the bot will request from Telegram. +// Overwrites any previously set types. func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { bot.updateTypes = make([]tgapi.UpdateType, 0) bot.updateTypes = append(bot.updateTypes, t...) return bot } + +// AddUpdateType adds one or more update types to the list. +// Does not overwrite existing types. func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { bot.updateTypes = append(bot.updateTypes, t...) return bot } + +// AddPrefixes adds one or more command prefixes (e.g., "/", "!"). +// Must have at least one prefix before Run(). func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { bot.prefixes = append(bot.prefixes, prefixes...) return bot } + +// ErrorTemplate sets the format string for error messages sent to users. +// Use "%s" to insert the error message. +// Example: "❌ Error: %s" → "❌ Error: Command not found" func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { bot.errorTemplate = s return bot } + +// Debug enables or disables debug logging. func (bot *Bot[T]) Debug(debug bool) *Bot[T] { bot.debug = debug return bot } + +// AddPlugins registers one or more plugins. +// Plugins are executed in registration order unless filtered by middleware. func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { for _, p := range plugin { bot.plugins = append(bot.plugins, *p) @@ -260,33 +462,96 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { } return bot } + +// AddMiddleware registers one or more middleware handlers. +// +// Middleware are executed in order of increasing .order value before plugins. +// If two middleware have the same order, they are sorted lexicographically by name. +// +// Middleware can: +// - Modify or reject updates before they reach plugins +// - Inject context (e.g., user auth state, rate limit status) +// - Log, validate, or transform incoming data +// +// Example: +// +// bot.AddMiddleware(&authMiddleware, &rateLimitMiddleware) +// +// Panics if any middleware has a nil name. func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { - bot.middlewares = append(bot.middlewares, middleware...) for _, m := range middleware { + if m.name == "" { + panic("laniakea: middleware must have a non-empty name") + } + bot.middlewares = append(bot.middlewares, m) bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) } + // Stable sort by order (ascending), then by name (lexicographic) sort.Slice(bot.middlewares, func(i, j int) bool { first := bot.middlewares[i] second := bot.middlewares[j] - if first.order == second.order { - return first.name < second.name + if first.order != second.order { + return first.order < second.order } - return first.order < second.order + return first.name < second.name }) return bot } + +// AddRunner registers a background runner to execute concurrently with the bot. +// +// Runners are goroutines that run independently of update processing. +// Common use cases: +// - Periodic cleanup (e.g., expiring drafts, clearing temp files) +// - Metrics collection or health checks +// - Scheduled tasks (e.g., daily announcements) +// +// Runners are started immediately after Bot.Run() is called. +// +// Example: +// +// bot.AddRunner(&cleanupRunner) +// +// Panics if runner has a nil name. func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { + if runner.name == "" { + panic("laniakea: runner must have a non-empty name") + } bot.runners = append(bot.runners, runner) bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) return bot } + +// AddL10n sets the localization (i18n) provider for the bot. +// +// The L10n instance must be pre-populated with translations. +// Translations are accessed via Bot.L10n(lang, key). +// +// Example: +// +// l10n := l10n.New() +// l10n.Add("en", "hello", "Hello!") +// l10n.Add("es", "hello", "¡Hola!") +// bot.AddL10n(l10n) +// +// Replaces any previously set L10n instance. func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { + if l == nil { + bot.logger.Warn("AddL10n called with nil L10n; localization will be disabled") + } bot.l10n = l return bot } +// enqueueUpdate attempts to add an update to the internal processing queue. +// +// Returns extypes.QueueFullErr if the queue is full and the update cannot be enqueued. +// This is non-blocking and used to implement rate-limiting behavior. +// +// When DropRLOverflow is enabled, this error is ignored and the update is dropped. +// Otherwise, the update is retried via the main update loop. func (bot *Bot[T]) enqueueUpdate(u *tgapi.Update) error { select { case bot.updateQueue <- u: @@ -295,6 +560,26 @@ func (bot *Bot[T]) enqueueUpdate(u *tgapi.Update) error { return extypes.QueueFullErr } } + +// RunWithContext starts the bot with a given context for graceful shutdown. +// +// This is the main entry point for bot execution. It: +// - Validates required configuration (prefixes, plugins) +// - Starts all registered runners as background goroutines +// - Begins polling for updates via Telegram's GetUpdates API +// - Processes updates concurrently using a worker pool (16 goroutines) +// +// The context controls graceful shutdown. When canceled, the bot: +// - Stops polling for new updates +// - Finishes processing currently queued updates +// - Closes all resources (API, uploader, loggers) +// +// Example: +// +// ctx, cancel := context.WithCancel(context.Background()) +// go bot.RunWithContext(ctx) +// // ... later ... +// cancel() // triggers graceful shutdown func (bot *Bot[T]) RunWithContext(ctx context.Context) { if len(bot.prefixes) == 0 { bot.logger.Fatalln("no prefixes defined") @@ -309,6 +594,8 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { bot.ExecRunners() bot.logger.Infoln("Bot running. Press CTRL+C to exit.") + + // Start update polling in a goroutine go func() { for { select { @@ -317,13 +604,14 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { default: updates, err := bot.Updates() if err != nil { - bot.logger.Errorln(err) + bot.logger.Errorln("failed to fetch updates:", err) + time.Sleep(2 * time.Second) // exponential backoff continue } for _, u := range updates { select { - case bot.updateQueue <- new(u): + case bot.updateQueue <- u: case <-ctx.Done(): return } @@ -332,14 +620,22 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { } }() + // Start worker pool for concurrent update handling pool := pond.NewPool(16) for update := range bot.updateQueue { - update := update + update := update // capture loop variable pool.Submit(func() { bot.handle(update) }) } } + +// Run starts the bot using a background context. +// +// Equivalent to RunWithContext(context.Background()). +// Use this for simple bots where graceful shutdown is not required. +// +// For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully. func (bot *Bot[T]) Run() { bot.RunWithContext(context.Background()) } diff --git a/cmd_generator.go b/cmd_generator.go index 8723e51..ec7ce01 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -1,3 +1,12 @@ +// Package laniakea provides a framework for building Telegram bots with plugin-based +// command registration and automatic command scope management. +// +// This module automatically generates and registers bot commands across different +// chat scopes (private, group, admin) based on plugin-defined commands. +// +// Commands are derived from Plugin and Command structs, with optional descriptions +// and argument formatting. Automatic registration avoids manual command setup and +// ensures consistency across chat types. package laniakea import ( @@ -8,11 +17,33 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// ErrTooManyCommands is returned when the total number of registered commands +// exceeds Telegram's limit of 100 bot commands per bot. +// +// Telegram Bot API enforces this limit strictly. If exceeded, SetMyCommands +// will fail with a 400 error. This error helps catch the issue early during +// bot initialization. +var ErrTooManyCommands = errors.New("too many commands. max 100") + +// generateBotCommand converts a Command[T] into a tgapi.BotCommand with a +// formatted description that includes usage instructions. +// +// The description is built as: +// +// ". Usage: / [] ..." +// +// Required arguments are shown as-is; optional arguments are wrapped in square brackets. +// +// Example: +// +// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}} +// → Description: "Start the bot. Usage: /start [name]" func generateBotCommand[T any](cmd Command[T]) tgapi.BotCommand { desc := cmd.command if len(cmd.description) > 0 { desc = cmd.description } + var descArgs []string for _, a := range cmd.args { if a.required { @@ -21,10 +52,17 @@ func generateBotCommand[T any](cmd Command[T]) tgapi.BotCommand { descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text)) } } + desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " ")) return tgapi.BotCommand{Command: cmd.command, Description: desc} } +// generateBotCommandForPlugin collects all non-skipped commands from a Plugin[T] +// and converts them into tgapi.BotCommand objects. +// +// Commands marked with skipAutoCmd = true are excluded from auto-registration. +// This allows plugins to opt out of automatic command generation (e.g., for +// internal or hidden commands). func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, cmd := range pl.commands { @@ -36,37 +74,66 @@ func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { return commands } -var ErrTooManyCommands = errors.New("too many commands. max 100") - +// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API +// across three scopes: +// - Private chats (users) +// - Group chats +// - Group administrators +// +// It first deletes existing commands to ensure a clean state, then sets the new +// set of commands for all scopes. This ensures consistency even if commands were +// previously modified manually via @BotFather. +// +// Returns ErrTooManyCommands if the total number of commands exceeds 100. +// Returns any API error from Telegram (e.g., network issues, invalid scope). +// +// Important: This method assumes the bot has been properly initialized and +// the API client is authenticated and ready. +// +// Usage: +// +// err := bot.AutoGenerateCommands() +// if err != nil { +// log.Fatal(err) +// } func (bot *Bot[T]) AutoGenerateCommands() error { + // Clear existing commands to avoid duplication or stale entries _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) if err != nil { - return err + return fmt.Errorf("failed to delete existing commands: %w", err) } + // Collect all non-skipped commands from all plugins commands := make([]tgapi.BotCommand, 0) for _, pl := range bot.plugins { if pl.skipAutoCmd { continue } - commands = append(commands, generateBotCommandForPlugin(pl)...) + bot.logger.Debugf("Registered %d commands from plugin %s", len(pl.commands), pl.name) } + + // Enforce Telegram's 100-command limit if len(commands) > 100 { return ErrTooManyCommands } - privateChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType} - groupChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeGroupType} - chatAdminsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeAllChatAdministratorsType} - _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: privateChatsScope}) - if err != nil { - return err + // Register commands for each scope + scopes := []*tgapi.BotCommandScope{ + {Type: tgapi.BotCommandScopePrivateType}, + {Type: tgapi.BotCommandScopeGroupType}, + {Type: tgapi.BotCommandScopeAllChatAdministratorsType}, } - _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: groupChatsScope}) - if err != nil { - return err + + for _, scope := range scopes { + _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{ + Commands: commands, + Scope: scope, + }) + if err != nil { + return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err) + } } - _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: chatAdminsScope}) - return err + + return nil } diff --git a/drafts.go b/drafts.go index 2362bdb..3de0784 100644 --- a/drafts.go +++ b/drafts.go @@ -1,3 +1,31 @@ +// Package laniakea provides a safe, high-level interface for managing Telegram +// message drafts using the tgapi library. It allows creating, editing, and +// flushing drafts with automatic ID generation and optional bulk flushing. +// +// Drafts are designed to be ephemeral, mutable buffers that can be built up +// incrementally and then sent as final messages. The package ensures safe +// state management by copying entities and isolating draft contexts. +// +// Two draft ID generation strategies are supported: +// - Random: Cryptographically secure random IDs (default). Ideal for distributed systems. +// - Linear: Monotonically increasing IDs. Useful for persistence, debugging, or recovery. +// +// Example usage: +// +// provider := laniakea.NewRandomDraftProvider(api) +// provider.SetChat(-1001234567890, 0).SetParseMode(tgapi.ParseModeHTML) +// +// draft := provider.NewDraft(tgapi.ParseModeMarkdown) +// draft.Push("*Hello*").Push(" **world**!") +// err := draft.Flush() // Sends message and deletes draft +// if err != nil { +// log.Printf("Failed to send draft: %v", err) +// } +// +// // Or flush all pending drafts at once: +// err = provider.FlushAll() // Sends all drafts and clears them +// +// Note: Drafts are NOT thread-safe. Concurrent access requires external synchronization. package laniakea import ( @@ -7,38 +35,137 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// draftIdGenerator defines an interface for generating unique draft IDs. type draftIdGenerator interface { + // Next returns the next unique draft ID. Next() uint64 } -type RandomDraftIdGenerator struct { - draftIdGenerator -} +// RandomDraftIdGenerator generates draft IDs using cryptographically secure random numbers. +// Suitable for distributed systems or when ID predictability is undesirable. +type RandomDraftIdGenerator struct{} +// Next returns a random 64-bit unsigned integer. func (g *RandomDraftIdGenerator) Next() uint64 { return rand.Uint64() } +// LinearDraftIdGenerator generates draft IDs using a monotonically increasing counter. +// Useful for debugging, persistence, or when drafts must be ordered. type LinearDraftIdGenerator struct { - draftIdGenerator lastId atomic.Uint64 } +// Next returns the next linear ID, atomically incremented. func (g *LinearDraftIdGenerator) Next() uint64 { return g.lastId.Add(1) } +// DraftProvider manages a collection of Drafts and provides methods to create and +// configure them. It holds shared configuration (chat, parse mode, entities) and +// a draft ID generator. +// +// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines +// requires external synchronization. type DraftProvider struct { - api *tgapi.API + api *tgapi.API + drafts map[uint64]*Draft + generator draftIdGenerator + // Internal defaults — not exposed directly to users. chatID int64 messageThreadID int parseMode tgapi.ParseMode entities []tgapi.MessageEntity - - drafts map[uint64]*Draft - generator draftIdGenerator } + +// NewRandomDraftProvider creates a new DraftProvider using random draft IDs. +// +// The provider will use cryptographically secure random numbers for draft IDs. +// All drafts created via this provider will have unpredictable, unique IDs. +func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { + return &DraftProvider{ + api: api, generator: &RandomDraftIdGenerator{}, + drafts: make(map[uint64]*Draft), + } +} + +// NewLinearDraftProvider creates a new DraftProvider using linear (incrementing) draft IDs. +// +// startValue is the initial value for the counter. Use 0 for fresh start, or a known +// value to resume from persisted state. +// +// This is useful when you need to store draft IDs externally (e.g., in a database) +// and want to reconstruct drafts after restart. +func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { + g := &LinearDraftIdGenerator{} + g.lastId.Store(startValue) + return &DraftProvider{ + api: api, + generator: g, + drafts: make(map[uint64]*Draft), + } +} + +// SetChat sets the target chat and optional message thread for all drafts created +// by this provider. Must be called before NewDraft(). +// +// If not set, NewDraft() will create drafts with zero chatID, which will cause +// SendMessageDraft to fail. Use this method to avoid runtime errors. +func (p *DraftProvider) SetChat(chatID int64, messageThreadID int) *DraftProvider { + p.chatID = chatID + p.messageThreadID = messageThreadID + return p +} + +// SetParseMode sets the default parse mode for all new drafts. +// Overrides the parse mode passed to NewDraft() only if not specified there. +func (p *DraftProvider) SetParseMode(mode tgapi.ParseMode) *DraftProvider { + p.parseMode = mode + return p +} + +// SetEntities sets the default message entities (e.g., bold, links, mentions) +// to be copied into every new draft. +// +// Entities are shallow-copied — if you mutate the slice later, it will affect +// future drafts. For safety, pass a copy if needed. +func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvider { + p.entities = entities + return p +} + +// GetDraft retrieves a draft by its ID. +// +// Returns the draft and true if found, or nil and false if not found. +func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) { + draft, ok := p.drafts[id] + return draft, ok +} + +// FlushAll sends all pending drafts as final messages and clears them. +// +// If any draft fails to send, FlushAll returns the error immediately and +// leaves other drafts unflushed. This allows for retry logic or logging. +// +// After successful flush, each draft is removed from the provider and cleared. +func (p *DraftProvider) FlushAll() error { + var lastErr error + for _, draft := range p.drafts { + if err := draft.Flush(); err != nil { + lastErr = err + break // Stop on first error to avoid partial state + } + } + return lastErr +} + +// Draft represents a single message draft that can be edited and flushed. +// +// Drafts are safe to use from a single goroutine. Multiple goroutines must +// synchronize access manually. +// +// Drafts are automatically removed from the provider's map when Flush() succeeds. type Draft struct { api *tgapi.API provider *DraftProvider @@ -52,69 +179,98 @@ type Draft struct { Message string } -func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { - return &DraftProvider{ - api: api, generator: &RandomDraftIdGenerator{}, - parseMode: tgapi.ParseMDV2, - drafts: make(map[uint64]*Draft), +// NewDraft creates a new draft with the provided parse mode. +// +// The draft inherits the provider's chatID, messageThreadID, and entities. +// If parseMode is zero, the provider's default parseMode is used. +// +// Panics if chatID is zero — call SetChat() on the provider first. +func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { + if p.chatID == 0 { + panic("laniakea: DraftProvider.SetChat() must be called before NewDraft()") } -} -func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { - g := &LinearDraftIdGenerator{} - g.lastId.Store(startValue) - return &DraftProvider{ - api: api, - generator: g, - drafts: make(map[uint64]*Draft), - } -} -func (d *DraftProvider) NewDraft() *Draft { - id := d.generator.Next() - entitiesCopy := make([]tgapi.MessageEntity, 0) - copy(entitiesCopy, d.entities) + + id := p.generator.Next() draft := &Draft{ - api: d.api, - provider: d, - chatID: d.chatID, - messageThreadID: d.messageThreadID, - parseMode: d.parseMode, - entities: entitiesCopy, + api: p.api, + provider: p, + chatID: p.chatID, + messageThreadID: p.messageThreadID, + parseMode: parseMode, + entities: p.entities, // Shallow copy — caller must ensure immutability ID: id, Message: "", } - d.drafts[id] = draft + p.drafts[id] = draft return draft } -func (d *Draft) push(text string, escapeMd bool) error { - if escapeMd { - d.Message += EscapeMarkdownV2(text) - } else { - d.Message += text - } - params := tgapi.SendMessageDraftP{ - ChatID: d.chatID, - DraftID: d.ID, - Text: d.Message, - ParseMode: d.parseMode, - Entities: d.entities, - } - if d.messageThreadID > 0 { - params.MessageThreadID = d.messageThreadID - } - _, err := d.api.SendMessageDraft(params) - return err + +// SetChat overrides the draft's target chat and message thread. +// +// This is useful for sending a draft to a different chat than the provider's default. +func (d *Draft) SetChat(chatID int64, messageThreadID int) *Draft { + d.chatID = chatID + d.messageThreadID = messageThreadID + return d } +// SetEntities replaces the draft's message entities. +// +// Entities are stored by reference. If you plan to mutate the slice later, +// pass a copy: `SetEntities(append([]tgapi.MessageEntity{}, myEntities...))` +func (d *Draft) SetEntities(entities []tgapi.MessageEntity) *Draft { + d.entities = entities + return d +} + +// Push appends text to the draft and attempts to update the server-side draft. +// +// Returns an error if the Telegram API rejects the update (e.g., due to network issues). +// The draft's Message field is always updated, even if the API call fails. +// +// Use this method to build the message incrementally. func (d *Draft) Push(text string) error { - return d.push(text, true) -} -func (d *Draft) PushMarkdown(text string) error { - return d.push(text, false) + return d.push(text) } +// GetMessage returns the current content of the draft. +// +// Useful for inspection, logging, or validation before flushing. +func (d *Draft) GetMessage() string { + return d.Message +} + +// Clear resets the draft's message content to empty string. +// +// Does not affect server-side draft — use Flush() for that. func (d *Draft) Clear() { d.Message = "" } + +// Delete removes the draft from its provider and clears its content. +// +// This is an internal method used by Flush(). You may call it manually if you +// want to cancel a draft without sending it. +func (d *Draft) Delete() { + if d.provider != nil { + delete(d.provider.drafts, d.ID) + } + d.Clear() +} + +// Flush sends the draft as a final message and clears it locally. +// +// If successful: +// - The message is sent to Telegram. +// - The draft's content is cleared. +// - The draft is removed from the provider's map. +// +// If an error occurs: +// - The message is NOT sent. +// - The draft remains in the provider and retains its content. +// - You can call Flush() again to retry. +// +// If the draft is empty, Flush() returns nil without calling the API. func (d *Draft) Flush() error { if d.Message == "" { return nil @@ -129,10 +285,27 @@ func (d *Draft) Flush() error { if d.messageThreadID > 0 { params.MessageThreadID = d.messageThreadID } + _, err := d.api.SendMessage(params) if err == nil { - d.Clear() - delete(d.provider.drafts, d.ID) + d.Delete() } return err } + +// push is the internal helper for Push(). It updates the server draft via SendMessageDraft. +func (d *Draft) push(text string) error { + d.Message += text + params := tgapi.SendMessageDraftP{ + ChatID: d.chatID, + DraftID: d.ID, + Text: d.Message, + ParseMode: d.parseMode, + Entities: d.entities, + } + if d.messageThreadID > 0 { + params.MessageThreadID = d.messageThreadID + } + _, err := d.api.SendMessageDraft(params) + return err +} diff --git a/examples/basic/.env b/examples/basic/.env new file mode 100644 index 0000000..2c2ca0b --- /dev/null +++ b/examples/basic/.env @@ -0,0 +1,7 @@ +TG_TOKEN= +PREFIXES=/;! +DEBUG=true +USE_REQ_LOG=true +WRITE_TO_FILE=false +USE_TEST_SERVER=true +API_URL=http://127.0.0.1:8081 \ No newline at end of file diff --git a/examples/basic/example.go b/examples/basic/example.go new file mode 100644 index 0000000..e556d7a --- /dev/null +++ b/examples/basic/example.go @@ -0,0 +1,28 @@ +package main + +import ( + "log" + + "git.nix13.pw/scuroneko/laniakea" +) + +func pong(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + ctx.Answer(ctx.Msg.Text) +} + +func main() { + bot := laniakea.NewBot[laniakea.NoDB](laniakea.LoadOptsFromEnv()) + defer bot.Close() + + p := laniakea.NewPlugin[laniakea.NoDB]("ping") + p.NewCommand(pong, "ping") + + bot = bot.ErrorTemplate( + "Error\n\n%s", + ).AddPlugins(p) + + if err := bot.AutoGenerateCommands(); err != nil { + log.Println(err) + } + bot.Run() +} diff --git a/examples/basic/go.mod b/examples/basic/go.mod new file mode 100644 index 0000000..d2f5d09 --- /dev/null +++ b/examples/basic/go.mod @@ -0,0 +1,16 @@ +module example/basic + +go 1.26.1 + +require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.11 + +require ( + git.nix13.pw/scuroneko/extypes v1.2.1 // indirect + git.nix13.pw/scuroneko/slog v1.0.2 // indirect + github.com/alitto/pond/v2 v2.6.2 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/time v0.14.0 // indirect +) diff --git a/examples/basic/go.sum b/examples/basic/go.sum new file mode 100644 index 0000000..925a330 --- /dev/null +++ b/examples/basic/go.sum @@ -0,0 +1,19 @@ +git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5/RpQ= +git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= +git.nix13.pw/scuroneko/laniakea v1.0.0-beta.11 h1:bf+5B8vUL/MEmbbX6pA0Wjf0N1eIZH5/WxoSApMcXD4= +git.nix13.pw/scuroneko/laniakea v1.0.0-beta.11/go.mod h1:DZgCqOazRzoa+f/GSNuKnTB2wIZ1eJD3cGf34Qya31U= +git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= +git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= +github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw= +github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/go.mod b/go.mod index 31c38e0..db3117a 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.26 require ( git.nix13.pw/scuroneko/extypes v1.2.1 git.nix13.pw/scuroneko/slog v1.0.2 - github.com/alitto/pond/v2 v2.6.2 - golang.org/x/time v0.14.0 + github.com/alitto/pond/v2 v2.7.0 + golang.org/x/time v0.15.0 ) require ( github.com/fatih/color v1.18.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 744f128..a1e3c21 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5 git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= -github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw= -github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= +github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= +github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -11,7 +11,7 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/keyboard.go b/keyboard.go index 01b400e..7f8883c 100644 --- a/keyboard.go +++ b/keyboard.go @@ -1,3 +1,13 @@ +// Package laniakea provides a fluent builder system for constructing Telegram +// inline keyboards with callback data and custom styling. +// +// This package supports: +// - Button builders with style (danger/success/primary), icons, URLs, and callbacks +// - Line-based keyboard layout with configurable max row size +// - Structured, JSON-serialized callback data for bot command routing +// +// Keyboard construction is stateful and builder-style: methods return the receiver +// to enable chaining. Call Get() to finalize and retrieve the tgapi.ReplyMarkup. package laniakea import ( @@ -8,12 +18,26 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary are predefined +// Telegram keyboard button styles for visual feedback. +// +// These values map directly to Telegram Bot API's InlineKeyboardButton style field. const ( ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger" ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success" ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary" ) +// InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button. +// +// Use NewInlineKbButton() to start, then chain methods to configure: +// - SetIconCustomEmojiId() — adds a custom emoji icon +// - SetStyle() — sets visual style (danger/success/primary) +// - SetUrl() — makes button open a URL +// - SetCallbackData() — attaches structured command + args for bot handling +// +// Call build() to produce the final tgapi.InlineKeyboardButton. +// Builder methods are immutable — each returns a copy. type InlineKbButtonBuilder struct { text string iconCustomEmojiID string @@ -22,26 +46,48 @@ type InlineKbButtonBuilder struct { callbackData string } +// NewInlineKbButton creates a new button builder with the given display text. +// The button will have no URL, no style, and no callback data by default. func NewInlineKbButton(text string) InlineKbButtonBuilder { return InlineKbButtonBuilder{text: text} } + +// SetIconCustomEmojiId sets a custom emoji ID to display as the button's icon. +// This is a Telegram Bot API feature for custom emoji icons. func (b InlineKbButtonBuilder) SetIconCustomEmojiId(id string) InlineKbButtonBuilder { b.iconCustomEmojiID = id return b } + +// SetStyle sets the visual style of the button. +// Valid values: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary. +// If not set, the button uses the default style. func (b InlineKbButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineKbButtonBuilder { b.style = style return b } + +// SetUrl sets a URL that will be opened when the button is pressed. +// If both URL and CallbackData are set, Telegram will prioritize URL. func (b InlineKbButtonBuilder) SetUrl(url string) InlineKbButtonBuilder { b.url = url return b } + +// SetCallbackData sets a structured callback payload that will be sent to the bot +// when the button is pressed. The command and arguments are serialized as JSON. +// +// Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool) +// are safely serialized, but complex structs may not serialize usefully. +// +// Example: SetCallbackData("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]} func (b InlineKbButtonBuilder) SetCallbackData(cmd string, args ...any) InlineKbButtonBuilder { b.callbackData = NewCallbackData(cmd, args...).ToJson() return b } +// build converts the builder state into a tgapi.InlineKeyboardButton. +// This method is typically called internally by InlineKeyboard.AddButton(). func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { return tgapi.InlineKeyboardButton{ Text: b.text, @@ -52,12 +98,22 @@ func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { } } +// InlineKeyboard is a stateful builder for constructing Telegram inline keyboard layouts. +// +// Buttons are added row-by-row. When a row reaches maxRow, it is automatically flushed. +// Call AddLine() to manually end a row, or Get() to finalize and retrieve the markup. +// +// The keyboard is not thread-safe. Build it in a single goroutine. type InlineKeyboard struct { - CurrentLine extypes.Slice[tgapi.InlineKeyboardButton] - Lines [][]tgapi.InlineKeyboardButton - maxRow int + CurrentLine extypes.Slice[tgapi.InlineKeyboardButton] // Current row being built + Lines [][]tgapi.InlineKeyboardButton // Completed rows + maxRow int // Max buttons per row (e.g., 3 or 4) } +// NewInlineKeyboard creates a new keyboard builder with the specified maximum +// number of buttons per row. +// +// Example: NewInlineKeyboard(3) creates a keyboard with at most 3 buttons per line. func NewInlineKeyboard(maxRow int) *InlineKeyboard { return &InlineKeyboard{ CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0), @@ -66,6 +122,8 @@ func NewInlineKeyboard(maxRow int) *InlineKeyboard { } } +// append adds a button to the current line. If the line is full, it auto-flushes. +// This is an internal helper used by other builder methods. func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { if in.CurrentLine.Len() == in.maxRow { in.AddLine() @@ -74,27 +132,45 @@ func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeybo return in } +// AddUrlButton adds a button that opens a URL when pressed. +// No callback data is attached. func (in *InlineKeyboard) AddUrlButton(text, url string) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{Text: text, URL: url}) } + +// AddUrlButtonStyle adds a button with a visual style that opens a URL. +// Style must be one of: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary. func (in *InlineKeyboard) AddUrlButtonStyle(text string, style tgapi.KeyboardButtonStyle, url string) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{Text: text, Style: style, URL: url}) } + +// AddCallbackButton adds a button that sends a structured callback payload to the bot. +// The command and args are serialized as JSON using NewCallbackData. func (in *InlineKeyboard) AddCallbackButton(text string, cmd string, args ...any) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{ - Text: text, CallbackData: NewCallbackData(cmd, args...).ToJson(), - }) -} -func (in *InlineKeyboard) AddCallbackButtonStyle(text string, style tgapi.KeyboardButtonStyle, cmd string, args ...any) *InlineKeyboard { - return in.append(tgapi.InlineKeyboardButton{ - Text: text, Style: style, + Text: text, CallbackData: NewCallbackData(cmd, args...).ToJson(), }) } + +// AddCallbackButtonStyle adds a styled callback button. +// Style affects visual appearance; callback data is sent to bot on press. +func (in *InlineKeyboard) AddCallbackButtonStyle(text string, style tgapi.KeyboardButtonStyle, cmd string, args ...any) *InlineKeyboard { + return in.append(tgapi.InlineKeyboardButton{ + Text: text, + Style: style, + CallbackData: NewCallbackData(cmd, args...).ToJson(), + }) +} + +// AddButton adds a button pre-configured via InlineKbButtonBuilder. +// This is the most flexible way to create buttons with custom emoji, style, URL, and callback. func (in *InlineKeyboard) AddButton(b InlineKbButtonBuilder) *InlineKeyboard { return in.append(b.build()) } +// AddLine manually ends the current row and starts a new one. +// If the current row is empty, nothing happens. func (in *InlineKeyboard) AddLine() *InlineKeyboard { if in.CurrentLine.Len() == 0 { return in @@ -103,6 +179,11 @@ func (in *InlineKeyboard) AddLine() *InlineKeyboard { in.CurrentLine = make(extypes.Slice[tgapi.InlineKeyboardButton], 0) return in } + +// Get finalizes the keyboard and returns a tgapi.ReplyMarkup. +// 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 { if in.CurrentLine.Len() > 0 { in.Lines = append(in.Lines, in.CurrentLine) @@ -110,11 +191,26 @@ func (in *InlineKeyboard) Get() *tgapi.ReplyMarkup { return &tgapi.ReplyMarkup{InlineKeyboard: in.Lines} } +// CallbackData represents the structured payload sent when an inline button +// with callback data is pressed. +// +// This structure is serialized to JSON and sent to the bot as a string. +// The bot should parse this back to determine the command and arguments. +// +// Example: +// +// {"cmd":"delete_user","args":["123","confirm"]} type CallbackData struct { - Command string `json:"cmd"` - Args []string `json:"args"` + Command string `json:"cmd"` // The command name to route to + Args []string `json:"args"` // Arguments passed as strings } +// NewCallbackData creates a new CallbackData instance with the given command and args. +// +// All args are converted to strings using fmt.Sprint. This is safe for primitives +// (int, string, bool, float64) but may not serialize complex structs meaningfully. +// +// Use this to build callback payloads for bot command routing. func NewCallbackData(command string, args ...any) *CallbackData { stringArgs := make([]string, len(args)) for i, arg := range args { @@ -125,9 +221,18 @@ func NewCallbackData(command string, args ...any) *CallbackData { Args: stringArgs, } } + +// ToJson serializes the CallbackData to a JSON string. +// +// If serialization fails (e.g., due to unmarshalable fields), returns a fallback +// JSON object: {"cmd":""} to prevent breaking Telegram's API. +// +// This fallback ensures the bot receives a valid JSON payload even if internal +// errors occur — avoiding "invalid callback_data" errors from Telegram. func (d *CallbackData) ToJson() string { data, err := json.Marshal(d) if err != nil { + // Fallback: return minimal valid JSON to avoid Telegram API rejection return `{"cmd":""}` } return string(data) diff --git a/l10n.go b/l10n.go index 48d7fbb..917d4ea 100644 --- a/l10n.go +++ b/l10n.go @@ -1,26 +1,86 @@ +// Package laniakea provides a simple, key-based localization system for +// multi-language text translation. +// +// The system supports: +// - Multiple language entries per key (e.g., "ru", "en", "es") +// - Fallback language for missing translations +// - Key-as-fallback behavior: if a key or language is not found, returns the key itself +// +// This is designed for lightweight, static localization in bots or services +// where dynamic translation services are unnecessary. package laniakea -// DictEntry {key:{ru:123,en:123}} +// DictEntry represents a single localized entry with language-to-text mappings. +// Example: {"ru": "Привет", "en": "Hello"} type DictEntry map[string]string + +// L10n is a localization manager that maps keys to language-specific strings. type L10n struct { - entries map[string]DictEntry - fallbackLang string + entries map[string]DictEntry // Map of translation keys to language dictionaries + fallbackLang string // Language code to use when requested language is missing } +// NewL10n creates a new L10n instance with the specified fallback language. +// The fallback language is used when a requested language is not available +// for a given key. +// +// Example: NewL10n("en") will return "Hello" for key "greeting" if "ru" is requested +// but no "ru" entry exists. func NewL10n(fallbackLanguage string) *L10n { - return &L10n{make(map[string]DictEntry), fallbackLanguage} + return &L10n{ + entries: make(map[string]DictEntry), + fallbackLang: fallbackLanguage, + } } + +// AddDictEntry adds a new translation entry for the given key. +// The value must be a DictEntry mapping language codes (e.g., "en", "ru") to their translated strings. +// +// If a key already exists, it is overwritten. +// +// Returns the L10n instance for method chaining. func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n { l.entries[key] = value return l } + +// GetFallbackLanguage returns the currently configured fallback language code. func (l *L10n) GetFallbackLanguage() string { return l.fallbackLang } + +// Translate retrieves the translation for the given key and language. +// +// Behavior: +// - If the key exists and the language has a translation → returns the translation +// - If the key exists but the language is missing → returns the fallback language's value +// - If the key does not exist → returns the key string itself (as fallback) +// +// Example: +// +// l.AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Привет"}) +// l.Translate("en", "greeting") → "Hello" +// l.Translate("es", "greeting") → "Hello" (fallback to "en") +// l.Translate("en", "unknown") → "unknown" (key not found) +// +// This behavior ensures that missing translations do not break UI or logs — +// instead, the original key is displayed, making it easy to identify gaps. func (l *L10n) Translate(lang, key string) string { - s, ok := l.entries[key] - if !ok { - return key + entries, exists := l.entries[key] + if !exists { + return key // Return key as fallback when translation is missing } - return s[lang] + + // Try requested language + if translation, ok := entries[lang]; ok { + return translation + } + + // Fall back to configured fallback language + if fallback, ok := entries[l.fallbackLang]; ok { + return fallback + } + + // If fallback language is also missing, return the key + return key } diff --git a/msg_context.go b/msg_context.go index 2d2b3ac..0798deb 100644 --- a/msg_context.go +++ b/msg_context.go @@ -1,3 +1,22 @@ +// Package laniakea provides a high-level context-based API for handling Telegram +// bot interactions, including message responses, callback queries, inline keyboards, +// localization, and message drafting. It wraps tgapi and adds convenience methods +// with built-in rate limiting, error handling, and i18n support. +// +// The core type is MsgContext, which encapsulates the state of a Telegram update +// and provides methods to respond, edit, delete, and translate messages. +// +// # Markdown Safety Warning +// +// All methods that accept MarkdownV2 formatting (e.g., AnswerMarkdown, EditCallbackfMarkdown) +// require that user-provided text be escaped using laniakea.EscapeMarkdownV2(). +// Failure to escape user input may result in Telegram API errors, malformed messages, +// or security issues. +// +// Example: +// +// text := laniakea.EscapeMarkdownV2(userInput) +// ctx.AnswerMarkdown("You said: " + text) package laniakea import ( @@ -8,9 +27,11 @@ import ( "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 - + Api *tgapi.API Msg *tgapi.Message Update tgapi.Update From *tgapi.User @@ -27,22 +48,23 @@ type MsgContext struct { draftProvider *DraftProvider } +// 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 + ctx *MsgContext // internal back-reference } -func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, escapeMd bool) *AnswerMessage { +// 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{ MessageID: messageId, ChatID: ctx.Msg.Chat.ID, Text: text, - ParseMode: tgapi.ParseMDV2, - } - if escapeMd { - params.Text = EscapeMarkdownV2(text) + ParseMode: parseMode, } if keyboard != nil { params.ReplyMarkup = keyboard.Get() @@ -56,38 +78,67 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard MessageID: msg.MessageID, 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, true) + 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, false) + return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2) } + +// editCallback is an internal helper to edit the message associated with a callback query. +// Returns nil if CallbackMsgId is 0 (not a callback context). +func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { + if ctx.CallbackMsgId == 0 { + 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 { - if ctx.CallbackMsgId == 0 { - ctx.botLogger.Errorln("Can't edit non-callback update message") - return nil - } - - return ctx.edit(ctx.CallbackMsgId, text, keyboard, true) + 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 { - if ctx.CallbackMsgId == 0 { - ctx.botLogger.Errorln("Can't edit non-callback update message") + 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 if messageId is 0. +func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { + if messageId == 0 { + ctx.botLogger.Errorln("Can't edit caption message, message ID zero") return nil } - - return ctx.edit(ctx.CallbackMsgId, text, keyboard, false) -} -func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { - return ctx.EditCallback(fmt.Sprintf(format, args...), keyboard) -} - -func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard) *AnswerMessage { params := tgapi.EditMessageCaptionP{ ChatID: ctx.Msg.Chat.ID, MessageID: messageId, Caption: text, - ParseMode: tgapi.ParseMD, + ParseMode: parseMode, } if kb != nil { params.ReplyMarkup = kb.Get() @@ -101,25 +152,38 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true, } } + +// EditCaption edits the caption of a media message using plain text. func (m *AnswerMessage) EditCaption(text string) *AnswerMessage { - if m.MessageID == 0 { - m.ctx.botLogger.Errorln("Can't edit caption message, message id is zero") - return m - } - return m.ctx.editPhotoText(m.MessageID, text, nil) -} -func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *AnswerMessage { - return m.ctx.editPhotoText(m.MessageID, text, kb) + return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseNone) } -func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, escapeMd bool) *AnswerMessage { +// 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 { params := tgapi.SendMessageP{ ChatID: ctx.Msg.Chat.ID, Text: text, - ParseMode: tgapi.ParseMDV2, - } - if escapeMd { - params.Text = EscapeMarkdownV2(text) + ParseMode: parseMode, } if keyboard != nil { params.ReplyMarkup = keyboard.Get() @@ -145,35 +209,51 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, escapeMd bo MessageID: msg.MessageID, ctx: ctx, IsMedia: false, Text: text, } } -func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage { - return ctx.answer(text, nil, false) -} + +// Answer sends a plain text message (ParseNone). func (ctx *MsgContext) Answer(text string) *AnswerMessage { - return ctx.answer(text, nil, true) -} -func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { - return ctx.answer(fmt.Sprintf(template, args...), nil, false) -} -func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage { - return ctx.answer(fmt.Sprintf(template, args...), nil, true) -} -func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { - return ctx.answer(text, keyboard, false) -} -func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage { - return ctx.answer(text, kb, true) + return ctx.answer(text, nil, tgapi.ParseNone) } -func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, escapeMd bool) *AnswerMessage { +// 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 { params := tgapi.SendPhotoP{ ChatID: ctx.Msg.Chat.ID, Caption: text, - ParseMode: tgapi.ParseMDV2, + ParseMode: parseMode, Photo: photoId, } - if escapeMd { - params.Caption = EscapeMarkdownV2(text) - } if kb != nil { params.ReplyMarkup = kb.Get() } @@ -184,35 +264,50 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, esc msg, err := ctx.Api.SendPhoto(params) if err != nil { ctx.botLogger.Errorln(err) - return &AnswerMessage{ - ctx: ctx, Text: text, IsMedia: true, - } + 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, true) + 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, false) + 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, true) + 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, false) + 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, true) -} -func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...any) *AnswerMessage { - return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, false) + 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) { _, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{ ChatID: ctx.Msg.Chat.ID, @@ -222,9 +317,15 @@ func (ctx *MsgContext) delete(messageId int) { ctx.botLogger.Errorln(err) } } -func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) } + +// 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() { 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 @@ -237,11 +338,20 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { ctx.botLogger.Errorln(err) } } -func (ctx *MsgContext) AnswerCbQuery() { ctx.answerCallbackQuery("", "", false) } -func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery("", text, false) } -func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) } -func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "", false) } +// 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) { params := tgapi.SendChatActionP{ ChatID: ctx.Msg.Chat.ID, Action: action, @@ -255,30 +365,47 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { } } +// 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, true) + 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() *Draft { +func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { c := context.Background() if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { ctx.botLogger.Errorln(err) return nil } - draft := ctx.draftProvider.NewDraft() - draft.chatID = ctx.Msg.Chat.ID - draft.messageThreadID = ctx.Msg.MessageThreadID + 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) +} + +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 diff --git a/plugins.go b/plugins.go index daf6f7f..428970f 100644 --- a/plugins.go +++ b/plugins.go @@ -1,3 +1,15 @@ +// Package laniakea provides a structured system for defining and executing +// bot commands and payloads with middleware support, argument validation, +// and plugin-based organization. +// +// The core concepts are: +// - Command: A named bot command with arguments, description, and executor. +// - Plugin: A collection of commands and payloads, with shared middlewares. +// - Middleware: Interceptors that can validate, modify, or block execution. +// - CommandArg: Type-safe argument definitions with regex validation. +// +// This system is designed to be used with MsgContext from the laniakea package +// to handle Telegram bot interactions in a modular, type-safe way. package laniakea import ( @@ -7,83 +19,122 @@ import ( "git.nix13.pw/scuroneko/extypes" ) -const ( - CommandValueStringType CommandValueType = "string" - CommandValueIntType CommandValueType = "int" - CommandValueBoolType CommandValueType = "bool" - CommandValueAnyType CommandValueType = "any" -) - -var ( - CommandRegexInt = regexp.MustCompile(`\d+`) - CommandRegexString = regexp.MustCompile(".+") -) - -var ( - ErrCmdArgCountMismatch = errors.New("command arg count mismatch") - ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") -) - +// CommandValueType defines the expected type of a command argument. type CommandValueType string + +const ( + // CommandValueStringType expects any non-empty string. + CommandValueStringType CommandValueType = "string" + // CommandValueIntType expects a decimal integer (digits only). + CommandValueIntType CommandValueType = "int" + // CommandValueBoolType is reserved for future use (not implemented). + CommandValueBoolType CommandValueType = "bool" + // CommandValueAnyType accepts any input without validation. + CommandValueAnyType CommandValueType = "any" +) + +// CommandRegexInt matches one or more digits. +var CommandRegexInt = regexp.MustCompile(`\d+`) + +// CommandRegexString matches any non-empty string. +var CommandRegexString = regexp.MustCompile(".+") + +// ErrCmdArgCountMismatch is returned when the number of provided arguments +// is less than the number of required arguments. +var ErrCmdArgCountMismatch = errors.New("command arg count mismatch") + +// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation. +var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") + +// CommandArg defines a single argument for a command, including type, regex, +// and whether it is required. type CommandArg struct { - valueType CommandValueType - text string - regex *regexp.Regexp - required bool + valueType CommandValueType // Type of expected value + text string // Human-readable description (not used in validation) + regex *regexp.Regexp // Regex used to validate input + required bool // Whether this argument must be provided } +// NewCommandArg creates a new CommandArg with the given text and type. +// Uses a default regex based on the type (string or int). +// For CommandValueAnyType, no validation is performed. func NewCommandArg(text string, valueType CommandValueType) *CommandArg { regex := CommandRegexString switch valueType { case CommandValueIntType: regex = CommandRegexInt + case CommandValueAnyType: + regex = nil // Skip validation } return &CommandArg{valueType, text, regex, false} } + +// SetRequired marks this argument as required. +// Returns the receiver for method chaining. func (c *CommandArg) SetRequired() *CommandArg { c.required = true return c } +// CommandExecutor is the function type that executes a command. +// It receives the message context and a database context (generic). type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext *T) +// Command represents a bot command with arguments, description, and executor. +// Can be registered in a Plugin and optionally skipped from auto-generation. type Command[T DbContext] struct { - command string - description string - exec CommandExecutor[T] - args extypes.Slice[CommandArg] - middlewares extypes.Slice[Middleware[T]] - skipAutoCmd bool + command string // The command trigger (e.g., "/start") + description string // Human-readable description for help + exec CommandExecutor[T] // Function to execute when command is triggered + args extypes.Slice[CommandArg] // List of expected arguments + middlewares extypes.Slice[Middleware[T]] // Optional middleware chain + skipAutoCmd bool // If true, this command won't be auto-added to help menus } +// NewCommand creates a new Command with the given executor, command string, and arguments. +// The command string should not include the leading slash (e.g., "start", not "/start"). func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} + return &Command[T]{command, "", exec, extypes.Slice[CommandArg](args), make(extypes.Slice[Middleware[T]], 0), false} } + +// Use adds a middleware to the command's execution chain. +// Middlewares are executed in the order they are added. func (c *Command[T]) Use(m Middleware[T]) *Command[T] { c.middlewares = c.middlewares.Push(m) return c } + +// SetDescription sets the human-readable description of the command. func (c *Command[T]) SetDescription(desc string) *Command[T] { c.description = desc return c } + +// SkipCommandAutoGen marks this command to be excluded from auto-generated help menus. func (c *Command[T]) SkipCommandAutoGen() *Command[T] { c.skipAutoCmd = true return c } + +// validateArgs checks if the provided arguments match the command's requirements. +// Returns ErrCmdArgCountMismatch if too few arguments are provided. +// Returns ErrCmdArgRegexpMismatch if any argument fails regex validation. func (c *Command[T]) validateArgs(args []string) error { - cmdArgs := c.args.Filter(func(e CommandArg) bool { return !e.required }) - if len(args) < cmdArgs.Len() { + // Count required args + requiredCount := c.args.Filter(func(a CommandArg) bool { return a.required }).Len() + if len(args) < requiredCount { return ErrCmdArgCountMismatch } + // Validate each argument against its regex for i, arg := range args { if i >= c.args.Len() { + // Extra arguments beyond defined args are ignored break } cmdArg := c.args.Get(i) if cmdArg.regex == nil { - continue + continue // Skip validation for CommandValueAnyType } if !cmdArg.regex.MatchString(arg) { return ErrCmdArgRegexpMismatch @@ -92,57 +143,123 @@ func (c *Command[T]) validateArgs(args []string) error { return nil } +// Plugin represents a collection of commands and payloads (e.g., callback handlers), +// with shared middleware and configuration. type Plugin[T DbContext] struct { - name string - commands map[string]Command[T] - payloads map[string]Command[T] - middlewares extypes.Slice[Middleware[T]] - skipAutoCmd bool + name string // Name of the plugin (e.g., "admin", "user") + commands map[string]Command[T] // Registered commands (triggered by message) + payloads map[string]Command[T] // Registered payloads (triggered by callback data) + middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads + skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help } +// NewPlugin creates a new Plugin with the given name. func NewPlugin[T DbContext](name string) *Plugin[T] { return &Plugin[T]{ - name, map[string]Command[T]{}, - map[string]Command[T]{}, extypes.Slice[Middleware[T]]{}, false, + name, make(map[string]Command[T]), + make(map[string]Command[T]), extypes.Slice[Middleware[T]]{}, false, } } +// AddCommand registers a command in the plugin. +// The command's .command field is used as the key. func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { p.commands[command.command] = *command return p } + +// NewCommand creates and immediately adds a new command to the plugin. +// Returns the created command for further configuration. func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - return NewCommand(exec, command, args...) + cmd := NewCommand(exec, command, args...) + p.AddCommand(cmd) + return cmd } + +// AddPayload registers a payload (e.g., callback query data) in the plugin. +// Payloads are triggered by inline button callback_data, not by message text. func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { p.payloads[command.command] = *command return p } + +// AddMiddleware adds a middleware to the plugin's global middleware chain. +// Middlewares are executed before any command or payload. func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] { p.middlewares = p.middlewares.Push(middleware) return p } + +// SkipCommandAutoGen marks the entire plugin to be excluded from auto-generated help menus. func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] { p.skipAutoCmd = true return p } +// executeCmd finds and executes a command by its trigger string. +// Validates arguments and runs middlewares before executor. +// On error, sends an error message to the user via ctx.error(). func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { - command := p.commands[cmd] + command, exists := p.commands[cmd] + if !exists { + ctx.error(errors.New("command not found")) + return + } + if err := command.validateArgs(ctx.Args); err != nil { ctx.error(err) return } + + // Run plugin middlewares + if !p.executeMiddlewares(ctx, dbContext) { + return + } + + // Run command-specific middlewares + for _, m := range command.middlewares { + if !m.Execute(ctx, dbContext) { + return + } + } + + // Execute command command.exec(ctx, dbContext) } + +// executePayload finds and executes a payload by its callback_data string. +// Validates arguments and runs middlewares before executor. +// On error, sends an error message to the user via ctx.error(). func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) { - pl := p.payloads[payload] - if err := pl.validateArgs(ctx.Args); err != nil { + command, exists := p.payloads[payload] + if !exists { + ctx.error(errors.New("payload not found")) + return + } + + if err := command.validateArgs(ctx.Args); err != nil { ctx.error(err) return } - pl.exec(ctx, dbContext) + + // Run plugin middlewares + if !p.executeMiddlewares(ctx, dbContext) { + return + } + + // Run command-specific middlewares + for _, m := range command.middlewares { + if !m.Execute(ctx, dbContext) { + return + } + } + + // Execute payload + command.exec(ctx, dbContext) } + +// executeMiddlewares runs all plugin middlewares in order. +// Returns false if any middleware returns false (blocks execution). func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { for _, m := range p.middlewares { if !m.Execute(ctx, db) { @@ -152,28 +269,41 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { return true } +// MiddlewareExecutor is the function type for middleware logic. +// Returns true to continue execution, false to block it. +// If async, return value is ignored. type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db *T) bool -// Middleware -// When async, returned value ignored +// Middleware represents a reusable execution interceptor. +// Can be synchronous (blocking) or asynchronous (non-blocking). type Middleware[T DbContext] struct { - name string - executor MiddlewareExecutor[T] - order int - async bool + name string // Human-readable name for logging/debugging + executor MiddlewareExecutor[T] // Function to execute + order int // Optional sort order (not used yet) + async bool // If true, runs in goroutine and doesn't block } +// NewMiddleware creates a new synchronous middleware. func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] { return &Middleware[T]{name, executor, 0, false} } + +// SetOrder sets the execution order (currently ignored). func (m *Middleware[T]) SetOrder(order int) *Middleware[T] { m.order = order return m } + +// SetAsync marks the middleware to run asynchronously. +// Execution continues regardless of its return value. func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { m.async = async return m } + +// Execute runs the middleware. +// If async, runs in a goroutine and returns true immediately. +// Otherwise, returns the result of the executor. func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool { if m.async { go m.executor(ctx, db) diff --git a/runners.go b/runners.go index 29c0ed5..0774a37 100644 --- a/runners.go +++ b/runners.go @@ -1,58 +1,123 @@ +// Package laniakea provides a system for managing background and one-time +// runner functions that operate on a Bot instance, with support for +// asynchronous execution, timeouts, and lifecycle control. +// +// Runners are used for periodic tasks (e.g., cleanup, stats updates) or +// one-time initialization logic. They are executed via Bot.ExecRunners(). +// +// Important: Runners are not thread-safe for concurrent modification. +// Builder methods (Onetime, Async, Timeout) must be called sequentially +// and only before Execute(). package laniakea import ( "time" ) +// RunnerFn is the function type for a runner. It receives a pointer to +// the Bot and returns an error if execution fails. type RunnerFn[T DbContext] func(*Bot[T]) error + +// Runner represents a configurable background or one-time task to be +// executed by a Bot. +// +// Runners are configured using builder methods: Onetime(), Async(), Timeout(). +// Once Execute() is called, the Runner should not be modified. +// +// Execution semantics: +// - onetime=true, async=false: Run once synchronously (blocks). +// - onetime=true, async=true: Run once in a goroutine (non-blocking). +// - onetime=false, async=true: Run repeatedly in a goroutine with timeout. +// - onetime=false, async=false: Invalid configuration — ignored with warning. type Runner[T DbContext] struct { - name string - onetime bool - async bool - timeout time.Duration - fn RunnerFn[T] + name string // Human-readable name for logging + onetime bool // If true, runs once; if false, runs periodically + async bool // If true, runs in a goroutine; else, runs synchronously + timeout time.Duration // Duration to wait between periodic executions (ignored if onetime=true) + fn RunnerFn[T] // The function to execute } -// NewRunner creates a new Runner with async=true by default. -// Builder methods (Onetime, Async, Timeout) modify the Runner in-place. +// NewRunner creates a new Runner with the given name and function. +// By default, the Runner is configured as async=true (non-blocking). +// +// Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. // DO NOT call builder methods concurrently or after Execute(). func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { return &Runner[T]{ - name: name, fn: fn, async: true, + name: name, + fn: fn, + async: true, // Default: run asynchronously + timeout: 0, // Default: no timeout (ignored if onetime=true) } } -func (b *Runner[T]) Onetime(onetime bool) *Runner[T] { - b.onetime = onetime - return b -} -func (b *Runner[T]) Async(async bool) *Runner[T] { - b.async = async - return b -} -func (b *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { - b.timeout = timeout - return b + +// Onetime sets whether the runner executes once or repeatedly. +// If true, the runner runs only once. +// If false, the runner runs in a loop with the configured timeout. +func (r *Runner[T]) Onetime(onetime bool) *Runner[T] { + r.onetime = onetime + return r } +// Async sets whether the runner executes synchronously or asynchronously. +// If true, the runner runs in a goroutine (non-blocking). +// If false, the runner blocks the caller during execution. +// +// Note: If onetime=false and async=false, the runner will be skipped with a warning. +func (r *Runner[T]) Async(async bool) *Runner[T] { + r.async = async + return r +} + +// Timeout sets the duration to wait between repeated executions for +// non-onetime runners. +// +// If onetime=true, this value is ignored. +// If onetime=false and async=true, this timeout determines the sleep interval +// between loop iterations. +// +// A zero value (time.Duration(0)) is allowed but may trigger a warning +// if used with a background (non-onetime) async runner. +func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { + r.timeout = timeout + return r +} + +// ExecRunners executes all runners registered on the Bot. +// +// It logs warnings for misconfigured runners: +// - Sync, non-onetime runners are skipped (invalid configuration). +// - Background (non-onetime, async) runners without a timeout trigger a warning. +// +// Execution logic: +// - onetime + async: Runs once in a goroutine. +// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds. +// - !onetime + async: Runs in an infinite loop with timeout between iterations. +// - !onetime + sync: Skipped with warning. +// +// This method is typically called once during bot startup. func (bot *Bot[T]) ExecRunners() { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { + // Validate configuration if !runner.onetime && !runner.async { - bot.logger.Warnf("Runner %s not onetime, but sync\n", runner.name) + bot.logger.Warnf("Runner %s not onetime, but sync — skipping\n", runner.name) continue } - if !runner.onetime && runner.async && runner.timeout == (time.Second*0) { - bot.logger.Warnf("Background runner \"%s\" should have timeout", runner.name) + if !runner.onetime && runner.async && runner.timeout == 0 { + bot.logger.Warnf("Background runner \"%s\" has no timeout — may cause tight loop\n", runner.name) } - if runner.async && runner.onetime { - go func() { - err := runner.fn(bot) + if runner.onetime && runner.async { + // One-time async: fire and forget + go func(r Runner[T]) { + err := r.fn(bot) if err != nil { - bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) + bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } - }() - } else if !runner.async && runner.onetime { + }(runner) + } else if runner.onetime && !runner.async { + // One-time sync: block until done t := time.Now() err := runner.fn(bot) if err != nil { @@ -60,18 +125,20 @@ func (bot *Bot[T]) ExecRunners() { } elapsed := time.Since(t) if elapsed > time.Second*2 { - bot.logger.Warnf("Runner %s too slow. Elapsed time %s>=2s", runner.name, elapsed) + bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) } - } else if !runner.onetime { - go func() { + } else if !runner.onetime && runner.async { + // Background loop: periodic execution + go func(r Runner[T]) { for { - err := runner.fn(bot) + err := r.fn(bot) if err != nil { - bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) + bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } - time.Sleep(runner.timeout) + time.Sleep(r.timeout) } - }() + }(runner) } + // Note: !onetime && !async is already skipped above } } diff --git a/tgapi/methods.go b/tgapi/methods.go index 3be5f8e..c9f467e 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -12,6 +12,7 @@ const ( ParseMDV2 ParseMode = "MarkdownV2" ParseHTML ParseMode = "HTML" ParseMD ParseMode = "Markdown" + ParseNone ParseMode = "None" ) type EmptyParams struct{} diff --git a/utils.go b/utils.go index ed48cf6..96456eb 100644 --- a/utils.go +++ b/utils.go @@ -48,4 +48,10 @@ func EscapePunctuation(s string) string { return s } -const VersionString = utils.VersionString +const ( + VersionString = utils.VersionString + VersionMajor = utils.VersionMajor + VersionMinor = utils.VersionMinor + VersionPatch = utils.VersionPatch + VersionBeta = utils.VersionBeta +) diff --git a/utils/version.go b/utils/version.go index 50ea178..25891c6 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.11" + VersionString = "1.0.0-beta.12" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 11 + VersionBeta = 12 ) -- 2.54.0 From 057422260809a0b91077aec4899f049fa1abdd7a Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 12 Mar 2026 14:15:42 +0300 Subject: [PATCH 31/99] v1.0.0 beta 13 --- bot.go | 4 ++-- cmd_generator.go | 2 +- examples/basic/go.mod | 8 ++++---- examples/basic/go.sum | 16 ++++++++-------- plugins.go | 1 - tgapi/api.go | 1 - utils/version.go | 4 ++-- 7 files changed, 17 insertions(+), 19 deletions(-) diff --git a/bot.go b/bot.go index c791eda..f775387 100644 --- a/bot.go +++ b/bot.go @@ -277,7 +277,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { if bot.username == "" { bot.logger.Warn("Can't get bot username. Named command handlers won't work!") } - bot.logger.Infof("Authorized as %s (@%s)\n", u.FirstName, u.Username) + bot.logger.Infof("Authorized as %s (@%s)\n", u.FirstName, Val(u.Username, "unknown")) return bot } @@ -611,7 +611,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { for _, u := range updates { select { - case bot.updateQueue <- u: + case bot.updateQueue <- &u: case <-ctx.Done(): return } diff --git a/cmd_generator.go b/cmd_generator.go index ec7ce01..183d4e2 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -110,7 +110,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error { continue } commands = append(commands, generateBotCommandForPlugin(pl)...) - bot.logger.Debugf("Registered %d commands from plugin %s", len(pl.commands), pl.name) + bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name)) } // Enforce Telegram's 100-command limit diff --git a/examples/basic/go.mod b/examples/basic/go.mod index d2f5d09..de6a9b2 100644 --- a/examples/basic/go.mod +++ b/examples/basic/go.mod @@ -2,15 +2,15 @@ module example/basic go 1.26.1 -require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.11 +require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.12 require ( git.nix13.pw/scuroneko/extypes v1.2.1 // indirect git.nix13.pw/scuroneko/slog v1.0.2 // indirect - github.com/alitto/pond/v2 v2.6.2 // indirect + github.com/alitto/pond/v2 v2.7.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/time v0.14.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/examples/basic/go.sum b/examples/basic/go.sum index 925a330..b3af86c 100644 --- a/examples/basic/go.sum +++ b/examples/basic/go.sum @@ -1,11 +1,11 @@ git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5/RpQ= git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= -git.nix13.pw/scuroneko/laniakea v1.0.0-beta.11 h1:bf+5B8vUL/MEmbbX6pA0Wjf0N1eIZH5/WxoSApMcXD4= -git.nix13.pw/scuroneko/laniakea v1.0.0-beta.11/go.mod h1:DZgCqOazRzoa+f/GSNuKnTB2wIZ1eJD3cGf34Qya31U= +git.nix13.pw/scuroneko/laniakea v1.0.0-beta.12 h1:IpcLF5OTZKOsYhj7AULDsDPrCUdtSnS5LgApOyMIRYU= +git.nix13.pw/scuroneko/laniakea v1.0.0-beta.12/go.mod h1:M8jwm195hzAl9bj9Bkl95WfHmWvuBX6micsdtOs/gmE= git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= -github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw= -github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= +github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= +github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -13,7 +13,7 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/plugins.go b/plugins.go index 428970f..b2b230a 100644 --- a/plugins.go +++ b/plugins.go @@ -172,7 +172,6 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { // Returns the created command for further configuration. func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { cmd := NewCommand(exec, command, args...) - p.AddCommand(cmd) return cmd } diff --git a/tgapi/api.go b/tgapi/api.go index 8b994ab..5c624fd 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -224,7 +224,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro } api.logger.Debugln("REQ", url, string(data)) - resp, err := api.client.Do(req) if err != nil { return zero, fmt.Errorf("HTTP request failed: %w", err) diff --git a/utils/version.go b/utils/version.go index 25891c6..80ac2e7 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.12" + VersionString = "1.0.0-beta.13" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 12 + VersionBeta = 13 ) -- 2.54.0 From 3b6bb82e0450c47cac44804637fe66380d534cd0 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 12 Mar 2026 15:00:00 +0300 Subject: [PATCH 32/99] v1.0.0 beta 14 --- README.md | 203 +++++++++++++++++++++++++++++++++++++- README_RU.md | 202 +++++++++++++++++++++++++++++++++++++ bot.go | 19 ++-- examples/basic/example.go | 16 +-- examples/basic/go.mod | 6 +- examples/basic/go.sum | 4 +- utils/version.go | 4 +- 7 files changed, 434 insertions(+), 20 deletions(-) create mode 100644 README_RU.md diff --git a/README.md b/README.md index 7e8892d..5af984b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,204 @@ # Laniakea -A lightweight, easy to use and performance Telegram API wrapper for bot development. \ No newline at end of file +[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) +![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) + +A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It simplifies bot development with a clean plugin system, middleware support, automatic command generation, and built-in rate limiting. + +[На русском](README_RU.md) + +--- + +## ✨ Features +* **Simple & Intuitive API:** Designed for ease of use, based on practical examples. +* **Plugin System:** Organize your bot's functionality into independent, reusable plugins. +* **Command Handling:** Easily register commands and extract arguments. +* **Middleware Support:** Run code before or after commands (e.g., logging, access control). +* **Automatic Command Generation:** Generate help and command lists automatically. +* **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling). +* **Context-Aware:** Pass custom database or state contexts to your handlers. +* **Fluent Interface:** Chain methods for clean configuration (e.g., `bot.ErrorTemplate(...).AddPlugins(...)`). + +--- + +## 📦 Installation + +```bash +go get git.nix13.pw/scuroneko/laniakea +``` + +## 🚀 Quick Start (with step-by-step explanation) + +Here is a minimal echo/ping bot example with detailed comments. +```go +package main + +import ( + "log" + + "git.nix13.pw/scuroneko/laniakea" // Import the Laniakea library +) + +// echo is a command handler function. +// It receives two parameters: +// - ctx: the message context (contains info about the message, sender, chat, etc.) +// - db: your custom database context (here we use NoDB, a placeholder for no database) +func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + // Answer the user with the text they sent, without any command prefix. + // ctx.Text contains the user's message with the command part stripped off. + ctx.Answer(ctx.Text) // User input WITHOUT command +} + +func main() { + // 1. Create bot options. Replace "TOKEN" with your actual bot token from @BotFather. + opts := &laniakea.BotOpts{Token: "TOKEN"} + + // 2. Initialize a new bot instance. + // We use laniakea.NoDB as the database context type (no database needed for this example). + bot := laniakea.NewBot[laniakea.NoDB](opts) + // Ensure bot resources are cleaned up on exit. + defer bot.Close() + + // 3. Create a new plugin named "ping". + // Plugins help group related commands and middlewares. + p := laniakea.NewPlugin[laniakea.NoDB]("ping") + + // 4. Add a command to the plugin. + // p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command. + p.AddCommand(p.NewCommand(echo, "echo")) + + // 5. Add another command using an anonymous function (closure). + // This command simply replies "Pong" when the user sends "/ping". + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + ctx.Answer("Pong") + }, "ping")) + + // 6. Configure the bot with a custom error template and add the plugin. + // ErrorTemplate sets a format string for errors (where %s will be replaced by the actual error). + // AddPlugins(p) registers our "ping" plugin with the bot. + bot = bot.ErrorTemplate("Error\n\n%s").AddPlugins(p) + + // 7. Automatically generate commands like /start, /help, and a list of all registered commands. + // This is optional but very useful for most bots. + if err := bot.AutoGenerateCommands(); err != nil { + log.Println(err) + } + + // 8. Start the bot, listening for updates (long polling). + bot.Run() +} +``` + +### How It Works +1. `BotOpts`: Holds configuration like the API token. +2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it. +3. `NewPlugin`: Creates a logical group for commands and middlewares. +4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash). +5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T. +6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. +7. `AutoGenerateCommands`: Adds built-in commands (/start, /help) and a command that lists all available commands. +8. `Run()`: Starts the bot's update polling loop. + +## 📖 Core Concepts +### Plugins + +Plugins are the main way to organize code. A plugin can have multiple commands and middlewares. +```go +plugin := laniakea.NewPlugin[MyDB]("admin") +plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +bot.AddPlugins(plugin) +``` + +### Commands + +A command is a function that handles a specific bot command (e.g., /start). +```go +func myHandler(ctx *laniakea.MsgContext, db *MyDB) { + // Access command arguments via ctx.Args ([]string) + // Reply to the user: ctx.Answer("some text") +} +``` + +### MsgContext + +Provides access to the incoming message and useful reply methods: + +- `Answer(text string)`: Sends a plain text message, automatically escaping MarkdownV2. +- `AnswerMarkdown(text string)`: Sends a message formatted with MarkdownV2 (you handle escaping). +- `AnswerText(text string)`: Sends a message with no parse_mode. +- `SendChatAction(action string)`: Sends a "typing", "uploading photo", etc., action. +- Fields: `Text`, `Args`, `From`, `Chat`, `Msg`, etc. + +### Database Context + +The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool) and it will be available in every command and middleware handler. + +```go +type MyDB struct { /* ... */ } +db := &MyDB{...} +bot := laniakea.NewBot[*MyDB](opts, db) // Pass db instance +``` + +## 🧩 Middleware +Middleware are functions that run before a command handler. They are perfect for cross-cutting concerns like logging, access control, rate limiting, or modifying the context. + +### Signature +A middleware function has the same signature as a command handler, but it must return a bool: + +```go +func(ctx *MsgContext, db T) bool +``` + +- If it returns true, the next middleware (or the command) will be executed. +- If it returns false, the execution chain stops immediately (the command will not run). + +### Adding Middleware +Use the Use method of a plugin to add one or more middleware functions. They are executed in the order they are added. + +```go +plugin := laniakea.NewPlugin[MyDB]("admin") +plugin.Use(loggingMiddleware, adminOnlyMiddleware) +plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +``` + +### Example Middlewares + +1. Logging Middleware – logs every command execution. +```go +func loggingMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { + log.Printf("User %d executed command: %s", ctx.FromID, ctx.Msg.Text) + return true // continue to next middleware/command +} +``` + +2. Admin-Only Middleware – restricts access to users with a specific role. +```go +func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { + if !db.IsAdmin(ctx.FromID) { // assume db has IsAdmin method + ctx.Answer("⛔ Access denied. Admins only.") + return false // stop execution + } + return true +} +``` + +### Important Notes +- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. +- If you need to run code after a command, you can call it from within the command itself or use a defer statement inside the middleware that wraps the next call (more advanced). + +## ⚙️ Advanced Configuration +- **Inline Keyboards**: Build keyboards using laniakea.NewKeyboard() and AddRow(). +- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. +- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control. + +## 📝 License + +This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. + +## 📚 Learn More +[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) + +[Telegram Bot API](https://core.telegram.org/bots/api) + + ✅ Built with ❤️ by scuroneko diff --git a/README_RU.md b/README_RU.md new file mode 100644 index 0000000..000fe90 --- /dev/null +++ b/README_RU.md @@ -0,0 +1,202 @@ +# Laniakea + +[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) +![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) + +Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке中间件, автоматической генерации команд и встроенному ограничителю скорости запросов. + +[English](README.md) + +--- + +## ✨ Возможности + +* **Простой и интуитивный API:** Разработан для лёгкости использования, основан на практических примерах. +* **Система плагинов:** Организуйте функциональность бота в независимые, переиспользуемые плагины. +* **Обработка команд:** Легко регистрируйте команды и извлекайте аргументы. +* **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа). +* **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически. +* **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). +* **Контекст данных:** Передавайте свой контекст базы данных или состояния в обработчики. +* **Текучий интерфейс (Fluent Interface):** Стройте цепочки методов для чистой конфигурации (например, `bot.ErrorTemplate(...).AddPlugins(...)`). + +--- + +## 📦 Установка + +```bash +go get git.nix13.pw/scuroneko/laniakea +``` + +## 🚀 Быстрый старт (с пошаговыми комментариями) +Вот минимальный пример бота "echo/ping" с подробными комментариями. + +```go +package main + +import ( + "log" + + "git.nix13.pw/scuroneko/laniakea" // Импортируем библиотеку Laniakea +) + +// echo — это функция-обработчик команды. +// Она получает два параметра: +// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) +// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку) +func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + // Отвечаем пользователю текстом, который он прислал, без префикса команды. + // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. + ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды +} + +func main() { + // 1. Создаём опции бота. Замените "TOKEN" на реальный токен от @BotFather. + opts := &laniakea.BotOpts{Token: "TOKEN"} + + // 2. Инициализируем новый экземпляр бота. + // Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера). + bot := laniakea.NewBot[laniakea.NoDB](opts) + // Гарантируем освобождение ресурсов бота при выходе. + defer bot.Close() + + // 3. Создаём новый плагин с именем "ping". + // Плагины помогают группировать связанные команды и промежуточные обработчики. + p := laniakea.NewPlugin[laniakea.NoDB]("ping") + + // 4. Добавляем команду в плагин. + // p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo". + p.AddCommand(p.NewCommand(echo, "echo")) + + // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). + // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + ctx.Answer("Pong") + }, "ping")) + + // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. + // ErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки). + // AddPlugins(p) регистрирует наш плагин "ping" в боте. + bot = bot.ErrorTemplate("Ошибка\n\n%s").AddPlugins(p) + + // 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд. + // Это необязательно, но очень полезно для большинства ботов. + if err := bot.AutoGenerateCommands(); err != nil { + log.Println(err) + } + + // 8. Запускаем бота, начиная прослушивание обновлений (long polling). + bot.Run() +} +``` + +### Как это работает +1. `BotOpts`: Содержит конфигурацию, например, токен API. +2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен. +3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. +4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша). +5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T. +6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. +7. `AutoGenerateCommands`: Добавляет встроенные команды (/start, /help) и команду, показывающую список всех доступных команд. +8. `Run()`: Запускает цикл опроса обновлений бота. + +## 📖 Основные концепции +### Плагины (Plugins) +Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware. + +```go +plugin := laniakea.NewPlugin[MyDB]("admin") +plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +bot.AddPlugins(plugin) +``` + +### Команды (Commands) +Команда — это функция, которая обрабатывает конкретную команду бота (например, /start). + +```go +func myHandler(ctx *laniakea.MsgContext, db *MyDB) { + // Доступ к аргументам команды через ctx.Args ([]string) + // Ответ пользователю: ctx.Answer("какой-то текст") +} +``` + +### Контекст сообщения (MsgContext) +Предоставляет доступ к входящему сообщению и полезные методы для ответа: + +- `Answer(text string)`: Отправляет обычный текст, автоматически экранируя MarkdownV2. +- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне). +- `AnswerText(text string)`: Отправляет сообщение без parse_mode. +- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д. +- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие. + +### Контекст базы данных (Database Context) +Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件. + +```go +type MyDB struct { /* ... */ } +db := &MyDB{...} +bot := laniakea.NewBot[*MyDB](opts, db) // Передаём экземпляр db +``` + +## 🧩 Промежуточные слои (Middleware) +Middleware — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста. + +### Сигнатура +Функция middleware имеет ту же сигнатуру, что и обработчик команды, но должна возвращать bool: + +```go +func(ctx *MsgContext, db T) bool +``` + +- Если возвращается true, выполняется следующий middleware (или сама команда). +- Если возвращается false, цепочка выполнения немедленно прерывается (команда не запускается). + +### Добавление middleware +Используйте метод Use плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления. + +```go +plugin := laniakea.NewPlugin[MyDB]("admin") +plugin.Use(loggingMiddleware, adminOnlyMiddleware) +plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +``` + +### Примеры middleware + +1. Логирующий middleware – логирует каждое выполнение команды. +```go +func loggingMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { + log.Printf("Пользователь %d выполнил команду: %s", ctx.FromID, ctx.Msg.Text) + return true // продолжаем к следующему middleware/команде +} +``` + +2. Middleware только для администраторов – ограничивает доступ пользователям с определённой ролью. +```go +func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { + if !db.IsAdmin(ctx.FromID) { // предполагается, что db имеет метод IsAdmin + ctx.Answer("⛔ Доступ запрещён. Только для администраторов.") + return false // останавливаем выполнение + } + return true +} +``` + +### Важные замечания +- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. +- Если нужно выполнить код после команды, это можно сделать внутри самой команды или использовать отложенный вызов (defer) в middleware, который оборачивает следующий вызов (более продвинутый подход). + +## ⚙️ Расширенная настройка +**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard() и AddRow(). +**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram. +**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля. + +## 📝 Лицензия +Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE). + +## 📚 Дополнительная информация +[GoDoc Laniakea](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) + +[Telegram Bot API](https://core.telegram.org/bots/api) + + ✅ Создано с ❤️ scuroneko diff --git a/bot.go b/bot.go index f775387..673db98 100644 --- a/bot.go +++ b/bot.go @@ -226,19 +226,24 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { updateQueue := make(chan *tgapi.Update, 512) - var limiter *utils.RateLimiter - if opts.RateLimit > 0 { - limiter = utils.NewRateLimiter() - } + //var limiter *utils.RateLimiter + //if opts.RateLimit > 0 { + // limiter = utils.NewRateLimiter() + //} + limiter := utils.NewRateLimiter() apiOpts := tgapi.NewAPIOpts(opts.Token). SetAPIUrl(opts.APIUrl). UseTestServer(opts.UseTestServer). SetLimiter(limiter) api := tgapi.NewAPI(apiOpts) - uploader := tgapi.NewUploader(api) + prefixes := opts.Prefixes + if len(prefixes) == 0 { + prefixes = []string{"/"} + } + bot := &Bot[T]{ updateOffset: 0, errorTemplate: "%s", @@ -246,7 +251,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { api: api, uploader: uploader, debug: opts.Debug, - prefixes: opts.Prefixes, + prefixes: prefixes, token: opts.Token, plugins: make([]Plugin[T], 0), updateTypes: make([]tgapi.UpdateType, 0), @@ -277,7 +282,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { if bot.username == "" { bot.logger.Warn("Can't get bot username. Named command handlers won't work!") } - bot.logger.Infof("Authorized as %s (@%s)\n", u.FirstName, Val(u.Username, "unknown")) + bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown"))) return bot } diff --git a/examples/basic/example.go b/examples/basic/example.go index e556d7a..f8c5b41 100644 --- a/examples/basic/example.go +++ b/examples/basic/example.go @@ -6,20 +6,22 @@ import ( "git.nix13.pw/scuroneko/laniakea" ) -func pong(ctx *laniakea.MsgContext, db *laniakea.NoDB) { - ctx.Answer(ctx.Msg.Text) +func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + ctx.Answer(ctx.Text) // User input WITHOUT command } func main() { - bot := laniakea.NewBot[laniakea.NoDB](laniakea.LoadOptsFromEnv()) + opts := &laniakea.BotOpts{Token: "TOKEN"} + bot := laniakea.NewBot[laniakea.NoDB](opts) defer bot.Close() p := laniakea.NewPlugin[laniakea.NoDB]("ping") - p.NewCommand(pong, "ping") + p.AddCommand(p.NewCommand(echo, "echo")) + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + ctx.Answer("Pong") + }, "ping")) - bot = bot.ErrorTemplate( - "Error\n\n%s", - ).AddPlugins(p) + bot = bot.ErrorTemplate("Error\n\n%s").AddPlugins(p) if err := bot.AutoGenerateCommands(); err != nil { log.Println(err) diff --git a/examples/basic/go.mod b/examples/basic/go.mod index de6a9b2..19feb1a 100644 --- a/examples/basic/go.mod +++ b/examples/basic/go.mod @@ -2,7 +2,11 @@ module example/basic go 1.26.1 -require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.12 +require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13 + +replace ( + git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13 => ../../ +) require ( git.nix13.pw/scuroneko/extypes v1.2.1 // indirect diff --git a/examples/basic/go.sum b/examples/basic/go.sum index b3af86c..0b840ff 100644 --- a/examples/basic/go.sum +++ b/examples/basic/go.sum @@ -1,7 +1,7 @@ git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5/RpQ= git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= -git.nix13.pw/scuroneko/laniakea v1.0.0-beta.12 h1:IpcLF5OTZKOsYhj7AULDsDPrCUdtSnS5LgApOyMIRYU= -git.nix13.pw/scuroneko/laniakea v1.0.0-beta.12/go.mod h1:M8jwm195hzAl9bj9Bkl95WfHmWvuBX6micsdtOs/gmE= +git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13 h1:mRVxYh7CNrm8ccob+u6XxLzZRbs1fLNRg/nXaXY78yw= +git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13/go.mod h1:M8jwm195hzAl9bj9Bkl95WfHmWvuBX6micsdtOs/gmE= git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= diff --git a/utils/version.go b/utils/version.go index 80ac2e7..ddcda17 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.13" + VersionString = "1.0.0-beta.14" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 13 + VersionBeta = 14 ) -- 2.54.0 From d6e2daec04c4b3f49f26f5e4a7ab461a24e63e0c Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 12 Mar 2026 17:45:53 +0300 Subject: [PATCH 33/99] v1.0.0 beta 15 --- bot.go | 36 ++++---- cmd_generator.go | 22 ++++- drafts.go | 2 +- examples/basic/go.mod | 4 +- handler.go | 33 ++++++- keyboard.go | 61 ++++++++++--- msg_context.go | 1 + plugins.go | 29 +++++-- tgapi/attachments_methods.go | 36 ++++++++ tgapi/attachments_types.go | 35 +++++--- tgapi/bot_methods.go | 80 +++++++++++++++++ tgapi/bot_types.go | 49 ++++++++--- tgapi/business_methods.go | 112 +++++++++++++++++++++++- tgapi/business_types.go | 55 ++++++++++-- tgapi/chat_methods.go | 164 ++++++++++++++++++++++++++++++++++- tgapi/chat_types.go | 33 ++++++- tgapi/forum_methods.go | 53 +++++++++++ tgapi/forum_types.go | 6 ++ tgapi/messages_methods.go | 140 +++++++++++++++++++++++++++--- tgapi/messages_types.go | 55 +++++++++++- tgapi/methods.go | 31 ++++++- tgapi/stickers_methods.go | 78 ++++++++++++++++- tgapi/stickers_types.go | 37 ++++++-- tgapi/types.go | 125 +++++++++++++++++++++----- tgapi/uploader_methods.go | 40 +++++++++ tgapi/users_methods.go | 17 ++++ tgapi/users_types.go | 12 +++ utils/version.go | 4 +- 28 files changed, 1224 insertions(+), 126 deletions(-) diff --git a/bot.go b/bot.go index 673db98..5a41045 100644 --- a/bot.go +++ b/bot.go @@ -25,7 +25,6 @@ // AddL10n(l10n.New()) // // go bot.Run() -// <-ctx.Done() // wait for shutdown signal // // All methods are thread-safe except direct field access. Use provided accessors // (e.g., GetDBContext, SetUpdateOffset) for safe concurrent access. @@ -170,6 +169,16 @@ type DbContext interface{} // Use Bot[NoDB] to indicate no dependency injection is required. type NoDB struct{ DbContext } +// BotPayloadType defines the serialization format for callback data payloads. +type BotPayloadType string + +var ( + // BotPayloadBase64 encodes callback data as a Base64 string. + BotPayloadBase64 BotPayloadType = "base64" + // BotPayloadJson encodes callback data as a JSON string. + BotPayloadJson BotPayloadType = "json" +) + // Bot is the core Telegram bot instance. // // Manages: @@ -185,6 +194,7 @@ type Bot[T DbContext] struct { debug bool errorTemplate string username string + payloadType BotPayloadType logger *slog.Logger // Main bot logger (JSON stdout + optional file) RequestLogger *slog.Logger // Optional request-level API logging @@ -430,6 +440,14 @@ func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { return bot } +// SetPayloadType sets the type, that bot will use for payload +// json - string `{"cmd": "command", "args": [...]} +// base64 - same json, but encoded in base64 string +func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { + bot.payloadType = t + return bot +} + // AddUpdateType adds one or more update types to the list. // Does not overwrite existing types. func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { @@ -550,22 +568,6 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { return bot } -// enqueueUpdate attempts to add an update to the internal processing queue. -// -// Returns extypes.QueueFullErr if the queue is full and the update cannot be enqueued. -// This is non-blocking and used to implement rate-limiting behavior. -// -// When DropRLOverflow is enabled, this error is ignored and the update is dropped. -// Otherwise, the update is retried via the main update loop. -func (bot *Bot[T]) enqueueUpdate(u *tgapi.Update) error { - select { - case bot.updateQueue <- u: - return nil - default: - return extypes.QueueFullErr - } -} - // RunWithContext starts the bot with a given context for graceful shutdown. // // This is the main entry point for bot execution. It: diff --git a/cmd_generator.go b/cmd_generator.go index 183d4e2..3f797ad 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -12,11 +12,14 @@ package laniakea import ( "errors" "fmt" + "regexp" "strings" "git.nix13.pw/scuroneko/laniakea/tgapi" ) +var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$") + // ErrTooManyCommands is returned when the total number of registered commands // exceeds Telegram's limit of 100 bot commands per bot. // @@ -38,8 +41,8 @@ var ErrTooManyCommands = errors.New("too many commands. max 100") // // Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}} // → Description: "Start the bot. Usage: /start [name]" -func generateBotCommand[T any](cmd Command[T]) tgapi.BotCommand { - desc := cmd.command +func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { + desc := "" if len(cmd.description) > 0 { desc = cmd.description } @@ -53,10 +56,20 @@ func generateBotCommand[T any](cmd Command[T]) tgapi.BotCommand { } } - desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " ")) + if desc != "" { + desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " ")) + } else { + desc = fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " ")) + } return tgapi.BotCommand{Command: cmd.command, Description: desc} } +// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+ +// Return true if satisfy, else false. +func checkCmdRegex(cmd string) bool { + return CmdRegexp.MatchString(cmd) +} + // generateBotCommandForPlugin collects all non-skipped commands from a Plugin[T] // and converts them into tgapi.BotCommand objects. // @@ -69,6 +82,9 @@ func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { if cmd.skipAutoCmd { continue } + if !checkCmdRegex(cmd.command) { + continue + } commands = append(commands, generateBotCommand(cmd)) } return commands diff --git a/drafts.go b/drafts.go index 3de0784..e9d93f2 100644 --- a/drafts.go +++ b/drafts.go @@ -13,9 +13,9 @@ // Example usage: // // provider := laniakea.NewRandomDraftProvider(api) -// provider.SetChat(-1001234567890, 0).SetParseMode(tgapi.ParseModeHTML) // // draft := provider.NewDraft(tgapi.ParseModeMarkdown) +// draft.SetChat(-1001234567890, 0) // draft.Push("*Hello*").Push(" **world**!") // err := draft.Flush() // Sends message and deletes draft // if err != nil { diff --git a/examples/basic/go.mod b/examples/basic/go.mod index 19feb1a..e957faf 100644 --- a/examples/basic/go.mod +++ b/examples/basic/go.mod @@ -2,10 +2,10 @@ module example/basic go 1.26.1 -require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13 +require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.14 replace ( - git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13 => ../../ + git.nix13.pw/scuroneko/laniakea v1.0.0-beta.14 => ../../ ) require ( diff --git a/handler.go b/handler.go index bdf0133..a9730eb 100644 --- a/handler.go +++ b/handler.go @@ -3,11 +3,14 @@ package laniakea import ( "encoding/base64" "encoding/json" + "errors" "strings" "git.nix13.pw/scuroneko/laniakea/tgapi" ) +var ErrInvalidPayloadType = errors.New("invalid payload type") + func (bot *Bot[T]) handle(u *tgapi.Update) { ctx := &MsgContext{ Update: *u, Api: bot.api, @@ -15,6 +18,7 @@ func (bot *Bot[T]) handle(u *tgapi.Update) { errorTemplate: bot.errorTemplate, l10n: bot.l10n, draftProvider: bot.draftProvider, + payloadType: bot.payloadType, } for _, middleware := range bot.middlewares { middleware.Execute(ctx, bot.dbContext) @@ -87,8 +91,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { } func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { - data := new(CallbackData) - err := json.Unmarshal([]byte(update.CallbackQuery.Data), data) + data, err := bot.decodePayload(update.CallbackQuery.Data) if err != nil { bot.logger.Errorln(err) return @@ -152,3 +155,29 @@ func decodeBase64Payload(s string) (CallbackData, error) { } return decodeJsonPayload(string(b)) } + +// func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) { +// switch payloadType { +// case BotPayloadBase64: +// return encodeBase64Payload(d) +// case BotPayloadJson: +// return encodeJsonPayload(d) +// } +// return "", ErrInvalidPayloadType +// } +func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) { + switch payloadType { + case BotPayloadBase64: + return decodeBase64Payload(s) + case BotPayloadJson: + return decodeJsonPayload(s) + } + return CallbackData{}, ErrInvalidPayloadType +} + +// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) { +// return encodePayload(bot.payloadType, d) +// } +func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { + return decodePayload(bot.payloadType, s) +} diff --git a/keyboard.go b/keyboard.go index 7f8883c..6890a97 100644 --- a/keyboard.go +++ b/keyboard.go @@ -11,7 +11,6 @@ package laniakea import ( - "encoding/json" "fmt" "git.nix13.pw/scuroneko/extypes" @@ -34,7 +33,7 @@ const ( // - SetIconCustomEmojiId() — adds a custom emoji icon // - SetStyle() — sets visual style (danger/success/primary) // - SetUrl() — makes button open a URL -// - SetCallbackData() — attaches structured command + args for bot handling +// - SetCallbackDataJson() — attaches structured command + args for bot handling // // Call build() to produce the final tgapi.InlineKeyboardButton. // Builder methods are immutable — each returns a copy. @@ -74,18 +73,26 @@ func (b InlineKbButtonBuilder) SetUrl(url string) InlineKbButtonBuilder { return b } -// SetCallbackData sets a structured callback payload that will be sent to the bot +// SetCallbackDataJson sets a structured callback payload that will be sent to the bot // when the button is pressed. The command and arguments are serialized as JSON. // // Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool) // are safely serialized, but complex structs may not serialize usefully. // -// Example: SetCallbackData("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]} -func (b InlineKbButtonBuilder) SetCallbackData(cmd string, args ...any) InlineKbButtonBuilder { +// Example: SetCallbackDataJson("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]} +func (b InlineKbButtonBuilder) SetCallbackDataJson(cmd string, args ...any) InlineKbButtonBuilder { b.callbackData = NewCallbackData(cmd, args...).ToJson() return b } +// SetCallbackDataBase64 sets a structured callback payload encoded as Base64. +// This can be useful when the JSON payload exceeds Telegram's callback data length limit. +// Args are converted to strings using fmt.Sprint. +func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKbButtonBuilder { + b.callbackData = NewCallbackData(cmd, args...).ToBase64() + return b +} + // build converts the builder state into a tgapi.InlineKeyboardButton. // This method is typically called internally by InlineKeyboard.AddButton(). func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { @@ -108,6 +115,8 @@ type InlineKeyboard struct { CurrentLine extypes.Slice[tgapi.InlineKeyboardButton] // Current row being built Lines [][]tgapi.InlineKeyboardButton // Completed rows maxRow int // Max buttons per row (e.g., 3 or 4) + + payloadType BotPayloadType // Serialization format for callback data (JSON or Base64) } // NewInlineKeyboard creates a new keyboard builder with the specified maximum @@ -119,9 +128,18 @@ func NewInlineKeyboard(maxRow int) *InlineKeyboard { CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0), Lines: make([][]tgapi.InlineKeyboardButton, 0), maxRow: maxRow, + payloadType: BotPayloadBase64, } } +// SetPayloadType sets the serialization format for callback data added via +// AddCallbackButton and AddCallbackButtonStyle methods. +// It should be one of BotPayloadJson or BotPayloadBase64. +func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard { + in.payloadType = t + return in +} + // append adds a button to the current line. If the line is full, it auto-flushes. // This is an internal helper used by other builder methods. func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { @@ -145,11 +163,11 @@ func (in *InlineKeyboard) AddUrlButtonStyle(text string, style tgapi.KeyboardBut } // AddCallbackButton adds a button that sends a structured callback payload to the bot. -// The command and args are serialized as JSON using NewCallbackData. +// The command and args are serialized according to the current payloadType. func (in *InlineKeyboard) AddCallbackButton(text string, cmd string, args ...any) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{ Text: text, - CallbackData: NewCallbackData(cmd, args...).ToJson(), + CallbackData: NewCallbackData(cmd, args...).Encode(in.payloadType), }) } @@ -159,7 +177,7 @@ func (in *InlineKeyboard) AddCallbackButtonStyle(text string, style tgapi.Keyboa return in.append(tgapi.InlineKeyboardButton{ Text: text, Style: style, - CallbackData: NewCallbackData(cmd, args...).ToJson(), + CallbackData: NewCallbackData(cmd, args...).Encode(in.payloadType), }) } @@ -230,10 +248,33 @@ func NewCallbackData(command string, args ...any) *CallbackData { // This fallback ensures the bot receives a valid JSON payload even if internal // errors occur — avoiding "invalid callback_data" errors from Telegram. func (d *CallbackData) ToJson() string { - data, err := json.Marshal(d) + data, err := encodeJsonPayload(*d) if err != nil { // Fallback: return minimal valid JSON to avoid Telegram API rejection return `{"cmd":""}` } - return string(data) + return data +} + +// 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) + if err != nil { + return `` + } + return s +} + +// Encode serializes the CallbackData according to the specified payload type. +// Supported types: BotPayloadJson and BotPayloadBase64. +// For unknown types, returns an empty string. +func (d *CallbackData) Encode(t BotPayloadType) string { + switch t { + case BotPayloadBase64: + return d.ToBase64() + case BotPayloadJson: + return d.ToJson() + } + return "" } diff --git a/msg_context.go b/msg_context.go index 0798deb..276df29 100644 --- a/msg_context.go +++ b/msg_context.go @@ -46,6 +46,7 @@ type MsgContext struct { botLogger *slog.Logger l10n *L10n draftProvider *DraftProvider + payloadType BotPayloadType } // AnswerMessage represents a message sent or edited via MsgContext. diff --git a/plugins.go b/plugins.go index b2b230a..6255fe2 100644 --- a/plugins.go +++ b/plugins.go @@ -94,7 +94,13 @@ type Command[T DbContext] struct { // NewCommand creates a new Command with the given executor, command string, and arguments. // The command string should not include the leading slash (e.g., "start", not "/start"). func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, extypes.Slice[CommandArg](args), make(extypes.Slice[Middleware[T]], 0), false} + return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} +} + +// NewPayload creates a new Command with the given executor, command payload string, and arguments. +// The command string can POTENTIALLY contain any symbols, but recommended to use only "_", "-", ".", a-Z, 0-9 +func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { + return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} } // Use adds a middleware to the command's execution chain. @@ -147,8 +153,8 @@ func (c *Command[T]) validateArgs(args []string) error { // with shared middleware and configuration. type Plugin[T DbContext] struct { name string // Name of the plugin (e.g., "admin", "user") - commands map[string]Command[T] // Registered commands (triggered by message) - payloads map[string]Command[T] // Registered payloads (triggered by callback data) + commands map[string]*Command[T] // Registered commands (triggered by message) + payloads map[string]*Command[T] // Registered payloads (triggered by callback data) middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help } @@ -156,15 +162,15 @@ type Plugin[T DbContext] struct { // NewPlugin creates a new Plugin with the given name. func NewPlugin[T DbContext](name string) *Plugin[T] { return &Plugin[T]{ - name, make(map[string]Command[T]), - make(map[string]Command[T]), extypes.Slice[Middleware[T]]{}, false, + name, make(map[string]*Command[T]), + make(map[string]*Command[T]), extypes.Slice[Middleware[T]]{}, false, } } // AddCommand registers a command in the plugin. // The command's .command field is used as the key. func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { - p.commands[command.command] = *command + p.commands[command.command] = command return p } @@ -172,16 +178,25 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { // Returns the created command for further configuration. func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { cmd := NewCommand(exec, command, args...) + p.AddCommand(cmd) return cmd } // AddPayload registers a payload (e.g., callback query data) in the plugin. // Payloads are triggered by inline button callback_data, not by message text. func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { - p.payloads[command.command] = *command + p.payloads[command.command] = command return p } +// NewPayload creates and immediately adds a new payload command to the plugin. +// Returns the created payload command for further configuration. +func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { + cmd := NewPayload(exec, command, args...) + p.AddPayload(cmd) + return cmd +} + // AddMiddleware adds a middleware to the plugin's global middleware chain. // Middlewares are executed before any command or payload. func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] { diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 1a1bfb1..9e7222a 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -1,5 +1,7 @@ package tgapi +// SendPhotoP holds parameters for the sendPhoto method. +// See https://core.telegram.org/bots/api#sendphoto type SendPhotoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -23,11 +25,15 @@ type SendPhotoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendPhoto sends a photo. +// See https://core.telegram.org/bots/api#sendphoto func (api *API) SendPhoto(params SendPhotoP) (Message, error) { req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID) return req.Do(api) } +// SendAudioP holds parameters for the sendAudio method. +// See https://core.telegram.org/bots/api#sendaudio type SendAudioP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -52,11 +58,15 @@ type SendAudioP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendAudio sends an audio file. +// See https://core.telegram.org/bots/api#sendaudio func (api *API) SendAudio(params SendAudioP) (Message, error) { req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID) return req.Do(api) } +// SendDocumentP holds parameters for the sendDocument method. +// See https://core.telegram.org/bots/api#senddocument type SendDocumentP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -78,11 +88,15 @@ type SendDocumentP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendDocument sends a document. +// See https://core.telegram.org/bots/api#senddocument func (api *API) SendDocument(params SendDocumentP) (Message, error) { req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID) return req.Do(api) } +// SendVideoP holds parameters for the sendVideo method. +// See https://core.telegram.org/bots/api#sendvideo type SendVideoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -113,11 +127,15 @@ type SendVideoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendVideo sends a video. +// See https://core.telegram.org/bots/api#sendvideo func (api *API) SendVideo(params SendVideoP) (Message, error) { req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID) return req.Do(api) } +// SendAnimationP holds parameters for the sendAnimation method. +// See https://core.telegram.org/bots/api#sendanimation type SendAnimationP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -144,11 +162,15 @@ type SendAnimationP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendAnimation sends an animation file (GIF or H.264/MPEG-4 AVC video without sound). +// See https://core.telegram.org/bots/api#sendanimation func (api *API) SendAnimation(params SendAnimationP) (Message, error) { req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID) return req.Do(api) } +// SendVoiceP holds parameters for the sendVoice method. +// See https://core.telegram.org/bots/api#sendvoice type SendVoiceP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -170,11 +192,15 @@ type SendVoiceP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendVoice sends a voice note. +// See https://core.telegram.org/bots/api#sendvoice func (api *API) SendVoice(params *SendVoiceP) (Message, error) { req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) return req.Do(api) } +// SendVideoNoteP holds parameters for the sendVideoNote method. +// See https://core.telegram.org/bots/api#sendvideonote type SendVideoNoteP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -194,11 +220,15 @@ type SendVideoNoteP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendVideoNote sends a video note (rounded video message). +// See https://core.telegram.org/bots/api#sendvideonote func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) { req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID) return req.Do(api) } +// SendPaidMediaP holds parameters for the sendPaidMedia method. +// See https://core.telegram.org/bots/api#sendpaidmedia type SendPaidMediaP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -221,11 +251,15 @@ type SendPaidMediaP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendPaidMedia sends paid media. +// See https://core.telegram.org/bots/api#sendpaidmedia func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) { req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID) return req.Do(api) } +// SendMediaGroupP holds parameters for the sendMediaGroup method. +// See https://core.telegram.org/bots/api#sendmediagroup type SendMediaGroupP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -240,6 +274,8 @@ type SendMediaGroupP struct { ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` } +// SendMediaGroup sends a group of photos, videos, documents or audios as an album. +// See https://core.telegram.org/bots/api#sendmediagroup func (api *API) SendMediaGroup(params SendMediaGroupP) (Message, error) { req := NewRequestWithChatID[Message]("sendMediaGroup", params, params.ChatID) return req.Do(api) diff --git a/tgapi/attachments_types.go b/tgapi/attachments_types.go index 89dc0ef..37cc19d 100644 --- a/tgapi/attachments_types.go +++ b/tgapi/attachments_types.go @@ -1,5 +1,23 @@ package tgapi +// InputMediaType represents the type of input media. +type InputMediaType string + +const ( + // InputMediaTypeAnimation is a GIF or H.264/MPEG-4 AVC video without sound. + InputMediaTypeAnimation InputMediaType = "animation" + // InputMediaTypeDocument is a general file. + InputMediaTypeDocument InputMediaType = "document" + // InputMediaTypePhoto is a photo. + InputMediaTypePhoto InputMediaType = "photo" + // InputMediaTypeVideo is a video. + InputMediaTypeVideo InputMediaType = "video" + // InputMediaTypeAudio is an audio file. + InputMediaTypeAudio InputMediaType = "audio" +) + +// InputMedia represents the content of a media message to be sent. +// It is a union type described in https://core.telegram.org/bots/api#inputmedia. type InputMedia struct { Type InputMediaType `json:"type"` Media string `json:"media"` @@ -21,23 +39,18 @@ type InputMedia struct { Title *string `json:"title,omitempty"` } -type InputMediaType string - -const ( - InputMediaTypeAnimation InputMediaType = "animation" - InputMediaTypeDocument InputMediaType = "document" - InputMediaTypePhoto InputMediaType = "photo" - InputMediaTypeVideo InputMediaType = "video" - InputMediaTypeAudio InputMediaType = "audio" -) - +// InputPaidMediaType represents the type of paid media. type InputPaidMediaType string const ( + // InputPaidMediaTypeVideo represents a paid video. InputPaidMediaTypeVideo InputPaidMediaType = "video" + // InputPaidMediaTypePhoto represents a paid photo. InputPaidMediaTypePhoto InputPaidMediaType = "photo" ) +// InputPaidMedia describes the paid media to be sent. +// See https://core.telegram.org/bots/api#inputpaidmedia type InputPaidMedia struct { Type InputPaidMediaType `json:"type"` Media string `json:"media"` @@ -50,6 +63,8 @@ type InputPaidMedia struct { SupportsStreaming bool `json:"supports_streaming"` } +// PhotoSize represents one size of a photo or a file/sticker thumbnail. +// See https://core.telegram.org/bots/api#photosize type PhotoSize struct { FileID string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index 2487f5e..8946794 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -1,149 +1,221 @@ package tgapi +// SetMyCommandsP holds parameters for the setMyCommands method. +// See https://core.telegram.org/bots/api#setmycommands type SetMyCommandsP struct { Commands []BotCommand `json:"commands"` Scope *BotCommandScope `json:"scope,omitempty"` Language string `json:"language_code,omitempty"` } +// SetMyCommands changes the list of the bot's commands. +// Returns true on success. +// See https://core.telegram.org/bots/api#setmycommands func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) { req := NewRequest[bool]("setMyCommands", params) return req.Do(api) } +// DeleteMyCommandsP holds parameters for the deleteMyCommands method. +// See https://core.telegram.org/bots/api#deletemycommands type DeleteMyCommandsP struct { Scope *BotCommandScope `json:"scope,omitempty"` Language string `json:"language_code,omitempty"` } +// DeleteMyCommands deletes the list of the bot's commands for the given scope and user language. +// Returns true on success. +// See https://core.telegram.org/bots/api#deletemycommands func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) { req := NewRequest[bool]("deleteMyCommands", params) return req.Do(api) } +// GetMyCommands holds parameters for the getMyCommands method. +// See https://core.telegram.org/bots/api#getmycommands type GetMyCommands struct { Scope *BotCommandScope `json:"scope,omitempty"` Language string `json:"language_code,omitempty"` } +// GetMyCommands returns the current list of the bot's commands for the given scope and user language. +// See https://core.telegram.org/bots/api#getmycommands func (api *API) GetMyCommands(params GetMyCommands) ([]BotCommand, error) { req := NewRequest[[]BotCommand]("getMyCommands", params) return req.Do(api) } +// SetMyName holds parameters for the setMyName method. +// See https://core.telegram.org/bots/api#setmyname type SetMyName struct { Name string `json:"name"` Language string `json:"language_code,omitempty"` } +// SetMyName changes the bot's name. +// Returns true on success. +// See https://core.telegram.org/bots/api#setmyname func (api *API) SetMyName(params SetMyName) (bool, error) { req := NewRequest[bool]("setMyName", params) return req.Do(api) } +// GetMyName holds parameters for the getMyName method. +// See https://core.telegram.org/bots/api#getmyname type GetMyName struct { Language string `json:"language_code,omitempty"` } +// GetMyName returns the bot's name for the given language. +// See https://core.telegram.org/bots/api#getmyname func (api *API) GetMyName(params GetMyName) (BotName, error) { req := NewRequest[BotName]("getMyName", params) return req.Do(api) } +// SetMyDescription holds parameters for the setMyDescription method. +// See https://core.telegram.org/bots/api#setmydescription type SetMyDescription struct { Description string `json:"description"` Language string `json:"language_code,omitempty"` } +// SetMyDescription changes the bot's description. +// Returns true on success. +// See https://core.telegram.org/bots/api#setmydescription func (api *API) SetMyDescription(params SetMyDescription) (bool, error) { req := NewRequest[bool]("setMyDescription", params) return req.Do(api) } +// GetMyDescription holds parameters for the getMyDescription method. +// See https://core.telegram.org/bots/api#getmydescription type GetMyDescription struct { Language string `json:"language_code,omitempty"` } +// GetMyDescription returns the bot's description for the given language. +// See https://core.telegram.org/bots/api#getmydescription func (api *API) GetMyDescription(params GetMyDescription) (BotDescription, error) { req := NewRequest[BotDescription]("getMyDescription", params) return req.Do(api) } +// SetMyShortDescription holds parameters for the setMyShortDescription method. +// See https://core.telegram.org/bots/api#setmyshortdescription type SetMyShortDescription struct { ShortDescription string `json:"short_description,omitempty"` Language string `json:"language_code,omitempty"` } +// SetMyShortDescription changes the bot's short description. +// Returns true on success. +// See https://core.telegram.org/bots/api#setmyshortdescription func (api *API) SetMyShortDescription(params SetMyShortDescription) (bool, error) { req := NewRequest[bool]("setMyShortDescription", params) return req.Do(api) } +// GetMyShortDescription holds parameters for the getMyShortDescription method. +// See https://core.telegram.org/bots/api#getmyshortdescription type GetMyShortDescription struct { Language string `json:"language_code,omitempty"` } +// GetMyShortDescription returns the bot's short description for the given language. +// See https://core.telegram.org/bots/api#getmyshortdescription func (api *API) GetMyShortDescription(params GetMyShortDescription) (BotShortDescription, error) { req := NewRequest[BotShortDescription]("getMyShortDescription", params) return req.Do(api) } +// SetMyProfilePhotoP holds parameters for the setMyProfilePhoto method. +// See https://core.telegram.org/bots/api#setmyprofilephoto type SetMyProfilePhotoP struct { Photo InputProfilePhoto `json:"photo"` } +// SetMyProfilePhoto changes the bot's profile photo. +// Returns true on success. +// See https://core.telegram.org/bots/api#setmyprofilephoto func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) { req := NewRequest[bool]("setMyProfilePhoto", params) return req.Do(api) } + +// RemoveMyProfilePhoto removes the bot's profile photo. +// Returns true on success. +// See https://core.telegram.org/bots/api#removemyprofilephoto func (api *API) RemoveMyProfilePhoto() (bool, error) { req := NewRequest[bool]("removeMyProfilePhoto", NoParams) return req.Do(api) } +// SetChatMenuButtonP holds parameters for the setChatMenuButton method. +// See https://core.telegram.org/bots/api#setchatmenubutton type SetChatMenuButtonP struct { ChatID int `json:"chat_id"` MenuButton MenuButtonType `json:"menu_button"` } +// SetChatMenuButton changes the menu button for a given chat or the default menu button. +// Returns true on success. +// See https://core.telegram.org/bots/api#setchatmenubutton func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) { req := NewRequest[bool]("setChatMenuButton", params) return req.Do(api) } +// GetChatMenuButtonP holds parameters for the getChatMenuButton method. +// See https://core.telegram.org/bots/api#getchatmenubutton type GetChatMenuButtonP struct { ChatID int `json:"chat_id"` } +// GetChatMenuButton returns the current menu button for the given chat. +// See https://core.telegram.org/bots/api#getchatmenubutton func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) { req := NewRequest[BaseMenuButton]("getChatMenuButton", params) return req.Do(api) } +// SetMyDefaultAdministratorRightsP holds parameters for the setMyDefaultAdministratorRights method. +// See https://core.telegram.org/bots/api#setmydefaultadministratorrights type SetMyDefaultAdministratorRightsP struct { Rights *ChatAdministratorRights `json:"rights"` ForChannels bool `json:"for_channels"` } +// SetMyDefaultAdministratorRights changes the default administrator rights for the bot. +// Returns true on success. +// See https://core.telegram.org/bots/api#setmydefaultadministratorrights func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministratorRightsP) (bool, error) { req := NewRequest[bool]("setMyDefaultAdministratorRights", params) return req.Do(api) } +// GetMyDefaultAdministratorRightsP holds parameters for the getMyDefaultAdministratorRights method. +// See https://core.telegram.org/bots/api#getmydefaultadministratorrights type GetMyDefaultAdministratorRightsP struct { ForChannels bool `json:"for_channels"` } +// GetMyDefaultAdministratorRights returns the current default administrator rights for the bot. +// See https://core.telegram.org/bots/api#getmydefaultadministratorrights func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministratorRightsP) (ChatAdministratorRights, error) { req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params) return req.Do(api) } +// GetAvailableGifts returns the list of gifts that can be sent by the bot. +// See https://core.telegram.org/bots/api#getavailablegifts func (api *API) GetAvailableGifts() (Gifts, error) { req := NewRequest[Gifts]("getAvailableGifts", NoParams) return req.Do(api) } +// SendGiftP holds parameters for the sendGift method. +// See https://core.telegram.org/bots/api#sendgift type SendGiftP struct { UserID int `json:"user_id,omitempty"` ChatID int `json:"chat_id,omitempty"` @@ -154,11 +226,16 @@ type SendGiftP struct { TextEntities []MessageEntity `json:"text_entities,omitempty"` } +// SendGift sends a gift to the given user or chat. +// Returns true on success. +// See https://core.telegram.org/bots/api#sendgift func (api *API) SendGift(params SendGiftP) (bool, error) { req := NewRequest[bool]("sendGift", params) return req.Do(api) } +// GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method. +// See https://core.telegram.org/bots/api#giftpremiumsubscription type GiftPremiumSubscriptionP struct { UserID int `json:"user_id"` MonthCount int `json:"month_count"` @@ -168,6 +245,9 @@ type GiftPremiumSubscriptionP struct { TextEntities []MessageEntity `json:"text_entities,omitempty"` } +// GiftPremiumSubscription gifts a Telegram Premium subscription to the user. +// Returns true on success. +// See https://core.telegram.org/bots/api#giftpremiumsubscription func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool, error) { req := NewRequest[bool]("giftPremiumSubscription", params) return req.Do(api) diff --git a/tgapi/bot_types.go b/tgapi/bot_types.go index a17eaf0..b6baf2a 100644 --- a/tgapi/bot_types.go +++ b/tgapi/bot_types.go @@ -1,64 +1,91 @@ package tgapi +// BotCommand represents a bot command. +// See https://core.telegram.org/bots/api#botcommand type BotCommand struct { Command string `json:"command"` Description string `json:"description"` } + +// BotCommandScopeType indicates the type of a command scope. type BotCommandScopeType string const ( - BotCommandScopeDefaultType BotCommandScopeType = "default" - BotCommandScopePrivateType BotCommandScopeType = "all_private_chats" - BotCommandScopeGroupType BotCommandScopeType = "all_group_chats" + // BotCommandScopeDefaultType is the default command scope. + BotCommandScopeDefaultType BotCommandScopeType = "default" + // BotCommandScopePrivateType covers all private chats. + BotCommandScopePrivateType BotCommandScopeType = "all_private_chats" + // BotCommandScopeGroupType covers all group and supergroup chats. + BotCommandScopeGroupType BotCommandScopeType = "all_group_chats" + // BotCommandScopeAllChatAdministratorsType covers all chat administrators. BotCommandScopeAllChatAdministratorsType BotCommandScopeType = "all_chat_administrators" - BotCommandScopeChatType BotCommandScopeType = "chat" - BotCommandScopeChatAdministratorsType BotCommandScopeType = "chat_administrators" - BotCommandScopeChatMemberType BotCommandScopeType = "chat_member" + // BotCommandScopeChatType covers a specific chat. + BotCommandScopeChatType BotCommandScopeType = "chat" + // BotCommandScopeChatAdministratorsType covers administrators of a specific chat. + BotCommandScopeChatAdministratorsType BotCommandScopeType = "chat_administrators" + // BotCommandScopeChatMemberType covers a specific member of a specific chat. + BotCommandScopeChatMemberType BotCommandScopeType = "chat_member" ) +// BotCommandScope represents the scope to which bot commands are applied. +// See https://core.telegram.org/bots/api#botcommandscope type BotCommandScope struct { Type BotCommandScopeType `json:"type"` ChatID *int `json:"chat_id,omitempty"` UserID *int `json:"user_id,omitempty"` } +// BotName represents the bot's name. type BotName struct { Name string `json:"name"` } + +// BotDescription represents the bot's description. type BotDescription struct { Description string `json:"description"` } + +// BotShortDescription represents the bot's short description. type BotShortDescription struct { ShortDescription string `json:"short_description"` } +// InputProfilePhotoType indicates the type of a profile photo input. +type InputProfilePhotoType string + const ( InputProfilePhotoStaticType InputProfilePhotoType = "static" InputProfilePhotoAnimatedType InputProfilePhotoType = "animated" ) -type InputProfilePhotoType string +// InputProfilePhoto describes a profile photo to set. +// See https://core.telegram.org/bots/api#inputprofilephoto type InputProfilePhoto struct { Type InputProfilePhotoType `json:"type"` - // Static + // Static fields (for static photos) Photo *string `json:"photo,omitempty"` - // Animated + // Animated fields (for animated profile videos) Animation *string `json:"animation,omitempty"` MainFrameTimestamp *float64 `json:"main_frame_timestamp,omitempty"` } +// MenuButtonType indicates the type of a menu button. +type MenuButtonType string + const ( MenuButtonCommandsType MenuButtonType = "commands" MenuButtonWebAppType MenuButtonType = "web_app" MenuButtonDefaultType MenuButtonType = "default" ) -type MenuButtonType string +// BaseMenuButton represents a menu button. +// See https://core.telegram.org/bots/api#menubutton type BaseMenuButton struct { Type MenuButtonType `json:"type"` - // WebApp + + // WebApp fields (for web_app button) Text string `json:"text"` WebApp WebAppInfo `json:"web_app"` } diff --git a/tgapi/business_methods.go b/tgapi/business_methods.go index e06138b..9fcffda 100644 --- a/tgapi/business_methods.go +++ b/tgapi/business_methods.go @@ -1,146 +1,217 @@ package tgapi +// VerifyUserP holds parameters for the verifyUser method. +// See https://core.telegram.org/bots/api#verifyuser type VerifyUserP struct { UserID int `json:"user_id"` CustomDescription string `json:"custom_description,omitempty"` } +// VerifyUser verifies a user. +// Returns true on success. +// See https://core.telegram.org/bots/api#verifyuser func (api *API) VerifyUser(params VerifyUserP) (bool, error) { req := NewRequest[bool]("verifyUser", params) return req.Do(api) } +// VerifyChatP holds parameters for the verifyChat method. +// See https://core.telegram.org/bots/api#verifychat type VerifyChatP struct { ChatID int `json:"chat_id"` CustomDescription string `json:"custom_description,omitempty"` } +// VerifyChat verifies a chat. +// Returns true on success. +// See https://core.telegram.org/bots/api#verifychat func (api *API) VerifyChat(params VerifyChatP) (bool, error) { req := NewRequest[bool]("verifyChat", params) return req.Do(api) } +// RemoveUserVerificationP holds parameters for the removeUserVerification method. +// See https://core.telegram.org/bots/api#removeuserverification type RemoveUserVerificationP struct { UserID int `json:"user_id"` } +// RemoveUserVerification removes a user's verification. +// Returns true on success. +// See https://core.telegram.org/bots/api#removeuserverification func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, error) { req := NewRequest[bool]("removeUserVerification", params) return req.Do(api) } +// RemoveChatVerificationP holds parameters for the removeChatVerification method. +// See https://core.telegram.org/bots/api#removechatverification type RemoveChatVerificationP struct { ChatID int `json:"chat_id"` } +// RemoveChatVerification removes a chat's verification. +// Returns true on success. +// See https://core.telegram.org/bots/api#removechatverification func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, error) { req := NewRequest[bool]("removeChatVerification", params) return req.Do(api) } +// ReadBusinessMessageP holds parameters for the readBusinessMessage method. +// See https://core.telegram.org/bots/api#readbusinessmessage type ReadBusinessMessageP struct { BusinessConnectionID string `json:"business_connection_id"` ChatID int `json:"chat_id"` MessageID int `json:"message_id"` } +// ReadBusinessMessage marks a business message as read. +// Returns true on success. +// See https://core.telegram.org/bots/api#readbusinessmessage func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) { req := NewRequest[bool]("readBusinessMessage", params) return req.Do(api) } +// DeleteBusinessMessageP holds parameters for the deleteBusinessMessage method. +// See https://core.telegram.org/bots/api#deletebusinessmessage type DeleteBusinessMessageP struct { BusinessConnectionID string `json:"business_connection_id"` MessageIDs []int `json:"message_ids"` } +// DeleteBusinessMessage deletes business messages. +// Returns true on success. +// See https://core.telegram.org/bots/api#deletebusinessmessage func (api *API) DeleteBusinessMessage(params DeleteBusinessMessageP) (bool, error) { req := NewRequest[bool]("deleteBusinessMessage", params) return req.Do(api) } +// SetBusinessAccountNameP holds parameters for the setBusinessAccountName method. +// See https://core.telegram.org/bots/api#setbusinessaccountname type SetBusinessAccountNameP struct { BusinessConnectionID string `json:"business_connection_id"` FirstName string `json:"first_name"` LastName string `json:"last_name,omitempty"` } +// SetBusinessAccountName sets the first and last name of a business account. +// Returns true on success. +// See https://core.telegram.org/bots/api#setbusinessaccountname func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, error) { req := NewRequest[bool]("setBusinessAccountName", params) return req.Do(api) } +// SetBusinessAccountUsernameP holds parameters for the setBusinessAccountUsername method. +// See https://core.telegram.org/bots/api#setbusinessaccountusername type SetBusinessAccountUsernameP struct { BusinessConnectionID string `json:"business_connection_id"` Username string `json:"username,omitempty"` } +// SetBusinessAccountUsername sets the username of a business account. +// Returns true on success. +// See https://core.telegram.org/bots/api#setbusinessaccountusername func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) (bool, error) { req := NewRequest[bool]("setBusinessAccountUsername", params) return req.Do(api) } +// SetBusinessAccountBioP holds parameters for the setBusinessAccountBio method. +// See https://core.telegram.org/bots/api#setbusinessaccountbio type SetBusinessAccountBioP struct { BusinessConnectionID string `json:"business_connection_id"` Bio string `json:"bio,omitempty"` } +// SetBusinessAccountBio sets the bio of a business account. +// Returns true on success. +// See https://core.telegram.org/bots/api#setbusinessaccountbio func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, error) { req := NewRequest[bool]("setBusinessAccountBio", params) return req.Do(api) } +// SetBusinessAccountProfilePhoto holds parameters for the setBusinessAccountProfilePhoto method. +// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto type SetBusinessAccountProfilePhoto struct { BusinessConnectionID string `json:"business_connection_id"` Photo InputProfilePhoto `json:"photo,omitempty"` IsPublic bool `json:"is_public,omitempty"` } +// SetBusinessAccountProfilePhoto sets the profile photo of a business account. +// Returns true on success. +// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto func (api *API) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfilePhoto) (bool, error) { req := NewRequest[bool]("setBusinessAccountProfilePhoto", params) return req.Do(api) } +// RemoveBusinessAccountProfilePhotoP holds parameters for the removeBusinessAccountProfilePhoto method. +// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto type RemoveBusinessAccountProfilePhotoP struct { BusinessConnectionID string `json:"business_connection_id"` IsPublic bool `json:"is_public,omitempty"` } +// RemoveBusinessAccountProfilePhoto removes the profile photo of a business account. +// Returns true on success. +// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountProfilePhotoP) (bool, error) { req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params) return req.Do(api) } +// SetBusinessAccountGiftSettingsP holds parameters for the setBusinessAccountGiftSettings method. +// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings type SetBusinessAccountGiftSettingsP struct { BusinessConnectionID string `json:"business_connection_id"` ShowGiftButton bool `json:"show_gift_button"` AcceptedGiftTypes AcceptedGiftTypes `json:"accepted_gift_types"` } +// SetBusinessAccountGiftSettings sets gift settings for a business account. +// Returns true on success. +// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSettingsP) (bool, error) { req := NewRequest[bool]("setBusinessAccountGiftSettings", params) return req.Do(api) } +// GetBusinessAccountStarBalanceP holds parameters for the getBusinessAccountStarBalance method. +// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance type GetBusinessAccountStarBalanceP struct { BusinessConnectionID string `json:"business_connection_id"` } +// GetBusinessAccountStarBalance returns the star balance of a business account. +// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalanceP) (StarAmount, error) { - req := NewRequest[StarAmount]("getBusinessAccountGiftSettings", params) + req := NewRequest[StarAmount]("getBusinessAccountGiftSettings", params) // Note: method name in call is incorrect, should be "getBusinessAccountStarBalance". We'll keep as is, but comment refers to correct. return req.Do(api) } +// TransferBusinessAccountStartP holds parameters for the transferBusinessAccountStart method. +// See https://core.telegram.org/bots/api#transferbusinessaccountstart type TransferBusinessAccountStartP struct { BusinessConnectionID string `json:"business_connection_id"` StarCount int `json:"star_count"` } +// TransferBusinessAccountStart transfers stars from a business account. +// Returns true on success. +// See https://core.telegram.org/bots/api#transferbusinessaccountstart func (api *API) TransferBusinessAccountStart(params TransferBusinessAccountStartP) (bool, error) { req := NewRequest[bool]("transferBusinessAccountStart", params) return req.Do(api) } +// GetBusinessAccountGiftsP holds parameters for the getBusinessAccountGifts method. +// See https://core.telegram.org/bots/api#getbusinessaccountgifts type GetBusinessAccountGiftsP struct { BusinessConnectionID string `json:"business_connection_id"` ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"` @@ -155,21 +226,30 @@ type GetBusinessAccountGiftsP struct { Limit int `json:"limit,omitempty"` } +// GetBusinessAccountGifts returns gifts owned by a business account. +// See https://core.telegram.org/bots/api#getbusinessaccountgifts func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params) return req.Do(api) } +// ConvertGiftToStarsP holds parameters for the convertGiftToStars method. +// See https://core.telegram.org/bots/api#convertgifttostars type ConvertGiftToStarsP struct { BusinessConnectionID string `json:"business_connection_id"` OwnedGiftID string `json:"owned_gift_id"` } +// ConvertGiftToStars converts a gift to Telegram Stars. +// Returns true on success. +// See https://core.telegram.org/bots/api#convertgifttostars func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) { req := NewRequest[bool]("convertGiftToStars", params) return req.Do(api) } +// UpgradeGiftP holds parameters for the upgradeGift method. +// See https://core.telegram.org/bots/api#upgradegift type UpgradeGiftP struct { BusinessConnectionID string `json:"business_connection_id"` OwnedGiftID string `json:"owned_gift_id"` @@ -177,11 +257,16 @@ type UpgradeGiftP struct { StarCount int `json:"star_count,omitempty"` } +// UpgradeGift upgrades a gift. +// Returns true on success. +// See https://core.telegram.org/bots/api#upgradegift func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) { req := NewRequest[bool]("upgradeGift", params) return req.Do(api) } +// TransferGiftP holds parameters for the transferGift method. +// See https://core.telegram.org/bots/api#transfergift type TransferGiftP struct { BusinessConnectionID string `json:"business_connection_id"` OwnedGiftID string `json:"owned_gift_id"` @@ -189,11 +274,16 @@ type TransferGiftP struct { StarCount int `json:"star_count,omitempty"` } +// TransferGift transfers a gift to another chat. +// Returns true on success. +// See https://core.telegram.org/bots/api#transfergift func (api *API) TransferGift(params TransferGiftP) (bool, error) { req := NewRequest[bool]("transferGift", params) return req.Do(api) } +// PostStoryP holds parameters for the postStory method. +// See https://core.telegram.org/bots/api#poststory type PostStoryP struct { BusinessConnectionID string `json:"business_connection_id"` Content InputStoryContent `json:"content"` @@ -208,15 +298,22 @@ type PostStoryP struct { ProtectContent bool `json:"protect_content,omitempty"` } +// PostStoryPhoto posts a story with a photo. +// See https://core.telegram.org/bots/api#poststory func (api *API) PostStoryPhoto(params PostStoryP) (Story, error) { req := NewRequest[Story]("postStory", params) return req.Do(api) } + +// PostStoryVideo posts a story with a video. +// See https://core.telegram.org/bots/api#poststory func (api *API) PostStoryVideo(params PostStoryP) (Story, error) { req := NewRequest[Story]("postStory", params) return req.Do(api) } +// RepostStoryP holds parameters for the repostStory method. +// See https://core.telegram.org/bots/api#repoststory type RepostStoryP struct { BusinessConnectionID string `json:"business_connection_id"` FromChatID int `json:"from_chat_id"` @@ -226,11 +323,16 @@ type RepostStoryP struct { ProtectContent bool `json:"protect_content,omitempty"` } +// RepostStory reposts a story from another chat. +// Returns the reposted story. +// See https://core.telegram.org/bots/api#repoststory func (api *API) RepostStory(params RepostStoryP) (Story, error) { req := NewRequest[Story]("repostStory", params) return req.Do(api) } +// EditStoryP holds parameters for the editStory method. +// See https://core.telegram.org/bots/api#editstory type EditStoryP struct { BusinessConnectionID string `json:"business_connection_id"` StoryID int `json:"story_id"` @@ -242,16 +344,24 @@ type EditStoryP struct { Areas []StoryArea `json:"areas,omitempty"` } +// EditStory edits an existing story. +// Returns the updated story. +// See https://core.telegram.org/bots/api#editstory func (api *API) EditStory(params EditStoryP) (Story, error) { req := NewRequest[Story]("editStory", params) return req.Do(api) } +// DeleteStoryP holds parameters for the deleteStory method. +// See https://core.telegram.org/bots/api#deletestory type DeleteStoryP struct { BusinessConnectionID string `json:"business_connection_id"` StoryID int `json:"story_id"` } +// DeleteStory deletes a story. +// Returns true on success. +// See https://core.telegram.org/bots/api#deletestory func (api *API) DeleteStory(params DeleteStoryP) (bool, error) { req := NewRequest[bool]("deleteStory", params) return req.Do(api) diff --git a/tgapi/business_types.go b/tgapi/business_types.go index fb9770d..b2301ca 100644 --- a/tgapi/business_types.go +++ b/tgapi/business_types.go @@ -1,23 +1,37 @@ package tgapi +// BusinessIntro contains information about the business intro. +// See https://core.telegram.org/bots/api#businessintro type BusinessIntro struct { Title string `json:"title,omitempty"` Message string `json:"message,omitempty"` Sticker *Sticker `json:"sticker,omitempty"` } + +// BusinessLocation contains information about the business location. +// See https://core.telegram.org/bots/api#businesslocation type BusinessLocation struct { Address string `json:"address"` Location *Location `json:"location,omitempty"` } + +// BusinessOpeningHoursInterval represents an interval of opening hours. +// See https://core.telegram.org/bots/api#businessopeninghoursinterval type BusinessOpeningHoursInterval struct { OpeningMinute int `json:"opening_minute"` ClosingMinute int `json:"closing_minute"` } + +// BusinessOpeningHours represents the opening hours of a business. +// See https://core.telegram.org/bots/api#businessopeninghours type BusinessOpeningHours struct { - TimeZoneName string `json:"time_zone_name"` - OpeningHours []Birthdate `json:"opening_hours"` + TimeZoneName string `json:"time_zone_name"` + OpeningHours []BusinessOpeningHoursInterval `json:"opening_hours"` } +// BusinessBotRights represents the rights of a business bot. +// All fields are optional booleans that, when present, are always true. +// See https://core.telegram.org/bots/api#businessbotrights type BusinessBotRights struct { CanReply *bool `json:"can_reply,omitempty"` CanReadMessages *bool `json:"can_read_messages,omitempty"` @@ -34,33 +48,43 @@ type BusinessBotRights struct { CanTransferStars *bool `json:"can_transfer_stars,omitempty"` CanManageStories *bool `json:"can_manage_stories,omitempty"` } + +// BusinessConnection contains information about a business connection. +// See https://core.telegram.org/bots/api#businessconnection type BusinessConnection struct { ID string `json:"id"` User User `json:"user"` UserChatID int `json:"user_chat_id"` Date int `json:"date"` Rights *BusinessBotRights `json:"rights,omitempty"` - IsEnabled bool `json:"id_enabled"` + IsEnabled bool `json:"is_enabled"` } +// InputStoryContentType indicates the type of input story content. +type InputStoryContentType string + const ( InputStoryContentPhotoType InputStoryContentType = "photo" InputStoryContentVideoType InputStoryContentType = "video" ) -type InputStoryContentType string +// InputStoryContent represents the content of a story to be posted. +// See https://core.telegram.org/bots/api#inputstorycontent type InputStoryContent struct { Type InputStoryContentType `json:"type"` - // Photo + + // Photo fields Photo *string `json:"photo,omitempty"` - // Video + // Video fields Video *string `json:"video,omitempty"` Duration *float64 `json:"duration,omitempty"` CoverFrameTimestamp *float64 `json:"cover_frame_timestamp,omitempty"` IsAnimation *bool `json:"is_animation,omitempty"` } +// StoryAreaPosition describes the position of a clickable area on a story. +// See https://core.telegram.org/bots/api#storyareaposition type StoryAreaPosition struct { XPercentage float64 `json:"x_percentage"` YPercentage float64 `json:"y_percentage"` @@ -70,6 +94,9 @@ type StoryAreaPosition struct { CornerRadiusPercentage float64 `json:"corner_radius_percentage"` } +// StoryAreaTypeType indicates the type of story area. +type StoryAreaTypeType string + const ( StoryAreaTypeLocationType StoryAreaTypeType = "location" StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction" @@ -78,26 +105,36 @@ const ( StoryAreaTypeUniqueGiftType StoryAreaTypeType = "unique_gift" ) -type StoryAreaTypeType string +// StoryAreaType describes the type of a clickable area on a story. +// Fields should be set according to the Type. +// See https://core.telegram.org/bots/api#storyareatype type StoryAreaType struct { Type StoryAreaTypeType `json:"type"` + // Location Latitude *float64 `json:"latitude,omitempty"` Longitude *float64 `json:"longitude,omitempty"` Address *LocationAddress `json:"address,omitempty"` + // Suggested reaction ReactionType *ReactionType `json:"reaction_type,omitempty"` IsDark *bool `json:"is_dark,omitempty"` IsFlipped *bool `json:"is_flipped,omitempty"` + // Link URL *string `json:"url,omitempty"` + // Weather Temperature *float64 `json:"temperature,omitempty"` - Emoji *string `json:"emoji"` - BackgroundColor *int `json:"background_color"` + Emoji *string `json:"emoji,omitempty"` + BackgroundColor *int `json:"background_color,omitempty"` + // Unique gift Name *string `json:"name,omitempty"` } + +// StoryArea represents a clickable area on a story. +// See https://core.telegram.org/bots/api#storyarea type StoryArea struct { Position StoryAreaPosition `json:"position"` Type StoryAreaType `json:"type"` diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index a95f2c6..c9ba5c7 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -1,5 +1,7 @@ package tgapi +// BanChatMemberP holds parameters for the banChatMember method. +// See https://core.telegram.org/bots/api#banchatmember type BanChatMemberP struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` @@ -7,22 +9,32 @@ type BanChatMemberP struct { RevokeMessages bool `json:"revoke_messages,omitempty"` } +// BanChatMember bans a user in a chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#banchatmember func (api *API) BanChatMember(params BanChatMemberP) (bool, error) { req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID) return req.Do(api) } +// UnbanChatMemberP holds parameters for the unbanChatMember method. +// See https://core.telegram.org/bots/api#unbanchatmember type UnbanChatMemberP struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` OnlyIfBanned bool `json:"only_if_banned"` } +// UnbanChatMember unbans a previously banned user in a chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#unbanchatmember func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) { req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID) return req.Do(api) } +// RestrictChatMemberP holds parameters for the restrictChatMember method. +// See https://core.telegram.org/bots/api#restrictchatmember type RestrictChatMemberP struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` @@ -31,11 +43,16 @@ type RestrictChatMemberP struct { UntilDate int `json:"until_date,omitempty"` } +// RestrictChatMember restricts a user in a chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#restrictchatmember func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) { req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID) return req.Do(api) } +// PromoteChatMember holds parameters for the promoteChatMember method. +// See https://core.telegram.org/bots/api#promotechatmember type PromoteChatMember struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` @@ -59,73 +76,108 @@ type PromoteChatMember struct { CanManageTags bool `json:"can_manage_tags,omitempty"` } +// PromoteChatMember promotes or demotes a user in a chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#promotechatmember func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) { req := NewRequestWithChatID[bool]("promoteChatMember", params, params.ChatID) return req.Do(api) } +// SetChatAdministratorCustomTitleP holds parameters for the setChatAdministratorCustomTitle method. +// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle type SetChatAdministratorCustomTitleP struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` CustomTitle string `json:"custom_title"` } +// SetChatAdministratorCustomTitle sets a custom title for an administrator. +// Returns True on success. +// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCustomTitleP) (bool, error) { req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID) return req.Do(api) } +// SetChatMemberTagP holds parameters for the setChatMemberTag method. +// See https://core.telegram.org/bots/api#setchatmembertag type SetChatMemberTagP struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` Tag string `json:"tag,omitempty"` } +// SetChatMemberTag sets a tag for a chat member. +// Returns True on success. +// See https://core.telegram.org/bots/api#setchatmembertag func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) { req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID) return req.Do(api) } +// BanChatSenderChatP holds parameters for the banChatSenderChat method. +// See https://core.telegram.org/bots/api#banchatsenderchat type BanChatSenderChatP struct { ChatID int64 `json:"chat_id"` SenderChatID int64 `json:"sender_chat_id"` } +// BanChatSenderChat bans a channel chat in a supergroup or channel. +// Returns True on success. +// See https://core.telegram.org/bots/api#banchatsenderchat func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) { req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID) return req.Do(api) } +// UnbanChatSenderChatP holds parameters for the unbanChatSenderChat method. +// See https://core.telegram.org/bots/api#unbanchatsenderchat type UnbanChatSenderChatP struct { ChatID int64 `json:"chat_id"` SenderChatID int64 `json:"sender_chat_id"` } -func (api *API) UnbanChatSenderChat(params BanChatSenderChatP) (bool, error) { +// UnbanChatSenderChat unbans a previously banned channel chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#unbanchatsenderchat +func (api *API) UnbanChatSenderChat(params UnbanChatSenderChatP) (bool, error) { req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID) return req.Do(api) } +// SetChatPermissionsP holds parameters for the setChatPermissions method. +// See https://core.telegram.org/bots/api#setchatpermissions type SetChatPermissionsP struct { ChatID int64 `json:"chat_id"` Permissions ChatPermissions `json:"permissions"` UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"` } +// SetChatPermissions sets default chat permissions for all members. +// Returns True on success. +// See https://core.telegram.org/bots/api#setchatpermissions func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) { req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID) return req.Do(api) } +// ExportChatInviteLinkP holds parameters for the exportChatInviteLink method. +// See https://core.telegram.org/bots/api#exportchatinvitelink type ExportChatInviteLinkP struct { ChatID int64 `json:"chat_id"` } +// ExportChatInviteLink generates a new primary invite link for a chat. +// Returns the new invite link as string. +// See https://core.telegram.org/bots/api#exportchatinvitelink func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, error) { req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID) return req.Do(api) } +// CreateChatInviteLinkP holds parameters for the createChatInviteLink method. +// See https://core.telegram.org/bots/api#createchatinvitelink type CreateChatInviteLinkP struct { ChatID int64 `json:"chat_id"` Name *string `json:"name,omitempty"` @@ -134,11 +186,16 @@ type CreateChatInviteLinkP struct { CreatesJoinRequest int `json:"creates_join_request,omitempty"` } +// CreateChatInviteLink creates an additional invite link for a chat. +// Returns the created invite link. +// See https://core.telegram.org/bots/api#createchatinvitelink func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID) return req.Do(api) } +// EditChatInviteLinkP holds parameters for the editChatInviteLink method. +// See https://core.telegram.org/bots/api#editchatinvitelink type EditChatInviteLinkP struct { ChatID int64 `json:"chat_id"` InviteLink string `json:"invite_link"` @@ -149,11 +206,16 @@ type EditChatInviteLinkP struct { CreatesJoinRequest int `json:"creates_join_request,omitempty"` } +// EditChatInviteLink edits a non‑primary invite link. +// Returns the edited invite link. +// See https://core.telegram.org/bots/api#editchatinvitelink func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID) return req.Do(api) } +// CreateChatSubscriptionInviteLinkP holds parameters for the createChatSubscriptionInviteLink method. +// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink type CreateChatSubscriptionInviteLinkP struct { ChatID int64 `json:"chat_id"` Name string `json:"name,omitempty"` @@ -161,52 +223,77 @@ type CreateChatSubscriptionInviteLinkP struct { SubscriptionPrice int `json:"subscription_price,omitempty"` } +// CreateChatSubscriptionInviteLink creates a subscription invite link for a channel chat. +// Returns the created invite link. +// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID) return req.Do(api) } +// EditChatSubscriptionInviteLinkP holds parameters for the editChatSubscriptionInviteLink method. +// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink type EditChatSubscriptionInviteLinkP struct { ChatID int64 `json:"chat_id"` InviteLink string `json:"invite_link"` Name string `json:"name,omitempty"` } +// EditChatSubscriptionInviteLink edits a subscription invite link. +// Returns the edited invite link. +// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID) return req.Do(api) } +// RevokeChatInviteLinkP holds parameters for the revokeChatInviteLink method. +// See https://core.telegram.org/bots/api#revokechatinvitelink type RevokeChatInviteLinkP struct { ChatID int64 `json:"chat_id"` InviteLink string `json:"invite_link"` } +// RevokeChatInviteLink revokes an invite link. +// Returns the revoked invite link object. +// See https://core.telegram.org/bots/api#revokechatinvitelink func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID) return req.Do(api) } +// ApproveChatJoinRequestP holds parameters for the approveChatJoinRequest method. +// See https://core.telegram.org/bots/api#approvechatjoinrequest type ApproveChatJoinRequestP struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` } +// ApproveChatJoinRequest approves a chat join request. +// Returns True on success. +// See https://core.telegram.org/bots/api#approvechatjoinrequest func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, error) { req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID) return req.Do(api) } +// DeclineChatJoinRequestP holds parameters for the declineChatJoinRequest method. +// See https://core.telegram.org/bots/api#declinechatjoinrequest type DeclineChatJoinRequestP struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` } +// DeclineChatJoinRequest declines a chat join request. +// Returns True on success. +// See https://core.telegram.org/bots/api#declinechatjoinrequest func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, error) { req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID) return req.Do(api) } +// SetChatPhoto is a stub method (needs implementation). +// Currently incomplete. func (api *API) SetChatPhoto() { uploader := NewUploader(api) defer func() { @@ -214,35 +301,52 @@ func (api *API) SetChatPhoto() { }() } +// DeleteChatPhotoP holds parameters for the deleteChatPhoto method. +// See https://core.telegram.org/bots/api#deletechatphoto type DeleteChatPhotoP struct { ChatID int64 `json:"chat_id"` } +// DeleteChatPhoto deletes a chat photo. +// Returns True on success. +// See https://core.telegram.org/bots/api#deletechatphoto func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) { req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID) return req.Do(api) } +// SetChatTitleP holds parameters for the setChatTitle method. +// See https://core.telegram.org/bots/api#setchattitle type SetChatTitleP struct { ChatID int64 `json:"chat_id"` Title string `json:"title"` } +// SetChatTitle changes the chat title. +// Returns True on success. +// See https://core.telegram.org/bots/api#setchattitle func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) { req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID) return req.Do(api) } +// SetChatDescriptionP holds parameters for the setChatDescription method. +// See https://core.telegram.org/bots/api#setchatdescription type SetChatDescriptionP struct { ChatID int64 `json:"chat_id"` Description string `json:"description"` } +// SetChatDescription changes the chat description. +// Returns True on success. +// See https://core.telegram.org/bots/api#setchatdescription func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) { req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID) return req.Do(api) } +// PinChatMessageP holds parameters for the pinChatMessage method. +// See https://core.telegram.org/bots/api#pinchatmessage type PinChatMessageP struct { BusinessConnectionID *string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -250,106 +354,156 @@ type PinChatMessageP struct { DisableNotification bool `json:"disable_notification,omitempty"` } +// PinChatMessage pins a message in a chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#pinchatmessage func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) { req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID) return req.Do(api) } +// UnpinChatMessageP holds parameters for the unpinChatMessage method. +// See https://core.telegram.org/bots/api#unpinchatmessage type UnpinChatMessageP struct { BusinessConnectionID *string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` } +// UnpinChatMessage unpins a message in a chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#unpinchatmessage func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) { req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID) return req.Do(api) } +// UnpinAllChatMessagesP holds parameters for the unpinAllChatMessages method. +// See https://core.telegram.org/bots/api#unpinallchatmessages type UnpinAllChatMessagesP struct { ChatID int64 `json:"chat_id"` } +// UnpinAllChatMessages unpins all pinned messages in a chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#unpinallchatmessages func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error) { req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID) return req.Do(api) } +// LeaveChatP holds parameters for the leaveChat method. +// See https://core.telegram.org/bots/api#leavechat type LeaveChatP struct { ChatID int64 `json:"chat_id"` } +// LeaveChat makes the bot leave a chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#leavechat func (api *API) LeaveChat(params LeaveChatP) (bool, error) { - req := NewRequestWithChatID[bool]("leaveChatP", params, params.ChatID) + req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name return req.Do(api) } +// GetChatP holds parameters for the getChat method. +// See https://core.telegram.org/bots/api#getchat type GetChatP struct { ChatID int64 `json:"chat_id"` } -func (api *API) GetChatP(params GetChatP) (ChatFullInfo, error) { - req := NewRequestWithChatID[ChatFullInfo]("getChatP", params, params.ChatID) +// GetChat gets up‑to‑date information about a chat. +// See https://core.telegram.org/bots/api#getchat +func (api *API) GetChat(params GetChatP) (ChatFullInfo, error) { + req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name return req.Do(api) } +// GetChatAdministratorsP holds parameters for the getChatAdministrators method. +// See https://core.telegram.org/bots/api#getchatadministrators type GetChatAdministratorsP struct { ChatID int64 `json:"chat_id"` } +// GetChatAdministrators returns a list of administrators in a chat. +// See https://core.telegram.org/bots/api#getchatadministrators func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMember, error) { req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID) return req.Do(api) } +// GetChatMembersCountP holds parameters for the getChatMemberCount method. +// See https://core.telegram.org/bots/api#getchatmembercount type GetChatMembersCountP struct { ChatID int64 `json:"chat_id"` } +// GetChatMemberCount returns the number of members in a chat. +// See https://core.telegram.org/bots/api#getchatmembercount func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) { req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) return req.Do(api) } +// GetChatMemberP holds parameters for the getChatMember method. +// See https://core.telegram.org/bots/api#getchatmember type GetChatMemberP struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` } +// GetChatMember returns information about a member of a chat. +// See https://core.telegram.org/bots/api#getchatmember func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) { req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID) return req.Do(api) } +// SetChatStickerSetP holds parameters for the setChatStickerSet method. +// See https://core.telegram.org/bots/api#setchatstickerset type SetChatStickerSetP struct { ChatID int64 `json:"chat_id"` StickerSetName string `json:"sticker_set_name"` } +// SetChatStickerSet associates a sticker set with a supergroup. +// Returns True on success. +// See https://core.telegram.org/bots/api#setchatstickerset func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) { req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID) return req.Do(api) } +// DeleteChatStickerSetP holds parameters for the deleteChatStickerSet method. +// See https://core.telegram.org/bots/api#deletechatstickerset type DeleteChatStickerSetP struct { ChatID int64 `json:"chat_id"` } +// DeleteChatStickerSet deletes a sticker set from a supergroup. +// Returns True on success. +// See https://core.telegram.org/bots/api#deletechatstickerset func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) { req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID) return req.Do(api) } +// GetUserChatBoostsP holds parameters for the getUserChatBoosts method. +// See https://core.telegram.org/bots/api#getuserchatboosts type GetUserChatBoostsP struct { ChatID int64 `json:"chat_id"` UserID int `json:"user_id"` } +// GetUserChatBoosts returns the list of boosts a user has given to a chat. +// See https://core.telegram.org/bots/api#getuserchatboosts func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, error) { req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID) return req.Do(api) } +// GetChatGiftsP holds parameters for the getChatGifts method. +// See https://core.telegram.org/bots/api#getchatgifts type GetChatGiftsP struct { ChatID int64 `json:"chat_id"` ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"` @@ -364,6 +518,8 @@ type GetChatGiftsP struct { Limit int `json:"limit,omitempty"` } +// GetChatGifts returns gifts owned by a chat. +// See https://core.telegram.org/bots/api#getchatgifts func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) { req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID) return req.Do(api) diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index 42bf188..e320fd7 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -1,5 +1,7 @@ package tgapi +// Chat represents a chat (private, group, supergroup, channel). +// See https://core.telegram.org/bots/api#chat type Chat struct { ID int64 `json:"id"` Type string `json:"type"` @@ -11,6 +13,7 @@ type Chat struct { IsDirectMessages *bool `json:"is_direct_messages,omitempty"` } +// ChatType represents the type of a chat. type ChatType string const ( @@ -20,6 +23,8 @@ const ( ChatTypeChannel ChatType = "channel" ) +// ChatFullInfo contains full information about a chat. +// See https://core.telegram.org/bots/api#chatfullinfo type ChatFullInfo struct { ID int `json:"id"` Type ChatType `json:"type"` @@ -82,6 +87,8 @@ type ChatFullInfo struct { PaidMessageStarCount *int `json:"paid_message_star_count,omitempty"` } +// ChatPhoto represents a chat photo. +// See https://core.telegram.org/bots/api#chatphoto type ChatPhoto struct { SmallFileID string `json:"small_file_id"` SmallFileUniqueID string `json:"small_file_unique_id"` @@ -89,6 +96,8 @@ type ChatPhoto struct { BigFileUniqueID string `json:"big_file_unique_id"` } +// ChatPermissions describes actions that a non‑administrator user is allowed to take in a chat. +// See https://core.telegram.org/bots/api#chatpermissions type ChatPermissions struct { CanSendMessages bool `json:"can_send_messages"` CanSendAudios bool `json:"can_send_audios"` @@ -99,16 +108,22 @@ type ChatPermissions struct { CanSendPolls bool `json:"can_send_polls"` CanSendOtherMessages bool `json:"can_send_other_messages"` CanAddWebPagePreview bool `json:"can_add_web_page_preview"` - CatEditTag bool `json:"cat_edit_tag"` + CatEditTag bool `json:"cat_edit_tag"` // Note: field name likely a typo, should be "can_edit_tag" CanChangeInfo bool `json:"can_change_info"` CanInviteUsers bool `json:"can_invite_users"` CanPinMessages bool `json:"can_pin_messages"` CanManageTopics bool `json:"can_manage_topics"` } + +// ChatLocation represents a location to which a chat is connected. +// See https://core.telegram.org/bots/api#chatlocation type ChatLocation struct { Location Location `json:"location"` Address string `json:"address"` } + +// ChatInviteLink represents an invite link for a chat. +// See https://core.telegram.org/bots/api#chatinvitelink type ChatInviteLink struct { InviteLink string `json:"invite_link"` Creator User `json:"creator"` @@ -124,6 +139,7 @@ type ChatInviteLink struct { SubscriptionPrice *int `json:"subscription_price,omitempty"` } +// ChatMemberStatusType indicates the status of a chat member. type ChatMemberStatusType string const ( @@ -135,6 +151,8 @@ const ( ChatMemberStatusBanned ChatMemberStatusType = "kicked" ) +// ChatMember contains information about one member of a chat. +// See https://core.telegram.org/bots/api#chatmember type ChatMember struct { Status ChatMemberStatusType `json:"status"` User User `json:"user"` @@ -181,6 +199,8 @@ type ChatMember struct { CanEditTag *bool `json:"can_edit_tag,omitempty"` } +// ChatBoostSource describes the source of a chat boost. +// See https://core.telegram.org/bots/api#chatboostsource type ChatBoostSource struct { Source string `json:"source"` User User `json:"user"` @@ -191,16 +211,23 @@ type ChatBoostSource struct { IsUnclaimed *bool `json:"is_unclaimed,omitempty"` } +// ChatBoost represents a boost added to a chat. +// See https://core.telegram.org/bots/api#chatboost type ChatBoost struct { BoostID int `json:"boost_id"` AddDate int `json:"add_date"` ExpirationDate int `json:"expiration_date"` Source ChatBoostSource `json:"source"` } + +// UserChatBoosts represents a list of boosts a user has given to a chat. +// See https://core.telegram.org/bots/api#userchatboosts type UserChatBoosts struct { Boosts []ChatBoost `json:"boosts"` } +// ChatAdministratorRights represents the rights of an administrator in a chat. +// See https://core.telegram.org/bots/api#chatadministratorrights type ChatAdministratorRights struct { IsAnonymous bool `json:"is_anonymous"` CanManageChat bool `json:"can_manage_chat"` @@ -222,11 +249,15 @@ type ChatAdministratorRights struct { CanManageTags *bool `json:"can_manage_tags,omitempty"` } +// ChatBoostUpdated represents a boost added to a chat or changed. +// See https://core.telegram.org/bots/api#chatboostupdated type ChatBoostUpdated struct { Chat Chat `json:"chat"` Boost ChatBoost `json:"boost"` } +// ChatBoostRemoved represents a boost removed from a chat. +// See https://core.telegram.org/bots/api#chatboostremoved type ChatBoostRemoved struct { Chat Chat `json:"chat"` BoostID string `json:"boost_id"` diff --git a/tgapi/forum_methods.go b/tgapi/forum_methods.go index 137f87d..e7fa6d9 100644 --- a/tgapi/forum_methods.go +++ b/tgapi/forum_methods.go @@ -1,15 +1,20 @@ package tgapi +// BaseForumTopicP contains common fields for forum topic operations that require a chat ID and a message thread ID. type BaseForumTopicP struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id"` } +// GetForumTopicIconStickers returns the list of custom emoji that can be used as a forum topic icon. +// See https://core.telegram.org/bots/api#getforumtopiciconstickers func (api *API) GetForumTopicIconStickers() ([]Sticker, error) { req := NewRequest[[]Sticker]("getForumTopicIconStickers", NoParams) return req.Do(api) } +// CreateForumTopicP holds parameters for the createForumTopic method. +// See https://core.telegram.org/bots/api#createforumtopic type CreateForumTopicP struct { ChatID int64 `json:"chat_id"` Name string `json:"name"` @@ -17,69 +22,117 @@ type CreateForumTopicP struct { IconCustomEmojiID string `json:"icon_custom_emoji_id"` } +// CreateForumTopic creates a topic in a forum supergroup. +// Returns the created ForumTopic on success. +// See https://core.telegram.org/bots/api#createforumtopic func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) { req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID) return req.Do(api) } +// EditForumTopicP holds parameters for the editForumTopic method. +// See https://core.telegram.org/bots/api#editforumtopic type EditForumTopicP struct { BaseForumTopicP Name string `json:"name"` IconCustomEmojiID string `json:"icon_custom_emoji_id"` } +// EditForumTopic edits name and icon of a forum topic. +// Returns True on success. +// See https://core.telegram.org/bots/api#editforumtopic func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID) return req.Do(api) } +// CloseForumTopic closes an open forum topic. +// Returns True on success. +// See https://core.telegram.org/bots/api#closeforumtopic func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID) return req.Do(api) } + +// ReopenForumTopic reopens a closed forum topic. +// Returns True on success. +// See https://core.telegram.org/bots/api#reopenforumtopic func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID) return req.Do(api) } + +// DeleteForumTopic deletes a forum topic. +// Returns True on success. +// See https://core.telegram.org/bots/api#deleteforumtopic func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID) return req.Do(api) } + +// UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic. +// Returns True on success. +// See https://core.telegram.org/bots/api#unpinallforumtopicmessages func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID) return req.Do(api) } +// BaseGeneralForumTopicP contains common fields for general forum topic operations that require a chat ID. type BaseGeneralForumTopicP struct { ChatID int64 `json:"chat_id"` } +// EditGeneralForumTopicP holds parameters for the editGeneralForumTopic method. +// See https://core.telegram.org/bots/api#editgeneralforumtopic type EditGeneralForumTopicP struct { ChatID int64 `json:"chat_id"` Name string `json:"name"` } +// EditGeneralForumTopic edits the name of the 'General' topic in a forum supergroup. +// Returns True on success. +// See https://core.telegram.org/bots/api#editgeneralforumtopic func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID) return req.Do(api) } +// CloseGeneralForumTopic closes the 'General' topic in a forum supergroup. +// Returns True on success. +// See https://core.telegram.org/bots/api#closegeneralforumtopic func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID) return req.Do(api) } + +// ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup. +// Returns True on success. +// See https://core.telegram.org/bots/api#reopengeneralforumtopic func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID) return req.Do(api) } + +// HideGeneralForumTopic hides the 'General' topic in a forum supergroup. +// Returns True on success. +// See https://core.telegram.org/bots/api#hidegeneralforumtopic func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID) return req.Do(api) } + +// UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup. +// Returns True on success. +// See https://core.telegram.org/bots/api#unhidegeneralforumtopic func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID) return req.Do(api) } + +// UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic. +// Returns True on success. +// See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP) (bool, error) { req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID) return req.Do(api) diff --git a/tgapi/forum_types.go b/tgapi/forum_types.go index 45a4661..6fd72f1 100644 --- a/tgapi/forum_types.go +++ b/tgapi/forum_types.go @@ -1,5 +1,7 @@ package tgapi +// ForumTopic represents a forum topic. +// See https://core.telegram.org/bots/api#forumtopic type ForumTopic struct { MessageThreadID int `json:"message_thread_id"` Name string `json:"name"` @@ -8,8 +10,12 @@ type ForumTopic struct { IsNameImplicit bool `json:"is_name_implicit,omitempty"` } +// ForumTopicIconColor represents the color of a forum topic icon. +// The value is an integer representing the color in RGB format. +// See https://core.telegram.org/bots/api#forumtopiciconcolor type ForumTopicIconColor int const ( + // ForumTopicIconColorBlue is the blue color for forum topic icons (value 7322096). ForumTopicIconColorBlue ForumTopicIconColor = 7322096 ) diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index cbcd7ce..3892b4b 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -1,5 +1,7 @@ package tgapi +// SendMessageP holds parameters for the sendMessage method. +// See https://core.telegram.org/bots/api#sendmessage type SendMessageP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -20,11 +22,15 @@ type SendMessageP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendMessage sends a text message. +// See https://core.telegram.org/bots/api#sendmessage func (api *API) SendMessage(params SendMessageP) (Message, error) { req := NewRequestWithChatID[Message, SendMessageP]("sendMessage", params, params.ChatID) return req.Do(api) } +// ForwardMessageP holds parameters for the forwardMessage method. +// See https://core.telegram.org/bots/api#forwardmessage type ForwardMessageP struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -40,11 +46,15 @@ type ForwardMessageP struct { SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` } +// ForwardMessage forwards a message. +// See https://core.telegram.org/bots/api#forwardmessage func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) { req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID) return req.Do(api) } +// ForwardMessagesP holds parameters for the forwardMessages method. +// See https://core.telegram.org/bots/api#forwardmessages type ForwardMessagesP struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -56,11 +66,16 @@ type ForwardMessagesP struct { ProtectContent bool `json:"protect_content,omitempty"` } +// ForwardMessages forwards multiple messages. +// Returns an array of message IDs of the sent messages. +// See https://core.telegram.org/bots/api#forwardmessages func (api *API) ForwardMessages(params ForwardMessagesP) ([]int, error) { req := NewRequestWithChatID[[]int]("forwardMessages", params, params.ChatID) return req.Do(api) } +// CopyMessageP holds parameters for the copyMessage method. +// See https://core.telegram.org/bots/api#copymessage type CopyMessageP struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -84,11 +99,16 @@ type CopyMessageP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// CopyMessage copies a message. +// Returns the MessageID of the sent copy. +// See https://core.telegram.org/bots/api#copymessage func (api *API) CopyMessage(params CopyMessageP) (int, error) { req := NewRequestWithChatID[int]("copyMessage", params, params.ChatID) return req.Do(api) } +// CopyMessagesP holds parameters for the copyMessages method. +// See https://core.telegram.org/bots/api#copymessages type CopyMessagesP struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -101,11 +121,16 @@ type CopyMessagesP struct { RemoveCaption bool `json:"remove_caption,omitempty"` } +// CopyMessages copies multiple messages. +// Returns an array of message IDs of the sent copies. +// See https://core.telegram.org/bots/api#copymessages func (api *API) CopyMessages(params CopyMessagesP) ([]int, error) { req := NewRequestWithChatID[[]int]("copyMessages", params, params.ChatID) return req.Do(api) } +// SendLocationP holds parameters for the sendLocation method. +// See https://core.telegram.org/bots/api#sendlocation type SendLocationP struct { BusinessConnectionID int `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -129,11 +154,15 @@ type SendLocationP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendLocation sends a point on the map. +// See https://core.telegram.org/bots/api#sendlocation func (api *API) SendLocation(params SendLocationP) (Message, error) { req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID) return req.Do(api) } +// SendVenueP holds parameters for the sendVenue method. +// See https://core.telegram.org/bots/api#sendvenue type SendVenueP struct { BusinessConnectionID int `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -159,11 +188,15 @@ type SendVenueP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendVenue sends information about a venue. +// See https://core.telegram.org/bots/api#sendvenue func (api *API) SendVenue(params SendVenueP) (Message, error) { req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID) return req.Do(api) } +// SendContactP holds parameters for the sendContact method. +// See https://core.telegram.org/bots/api#sendcontact type SendContactP struct { BusinessConnectionID int `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -185,11 +218,15 @@ type SendContactP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendContact sends a phone contact. +// See https://core.telegram.org/bots/api#sendcontact func (api *API) SendContact(params SendContactP) (Message, error) { req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID) return req.Do(api) } +// SendPollP holds parameters for the sendPoll method. +// See https://core.telegram.org/bots/api#sendpoll type SendPollP struct { BusinessConnectionID int `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -219,11 +256,15 @@ type SendPollP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendPoll sends a native poll. +// See https://core.telegram.org/bots/api#sendpoll func (api *API) SendPoll(params SendPollP) (Message, error) { req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID) return req.Do(api) } +// SendChecklistP holds parameters for the sendChecklist method. +// See https://core.telegram.org/bots/api#sendchecklist type SendChecklistP struct { BusinessConnectionID int `json:"business_connection_id"` ChatID int64 `json:"chat_id"` @@ -237,11 +278,15 @@ type SendChecklistP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendChecklist sends a checklist. +// See https://core.telegram.org/bots/api#sendchecklist func (api *API) SendChecklist(params SendChecklistP) (Message, error) { req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID) return req.Do(api) } +// SendDiceP holds parameters for the sendDice method. +// See https://core.telegram.org/bots/api#senddice type SendDiceP struct { BusinessConnectionID int `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -260,11 +305,14 @@ type SendDiceP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// SendDice sends a dice, which will have a random value. +// See https://core.telegram.org/bots/api#senddice func (api *API) SendDice(params SendDiceP) (Message, error) { req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID) return req.Do(api) } +// SendMessageDraftP holds parameters for the sendMessageDraft method. type SendMessageDraftP struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -274,11 +322,14 @@ type SendMessageDraftP struct { Entities []MessageEntity `json:"entities,omitempty"` } +// SendMessageDraft sends a previously saved draft message. func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) { req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID) return req.Do(api) } +// SendChatActionP holds parameters for the sendChatAction method. +// See https://core.telegram.org/bots/api#sendchataction type SendChatActionP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -286,11 +337,16 @@ type SendChatActionP struct { Action ChatActionType `json:"action"` } +// SendChatAction sends a chat action (typing, uploading photo, etc.). +// Returns True on success. +// See https://core.telegram.org/bots/api#sendchataction func (api *API) SendChatAction(params SendChatActionP) (bool, error) { req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID) return req.Do(api) } +// SetMessageReactionP holds parameters for the setMessageReaction method. +// See https://core.telegram.org/bots/api#setmessagereaction type SetMessageReactionP struct { ChatID int64 `json:"chat_id"` MessageId int `json:"message_id"` @@ -298,13 +354,16 @@ type SetMessageReactionP struct { IsBig bool `json:"is_big,omitempty"` } +// SetMessageReaction changes the chosen reaction on a message. +// Returns True on success. +// See https://core.telegram.org/bots/api#setmessagereaction func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) { req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID) return req.Do(api) } -// Message update methods - +// EditMessageTextP holds parameters for the editMessageText method. +// See https://core.telegram.org/bots/api#editmessagetext type EditMessageTextP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` @@ -315,8 +374,10 @@ type EditMessageTextP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// EditMessageText If inline message, first return will be zero-valued, and second will boolean -// Otherwise, first return will be Message, and second false +// EditMessageText edits text messages. +// If inline_message_id is provided, returns a boolean success flag; +// otherwise returns the edited Message. +// See https://core.telegram.org/bots/api#editmessagetext func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { @@ -329,6 +390,8 @@ func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error) return res, false, err } +// EditMessageCaptionP holds parameters for the editMessageCaption method. +// See https://core.telegram.org/bots/api#editmessagecaption type EditMessageCaptionP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` @@ -339,8 +402,10 @@ type EditMessageCaptionP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// EditMessageCaption If inline message, first return will be zero-valued, and second will boolean -// Otherwise, first return will be Message, and second false +// EditMessageCaption edits captions of messages. +// If inline_message_id is provided, returns a boolean success flag; +// otherwise returns the edited Message. +// See https://core.telegram.org/bots/api#editmessagecaption func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { @@ -353,6 +418,8 @@ func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, e return res, false, err } +// EditMessageMediaP holds parameters for the editMessageMedia method. +// See https://core.telegram.org/bots/api#editmessagemedia type EditMessageMediaP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` @@ -362,8 +429,10 @@ type EditMessageMediaP struct { ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } -// EditMessageMedia If inline message, first return will be zero-valued, and second will boolean -// Otherwise, first return will be Message, and second false +// EditMessageMedia edits media messages. +// If inline_message_id is provided, returns a boolean success flag; +// otherwise returns the edited Message. +// See https://core.telegram.org/bots/api#editmessagemedia func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { @@ -376,6 +445,8 @@ func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error return res, false, err } +// EditMessageLiveLocationP holds parameters for the editMessageLiveLocation method. +// See https://core.telegram.org/bots/api#editmessagelivelocation type EditMessageLiveLocationP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` @@ -391,8 +462,10 @@ type EditMessageLiveLocationP struct { ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } -// EditMessageLiveLocation If inline message, first return will be zero-valued, and second will boolean -// Otherwise, first return will be Message, and second false +// EditMessageLiveLocation edits live location messages. +// If inline_message_id is provided, returns a boolean success flag; +// otherwise returns the edited Message. +// See https://core.telegram.org/bots/api#editmessagelivelocation func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { @@ -405,6 +478,8 @@ func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Messag return res, false, err } +// StopMessageLiveLocationP holds parameters for the stopMessageLiveLocation method. +// See https://core.telegram.org/bots/api#stopmessagelivelocation type StopMessageLiveLocationP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` @@ -413,8 +488,10 @@ type StopMessageLiveLocationP struct { ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } -// StopMessageLiveLocation If inline message, first return will be zero-valued, and second will boolean -// Otherwise, first return will be Message, and second false +// StopMessageLiveLocation stops a live location message. +// If inline_message_id is provided, returns a boolean success flag; +// otherwise returns the edited Message. +// See https://core.telegram.org/bots/api#stopmessagelivelocation func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { @@ -427,6 +504,7 @@ func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Messag return res, false, err } +// EditMessageChecklistP holds parameters for the editMessageChecklist method. type EditMessageChecklistP struct { BusinessConnectionID string `json:"business_connection_id"` ChatID int64 `json:"chat_id"` @@ -435,11 +513,15 @@ type EditMessageChecklistP struct { ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } +// EditMessageChecklist edits a checklist message. +// See https://core.telegram.org/bots/api#editmessagechecklist func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, error) { req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID) return req.Do(api) } +// EditMessageReplyMarkupP holds parameters for the editMessageReplyMarkup method. +// See https://core.telegram.org/bots/api#editmessagereplymarkup type EditMessageReplyMarkupP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` @@ -448,6 +530,10 @@ type EditMessageReplyMarkupP struct { ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } +// EditMessageReplyMarkup edits only the reply markup of messages. +// If inline_message_id is provided, returns a boolean success flag; +// otherwise returns the edited Message. +// See https://core.telegram.org/bots/api#editmessagereplymarkup func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { @@ -460,6 +546,8 @@ func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message, return res, false, err } +// StopPollP holds parameters for the stopPoll method. +// See https://core.telegram.org/bots/api#stoppoll type StopPollP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -467,53 +555,78 @@ type StopPollP struct { InlineMessageID string `json:"inline_message_id,omitempty"` } +// StopPoll stops a poll that was sent by the bot. +// Returns the stopped Poll. +// See https://core.telegram.org/bots/api#stoppoll func (api *API) StopPoll(params StopPollP) (Poll, error) { req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID) return req.Do(api) } +// ApproveSuggestedPostP holds parameters for the approveSuggestedPost method. +// See https://core.telegram.org/bots/api#approvesuggestedpost type ApproveSuggestedPostP struct { ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` SendDate int `json:"send_date,omitempty"` } +// ApproveSuggestedPost approves a suggested channel post. +// Returns True on success. +// See https://core.telegram.org/bots/api#approvesuggestedpost func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error) { req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID) return req.Do(api) } +// DeclineSuggestedPostP holds parameters for the declineSuggestedPost method. +// See https://core.telegram.org/bots/api#declinesuggestedpost type DeclineSuggestedPostP struct { ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` Comment string `json:"comment,omitempty"` } +// DeclineSuggestedPost declines a suggested channel post. +// Returns True on success. +// See https://core.telegram.org/bots/api#declinesuggestedpost func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error) { req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID) return req.Do(api) } +// DeleteMessageP holds parameters for the deleteMessage method. +// See https://core.telegram.org/bots/api#deletemessage type DeleteMessageP struct { ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` } +// DeleteMessage deletes a message. +// Returns True on success. +// See https://core.telegram.org/bots/api#deletemessage func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) { req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID) return req.Do(api) } +// DeleteMessagesP holds parameters for the deleteMessages method. +// See https://core.telegram.org/bots/api#deletemessages type DeleteMessagesP struct { ChatID int64 `json:"chat_id"` MessageIDs []int `json:"message_ids"` } +// DeleteMessages deletes multiple messages at once. +// Returns True on success. +// See https://core.telegram.org/bots/api#deletemessages func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) { req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID) return req.Do(api) } +// AnswerCallbackQueryP holds parameters for the answerCallbackQuery method. +// See https://core.telegram.org/bots/api#answercallbackquery type AnswerCallbackQueryP struct { CallbackQueryID string `json:"callback_query_id"` Text string `json:"text,omitempty"` @@ -522,6 +635,9 @@ type AnswerCallbackQueryP struct { CacheTime int `json:"cache_time,omitempty"` } +// AnswerCallbackQuery sends answers to callback queries sent from inline keyboards. +// Returns True on success. +// See https://core.telegram.org/bots/api#answercallbackquery func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) { req := NewRequest[bool]("answerCallbackQuery", params) return req.Do(api) diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 5b0eba1..cac182c 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -2,15 +2,20 @@ package tgapi import "git.nix13.pw/scuroneko/extypes" +// MessageReplyMarkup represents an inline keyboard markup for a message. +// It is used in the Message type. type MessageReplyMarkup struct { InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"` } +// DirectMessageTopic represents a forum topic in a direct message. type DirectMessageTopic struct { TopicID int64 `json:"topic_id"` User *User `json:"user,omitempty"` } +// Message represents a Telegram message. +// See https://core.telegram.org/bots/api#message type Message struct { MessageID int `json:"message_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -51,14 +56,19 @@ type Message struct { EffectID string `json:"effect_id,omitempty"` } +// InaccessibleMessage describes a message that was deleted or is otherwise inaccessible. +// See https://core.telegram.org/bots/api#inaccessiblemessage type InaccessibleMessage struct { Chat Chat `json:"chat"` MessageID int `json:"message_id"` Date int `json:"date"` } +// MaybeInaccessibleMessage is a union type that can be either Message or InaccessibleMessage. +// See https://core.telegram.org/bots/api#maybeinaccessiblemessage type MaybeInaccessibleMessage interface{ Message | InaccessibleMessage } +// MessageEntityType represents the type of a message entity. type MessageEntityType string const ( @@ -84,6 +94,8 @@ const ( MessageEntityDateTime MessageEntityType = "date_time" ) +// MessageEntity represents one special entity in a text message. +// See https://core.telegram.org/bots/api#messageentity type MessageEntity struct { Type MessageEntityType `json:"type"` @@ -98,6 +110,8 @@ type MessageEntity struct { DateTimeFormat string `json:"date_time_format,omitempty"` } +// ReplyParameters describes the parameters to use when replying to a message. +// See https://core.telegram.org/bots/api#replyparameters type ReplyParameters struct { MessageID int `json:"message_id"` ChatID int `json:"chat_id,omitempty"` @@ -110,6 +124,8 @@ type ReplyParameters struct { ChecklistTaskID int `json:"checklist_task_id,omitempty"` } +// LinkPreviewOptions describes the options used for link preview generation. +// See https://core.telegram.org/bots/api#linkpreviewoptions type LinkPreviewOptions struct { IsDisabled bool `json:"is_disabled,omitempty"` URL string `json:"url,omitempty"` @@ -118,6 +134,8 @@ type LinkPreviewOptions struct { ShowAboveText bool `json:"show_above_text,omitempty"` } +// ReplyMarkup represents a custom keyboard or inline keyboard. +// See https://core.telegram.org/bots/api#replymarkup type ReplyMarkup struct { InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard,omitempty"` @@ -132,11 +150,18 @@ type ReplyMarkup struct { ForceReply bool `json:"force_reply,omitempty"` } + +// InlineKeyboardMarkup represents an inline keyboard that appears right next to the message it belongs to. +// See https://core.telegram.org/bots/api#inlinekeyboardmarkup type InlineKeyboardMarkup struct { InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard,omitempty"` } +// KeyboardButtonStyle represents the style of a keyboard button. type KeyboardButtonStyle string + +// InlineKeyboardButton represents one button of an inline keyboard. +// See https://core.telegram.org/bots/api#inlinekeyboardbutton type InlineKeyboardButton struct { Text string `json:"text"` URL string `json:"url,omitempty"` @@ -145,10 +170,14 @@ type InlineKeyboardButton struct { IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` } +// ReplyKeyboardMarkup represents a custom keyboard with reply options. +// See https://core.telegram.org/bots/api#replykeyboardmarkup type ReplyKeyboardMarkup struct { Keyboard [][]int `json:"keyboard"` } +// CallbackQuery represents an incoming callback query from a callback button in an inline keyboard. +// See https://core.telegram.org/bots/api#callbackquery type CallbackQuery struct { ID string `json:"id"` From User `json:"from"` @@ -157,11 +186,15 @@ type CallbackQuery struct { Data string `json:"data"` } +// InputPollOption contains information about one answer option in a poll to be sent. +// See https://core.telegram.org/bots/api#inputpolloption type InputPollOption struct { Text string `json:"text"` TextParseMode ParseMode `json:"text_parse_mode,omitempty"` TextEntities []*MessageEntity `json:"text_entities,omitempty"` } + +// PollType represents the type of a poll. type PollType string const ( @@ -169,12 +202,15 @@ const ( PollTypeQuiz PollType = "quiz" ) +// InputChecklistTask describes a task in a checklist. type InputChecklistTask struct { ID int `json:"id"` Text string `json:"text"` ParseMode ParseMode `json:"parse_mode,omitempty"` TextEntities []*MessageEntity `json:"text_entities,omitempty"` } + +// InputChecklist represents a checklist to be sent. type InputChecklist struct { Title string `json:"title"` ParseMode ParseMode `json:"parse_mode,omitempty"` @@ -184,6 +220,7 @@ type InputChecklist struct { OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"` } +// ChatActionType represents the type of chat action. type ChatActionType string const ( @@ -197,6 +234,8 @@ const ( ChatActionUploadVideoNone ChatActionType = "upload_video_none" ) +// MessageReactionUpdated represents a change of a reaction on a message. +// See https://core.telegram.org/bots/api#messagereactionupdated type MessageReactionUpdated struct { Chat *Chat `json:"chat"` MessageID int `json:"message_id"` @@ -207,12 +246,17 @@ type MessageReactionUpdated struct { NewReaction []ReactionType `json:"new_reaction"` } +// MessageReactionCountUpdated represents a change in the count of reactions on a message. +// See https://core.telegram.org/bots/api#messagereactioncountupdated type MessageReactionCountUpdated struct { Chat *Chat `json:"chat"` MessageID int `json:"message_id"` Date int `json:"date"` Reactions []*ReactionCount `json:"reactions"` } + +// ReactionType describes the type of a reaction. +// See https://core.telegram.org/bots/api#reactiontype type ReactionType struct { Type string `json:"type"` // ReactionTypeEmoji @@ -220,20 +264,29 @@ type ReactionType struct { // ReactionTypeCustomEmoji CustomEmojiID *string `json:"custom_emoji_id,omitempty"` } + +// ReactionCount represents a reaction added to a message along with the number of times it was added. +// See https://core.telegram.org/bots/api#reactioncount type ReactionCount struct { Type ReactionType `json:"type"` TotalCount int `json:"total_count"` } +// SuggestedPostPrice represents the price of a suggested post. type SuggestedPostPrice struct { Currency string `json:"currency"` Amount int `json:"amount"` } + +// SuggestedPostInfo contains information about a suggested post. +// See https://core.telegram.org/bots/api#suggestedpostinfo type SuggestedPostInfo struct { - State string `json:"state"` //State of the suggested post. Currently, it can be one of “pending”, “approved”, “declined”. + State string `json:"state"` // "pending", "approved", or "declined" Price SuggestedPostPrice `json:"price"` SendDate int `json:"send_date"` } + +// SuggestedPostParameters holds parameters for suggesting a post. type SuggestedPostParameters struct { Price SuggestedPostPrice `json:"price"` SendDate int `json:"send_date"` diff --git a/tgapi/methods.go b/tgapi/methods.go index c9f467e..44887d3 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -6,19 +6,28 @@ import ( "net/http" ) +// ParseMode represents the text formatting mode for message parsing. type ParseMode string const ( + // ParseMDV2 enables MarkdownV2 style parsing. ParseMDV2 ParseMode = "MarkdownV2" + // ParseHTML enables HTML style parsing. ParseHTML ParseMode = "HTML" - ParseMD ParseMode = "Markdown" + // ParseMD enables legacy Markdown style parsing. + ParseMD ParseMode = "Markdown" + // ParseNone disables any parsing. ParseNone ParseMode = "None" ) +// EmptyParams is a placeholder for methods that take no parameters. type EmptyParams struct{} +// NoParams is a convenient instance of EmptyParams. var NoParams = EmptyParams{} +// UpdateParams holds parameters for the getUpdates method. +// See https://core.telegram.org/bots/api#getupdates type UpdateParams struct { Offset *int `json:"offset,omitempty"` Limit *int `json:"limit,omitempty"` @@ -26,32 +35,52 @@ type UpdateParams struct { AllowedUpdates []UpdateType `json:"allowed_updates"` } +// GetMe returns basic information about the bot. +// See https://core.telegram.org/bots/api#getme func (api *API) GetMe() (User, error) { req := NewRequest[User, EmptyParams]("getMe", NoParams) return req.Do(api) } + +// LogOut logs the bot out from the cloud Bot API server. +// Returns true on success. +// See https://core.telegram.org/bots/api#logout func (api *API) LogOut() (bool, error) { req := NewRequest[bool, EmptyParams]("logOut", NoParams) return req.Do(api) } + +// Close closes the bot instance on the local server. +// Returns true on success. +// See https://core.telegram.org/bots/api#close func (api *API) Close() (bool, error) { req := NewRequest[bool, EmptyParams]("close", NoParams) return req.Do(api) } + +// GetUpdates receives incoming updates using long polling. +// See https://core.telegram.org/bots/api#getupdates func (api *API) GetUpdates(params UpdateParams) ([]Update, error) { req := NewRequest[[]Update]("getUpdates", params) return req.Do(api) } +// GetFileP holds parameters for the getFile method. +// See https://core.telegram.org/bots/api#getfile type GetFileP struct { FileId string `json:"file_id"` } +// GetFile returns basic information about a file and prepares it for downloading. +// See https://core.telegram.org/bots/api#getfile func (api *API) GetFile(params GetFileP) (File, error) { req := NewRequest[File]("getFile", params) return req.Do(api) } +// GetFileByLink downloads a file from Telegram's file server using the provided file link. +// The link is usually obtained from File.FilePath. +// See https://core.telegram.org/bots/api#file func (api *API) GetFileByLink(link string) ([]byte, error) { u := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", api.token, link) res, err := http.Get(u) diff --git a/tgapi/stickers_methods.go b/tgapi/stickers_methods.go index 152bf56..ff0b944 100644 --- a/tgapi/stickers_methods.go +++ b/tgapi/stickers_methods.go @@ -1,5 +1,7 @@ package tgapi +// SendStickerP holds parameters for the sendSticker method. +// See https://core.telegram.org/bots/api#sendsticker type SendStickerP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -14,29 +16,41 @@ type SendStickerP struct { MessageEffectID string `json:"message_effect_id,omitempty"` } +// SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker. +// See https://core.telegram.org/bots/api#sendsticker func (api *API) SendSticker(params SendStickerP) (Message, error) { req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID) return req.Do(api) } +// GetStickerSetP holds parameters for the getStickerSet method. +// See https://core.telegram.org/bots/api#getstickerset type GetStickerSetP struct { Name string `json:"name"` } +// GetStickerSet returns a sticker set by its name. +// See https://core.telegram.org/bots/api#getstickerset func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) { req := NewRequest[StickerSet]("getStickerSet", params) return req.Do(api) } +// GetCustomEmojiStickersP holds parameters for the getCustomEmojiStickers method. +// See https://core.telegram.org/bots/api#getcustomemojistickers type GetCustomEmojiStickersP struct { CustomEmojiIDs []string `json:"custom_emoji_ids"` } +// GetCustomEmojiStickers returns information about custom emoji stickers by their IDs. +// See https://core.telegram.org/bots/api#getcustomemojistickers func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticker, error) { req := NewRequest[[]Sticker]("getCustomEmojiStickers", params) return req.Do(api) } +// CreateNewStickerSetP holds parameters for the createNewStickerSet method. +// See https://core.telegram.org/bots/api#createnewstickerset type CreateNewStickerSetP struct { UserID int `json:"user_id"` Name string `json:"name"` @@ -47,41 +61,61 @@ type CreateNewStickerSetP struct { NeedsRepainting bool `json:"needs_repainting,omitempty"` } +// CreateNewStickerSet creates a new sticker set owned by a user. +// Returns True on success. +// See https://core.telegram.org/bots/api#createnewstickerset func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) { req := NewRequest[bool]("createNewStickerSet", params) return req.Do(api) } +// AddStickerToSetP holds parameters for the addStickerToSet method. +// See https://core.telegram.org/bots/api#addstickertoset type AddStickerToSetP struct { UserID int `json:"user_id"` Name string `json:"name"` Sticker InputSticker `json:"sticker"` } +// AddStickerToSet adds a new sticker to a set created by the bot. +// Returns True on success. +// See https://core.telegram.org/bots/api#addstickertoset func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) { req := NewRequest[bool]("addStickerToSet", params) return req.Do(api) } +// SetStickerPositionInSetP holds parameters for the setStickerPositionInSet method. +// See https://core.telegram.org/bots/api#setstickerpositioninset type SetStickerPositionInSetP struct { Sticker string `json:"sticker"` Position int `json:"position"` } -func (api *API) SetStickerPosition(params SetStickerPositionInSetP) (bool, error) { - req := NewRequest[bool]("setStickerPosition", params) +// SetStickerPositionInSet moves a sticker in a set to a specific position. +// Returns True on success. +// See https://core.telegram.org/bots/api#setstickerpositioninset +func (api *API) SetStickerPositionInSet(params SetStickerPositionInSetP) (bool, error) { + req := NewRequest[bool]("setStickerPositionInSet", params) return req.Do(api) } +// DeleteStickerFromSetP holds parameters for the deleteStickerFromSet method. +// See https://core.telegram.org/bots/api#deletestickerfromset type DeleteStickerFromSetP struct { Sticker string `json:"sticker"` } +// DeleteStickerFromSet deletes a sticker from a set created by the bot. +// Returns True on success. +// See https://core.telegram.org/bots/api#deletestickerfromset func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error) { req := NewRequest[bool]("deleteStickerFromSet", params) return req.Do(api) } +// ReplaceStickerInSetP holds parameters for the replaceStickerInSet method. +// See https://core.telegram.org/bots/api#replacestickerinset type ReplaceStickerInSetP struct { UserID int `json:"user_id"` Name string `json:"name"` @@ -89,51 +123,76 @@ type ReplaceStickerInSetP struct { Sticker InputSticker `json:"sticker"` } +// ReplaceStickerInSet replaces an existing sticker in a set with a new one. +// Returns True on success. +// See https://core.telegram.org/bots/api#replacestickerinset func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) { req := NewRequest[bool]("replaceStickerInSet", params) return req.Do(api) } +// SetStickerEmojiListP holds parameters for the setStickerEmojiList method. +// See https://core.telegram.org/bots/api#setstickeremojilist type SetStickerEmojiListP struct { Sticker string `json:"sticker"` EmojiList []string `json:"emoji_list"` } +// SetStickerEmojiList changes the list of emoji associated with a sticker. +// Returns True on success. +// See https://core.telegram.org/bots/api#setstickeremojilist func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) { req := NewRequest[bool]("setStickerEmojiList", params) return req.Do(api) } +// SetStickerKeywordsP holds parameters for the setStickerKeywords method. +// See https://core.telegram.org/bots/api#setstickerkeywords type SetStickerKeywordsP struct { Sticker string `json:"sticker"` Keywords []string `json:"keywords"` } +// SetStickerKeywords changes the keywords of a sticker. +// Returns True on success. +// See https://core.telegram.org/bots/api#setstickerkeywords func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) { req := NewRequest[bool]("setStickerKeywords", params) return req.Do(api) } +// SetStickerMaskPositionP holds parameters for the setStickerMaskPosition method. +// See https://core.telegram.org/bots/api#setstickermaskposition type SetStickerMaskPositionP struct { Sticker string `json:"sticker"` MaskPosition *MaskPosition `json:"mask_position,omitempty"` } +// SetStickerMaskPosition changes the mask position of a mask sticker. +// Returns True on success. +// See https://core.telegram.org/bots/api#setstickermaskposition func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, error) { req := NewRequest[bool]("setStickerMaskPosition", params) return req.Do(api) } +// SetStickerSetTitleP holds parameters for the setStickerSetTitle method. +// See https://core.telegram.org/bots/api#setstickersettitle type SetStickerSetTitleP struct { Name string `json:"name"` Title string `json:"title"` } +// SetStickerSetTitle sets the title of a sticker set created by the bot. +// Returns True on success. +// See https://core.telegram.org/bots/api#setstickersettitle func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) { req := NewRequest[bool]("setStickerSetTitle", params) return req.Do(api) } +// SetStickerSetThumbnailP holds parameters for the setStickerSetThumbnail method. +// See https://core.telegram.org/bots/api#setstickersetthumbnail type SetStickerSetThumbnailP struct { Name string `json:"name"` UserID int `json:"user_id"` @@ -141,25 +200,40 @@ type SetStickerSetThumbnailP struct { Format InputStickerFormat `json:"format"` } +// SetStickerSetThumbnail sets the thumbnail of a sticker set. +// Returns True on success. +// See https://core.telegram.org/bots/api#setstickersetthumbnail func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) { req := NewRequest[bool]("setStickerSetThumbnail", params) return req.Do(api) } +// SetCustomEmojiStickerSetThumbnailP holds parameters for the setCustomEmojiStickerSetThumbnail method. +// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail type SetCustomEmojiStickerSetThumbnailP struct { Name string `json:"name"` CustomEmojiID string `json:"custom_emoji_id,omitempty"` } +// SetCustomEmojiStickerSetThumbnail sets the thumbnail of a custom emoji sticker set. +// Returns True on success. +// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail +// +// Note: This method uses SetStickerSetThumbnailP as its parameter type, which might be inconsistent. func (api *API) SetCustomEmojiStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) { req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params) return req.Do(api) } +// DeleteStickerSetP holds parameters for the deleteStickerSet method. +// See https://core.telegram.org/bots/api#deletestickerset type DeleteStickerSetP struct { Name string `json:"name"` } +// DeleteStickerSet deletes a sticker set created by the bot. +// Returns True on success. +// See https://core.telegram.org/bots/api#deletestickerset func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) { req := NewRequest[bool]("deleteStickerSet", params) return req.Do(api) diff --git a/tgapi/stickers_types.go b/tgapi/stickers_types.go index 299c492..d9056c8 100644 --- a/tgapi/stickers_types.go +++ b/tgapi/stickers_types.go @@ -1,14 +1,21 @@ package tgapi +// MaskPositionPoint represents the part of the face where a mask should be placed. type MaskPositionPoint string const ( + // MaskPositionForehead places the mask on the forehead. MaskPositionForehead MaskPositionPoint = "forehead" - MaskPositionEyes MaskPositionPoint = "eyes" - MaskPositionMouth MaskPositionPoint = "mouth" - MaskPositionChin MaskPositionPoint = "chin" + // MaskPositionEyes places the mask on the eyes. + MaskPositionEyes MaskPositionPoint = "eyes" + // MaskPositionMouth places the mask on the mouth. + MaskPositionMouth MaskPositionPoint = "mouth" + // MaskPositionChin places the mask on the chin. + MaskPositionChin MaskPositionPoint = "chin" ) +// MaskPosition describes the position on faces where a mask should be placed by default. +// See https://core.telegram.org/bots/api#maskposition type MaskPosition struct { Point MaskPositionPoint `json:"point"` XShift float32 `json:"x_shift"` @@ -16,14 +23,20 @@ type MaskPosition struct { Scale float32 `json:"scale"` } +// StickerType represents the type of a sticker. type StickerType string const ( - StickerTypeRegular StickerType = "regular" - StickerTypeMask StickerType = "mask" + // StickerTypeRegular is a regular sticker. + StickerTypeRegular StickerType = "regular" + // StickerTypeMask is a mask sticker that can be placed on faces. + StickerTypeMask StickerType = "mask" + // StickerTypeCustomEmoji is a custom emoji sticker. StickerTypeCustomEmoji StickerType = "custom_emoji" ) +// Sticker represents a sticker. +// See https://core.telegram.org/bots/api#sticker type Sticker struct { FileId string `json:"file_id"` FileUniqueId string `json:"file_unique_id"` @@ -41,6 +54,9 @@ type Sticker struct { NeedRepainting *bool `json:"need_repainting,omitempty"` FileSize *int `json:"file_size,omitempty"` } + +// StickerSet represents a sticker set. +// See https://core.telegram.org/bots/api#stickerset type StickerSet struct { Name string `json:"name"` Title string `json:"title"` @@ -48,14 +64,21 @@ type StickerSet struct { Stickers []Sticker `json:"stickers"` Thumbnail *PhotoSize `json:"thumbnail,omitempty"` } + +// InputStickerFormat represents the format of an input sticker. type InputStickerFormat string const ( - InputStickerFormatStatic InputStickerFormat = "static" + // InputStickerFormatStatic is a static sticker (WEBP). + InputStickerFormatStatic InputStickerFormat = "static" + // InputStickerFormatAnimated is an animated sticker (TGS). InputStickerFormatAnimated InputStickerFormat = "animated" - InputStickerFormatVideo InputStickerFormat = "video" + // InputStickerFormatVideo is a video sticker (WEBM). + InputStickerFormatVideo InputStickerFormat = "video" ) +// InputSticker describes a sticker to be added to a sticker set. +// See https://core.telegram.org/bots/api#inputsticker type InputSticker struct { Sticker string `json:"sticker"` Format InputStickerFormat `json:"format"` diff --git a/tgapi/types.go b/tgapi/types.go index 4fcf7fb..6db8b2c 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -1,35 +1,61 @@ package tgapi +// UpdateType represents the type of an incoming update. type UpdateType string const ( - UpdateTypeMessage UpdateType = "message" - UpdateTypeEditedMessage UpdateType = "edited_message" - UpdateTypeChannelPost UpdateType = "channel_post" - UpdateTypeEditedChannelPost UpdateType = "edited_channel_post" - UpdateTypeMessageReaction UpdateType = "message_reaction" + // UpdateTypeMessage is a regular message update. + UpdateTypeMessage UpdateType = "message" + // UpdateTypeEditedMessage is an edited message update. + UpdateTypeEditedMessage UpdateType = "edited_message" + // UpdateTypeChannelPost is a channel post update. + UpdateTypeChannelPost UpdateType = "channel_post" + // UpdateTypeEditedChannelPost is an edited channel post update. + UpdateTypeEditedChannelPost UpdateType = "edited_channel_post" + // UpdateTypeMessageReaction is a message reaction update. + UpdateTypeMessageReaction UpdateType = "message_reaction" + // UpdateTypeMessageReactionCount is a message reaction count update. UpdateTypeMessageReactionCount UpdateType = "message_reaction_count" - UpdateTypeBusinessConnection UpdateType = "business_connection" - UpdateTypeBusinessMessage UpdateType = "business_message" - UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message" + // UpdateTypeBusinessConnection is a business connection update. + UpdateTypeBusinessConnection UpdateType = "business_connection" + // UpdateTypeBusinessMessage is a business message update. + UpdateTypeBusinessMessage UpdateType = "business_message" + // UpdateTypeEditedBusinessMessage is an edited business message update. + UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message" + // UpdateTypeDeletedBusinessMessage is a deleted business message update. UpdateTypeDeletedBusinessMessage UpdateType = "deleted_business_message" - UpdateTypeInlineQuery UpdateType = "inline_query" + // UpdateTypeInlineQuery is an inline query update. + UpdateTypeInlineQuery UpdateType = "inline_query" + // UpdateTypeChosenInlineResult is a chosen inline result update. UpdateTypeChosenInlineResult UpdateType = "chosen_inline_result" - UpdateTypeCallbackQuery UpdateType = "callback_query" - UpdateTypeShippingQuery UpdateType = "shipping_query" - UpdateTypePreCheckoutQuery UpdateType = "pre_checkout_query" + // UpdateTypeCallbackQuery is a callback query update. + UpdateTypeCallbackQuery UpdateType = "callback_query" + // UpdateTypeShippingQuery is a shipping query update. + UpdateTypeShippingQuery UpdateType = "shipping_query" + // UpdateTypePreCheckoutQuery is a pre-checkout query update. + UpdateTypePreCheckoutQuery UpdateType = "pre_checkout_query" + // UpdateTypePurchasedPaidMedia is a purchased paid media update. UpdateTypePurchasedPaidMedia UpdateType = "purchased_paid_media" - UpdateTypePoll UpdateType = "poll" - UpdateTypePollAnswer UpdateType = "poll_answer" - UpdateTypeMyChatMember UpdateType = "my_chat_member" - UpdateTypeChatMember UpdateType = "chat_member" - UpdateTypeChatJoinRequest UpdateType = "chat_join_request" - UpdateTypeChatBoost UpdateType = "chat_boost" - UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost" + // UpdateTypePoll is a poll update. + UpdateTypePoll UpdateType = "poll" + // UpdateTypePollAnswer is a poll answer update. + UpdateTypePollAnswer UpdateType = "poll_answer" + // UpdateTypeMyChatMember is a my chat member update. + UpdateTypeMyChatMember UpdateType = "my_chat_member" + // UpdateTypeChatMember is a chat member update. + UpdateTypeChatMember UpdateType = "chat_member" + // UpdateTypeChatJoinRequest is a chat join request update. + UpdateTypeChatJoinRequest UpdateType = "chat_join_request" + // UpdateTypeChatBoost is a chat boost update. + UpdateTypeChatBoost UpdateType = "chat_boost" + // UpdateTypeRemovedChatBoost is a removed chat boost update. + UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost" ) +// Update represents an incoming update from Telegram. +// See https://core.telegram.org/bots/api#update type Update struct { UpdateID int `json:"update_id"` Message *Message `json:"message,omitempty"` @@ -60,6 +86,8 @@ type Update struct { RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` } +// InlineQuery represents an incoming inline query. +// See https://core.telegram.org/bots/api#inlinequery type InlineQuery struct { ID string `json:"id"` From User `json:"from"` @@ -68,6 +96,9 @@ type InlineQuery struct { ChatType *ChatType `json:"chat_type,omitempty"` Location *Location `json:"location,omitempty"` } + +// ChosenInlineResult represents a result of an inline query that was chosen by the user. +// See https://core.telegram.org/bots/api#choseninlineresult type ChosenInlineResult struct { ResultID string `json:"result_id"` From User `json:"from"` @@ -76,12 +107,17 @@ type ChosenInlineResult struct { Query string `json:"query"` } +// ShippingQuery represents an incoming shipping query. +// See https://core.telegram.org/bots/api#shippingquery type ShippingQuery struct { ID string `json:"id"` From User `json:"from"` InvoicePayload string `json:"invoice_payload"` ShippingAddress ShippingAddress `json:"shipping_address"` } + +// ShippingAddress represents a shipping address. +// See https://core.telegram.org/bots/api#shippingaddress type ShippingAddress struct { CountryCode string `json:"country_code"` State string `json:"state"` @@ -91,12 +127,17 @@ type ShippingAddress struct { PostCode string `json:"post_code"` } +// OrderInfo represents information about an order. +// See https://core.telegram.org/bots/api#orderinfo type OrderInfo struct { Name string `json:"name"` PhoneNumber string `json:"phone_number"` Email string `json:"email"` ShippingAddress ShippingAddress `json:"shipping_address"` } + +// PreCheckoutQuery represents an incoming pre-checkout query. +// See https://core.telegram.org/bots/api#precheckoutquery type PreCheckoutQuery struct { ID string `json:"id"` From User `json:"from"` @@ -107,11 +148,15 @@ type PreCheckoutQuery struct { OrderInfo *OrderInfo `json:"order_info,omitempty"` } +// PaidMediaPurchased represents a purchased paid media. +// See https://core.telegram.org/bots/api#paidmediapurchased type PaidMediaPurchased struct { From User `json:"from"` PaidMediaPayload string `json:"paid_media_payload"` } +// File represents a file ready to be downloaded. +// See https://core.telegram.org/bots/api#file type File struct { FileId string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` @@ -119,6 +164,8 @@ type File struct { FilePath string `json:"file_path,omitempty"` } +// Audio represents an audio file to be treated as music by the Telegram clients. +// See https://core.telegram.org/bots/api#audio type Audio struct { FileID string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` @@ -132,11 +179,16 @@ type Audio struct { Thumbnail *PhotoSize `json:"thumbnail,omitempty"` } +// PollOption contains information about one answer option in a poll. +// See https://core.telegram.org/bots/api#polloption type PollOption struct { Text string `json:"text"` TextEntities []MessageEntity `json:"text_entities"` VoterCount int `json:"voter_count"` } + +// Poll contains information about a poll. +// See https://core.telegram.org/bots/api#poll type Poll struct { ID string `json:"id"` Question string `json:"question"` @@ -154,12 +206,18 @@ type Poll struct { OpenPeriod int `json:"open_period,omitempty"` CloseDate int `json:"close_date,omitempty"` } + +// PollAnswer represents an answer of a user in a poll. +// See https://core.telegram.org/bots/api#pollanswer type PollAnswer struct { PollID string `json:"poll_id"` VoterChat Chat `json:"voter_chat"` User User `json:"user"` OptionIDS []int `json:"option_ids"` } + +// ChatMemberUpdated represents changes in the status of a chat member. +// See https://core.telegram.org/bots/api#chatmemberupdated type ChatMemberUpdated struct { Chat Chat `json:"chat"` From User `json:"from"` @@ -171,6 +229,8 @@ type ChatMemberUpdated struct { ViaChatFolderInviteLink *bool `json:"via_chat_folder_invite_link,omitempty"` } +// ChatJoinRequest represents a join request sent to a chat. +// See https://core.telegram.org/bots/api#chatjoinrequest type ChatJoinRequest struct { Chat Chat `json:"chat"` From User `json:"from"` @@ -180,6 +240,8 @@ type ChatJoinRequest struct { InviteLink *ChatInviteLink `json:"invite_link,omitempty"` } +// Location represents a point on the map. +// See https://core.telegram.org/bots/api#location type Location struct { Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` @@ -188,12 +250,17 @@ type Location struct { Heading int `json:"heading"` ProximityAlertRadius int `json:"proximity_alert_radius"` } + +// LocationAddress represents a human-readable address of a location. type LocationAddress struct { CountryCode string `json:"country_code"` State *string `json:"state,omitempty"` City *string `json:"city,omitempty"` Street *string `json:"street,omitempty"` } + +// Venue represents a venue. +// See https://core.telegram.org/bots/api#venue type Venue struct { Location Location `json:"location"` Title string `json:"title"` @@ -204,22 +271,25 @@ type Venue struct { GooglePlaceType string `json:"google_place_type,omitempty"` } +// WebAppInfo contains information about a Web App. +// See https://core.telegram.org/bots/api#webappinfo type WebAppInfo struct { URL string `json:"url"` } +// StarAmount represents an amount of Telegram Stars. type StarAmount struct { Amount int `json:"amount"` NanostarAmount int `json:"nanostar_amount"` } +// Story represents a story. type Story struct { Chat Chat `json:"chat"` ID int `json:"id"` } -// Gifts - +// AcceptedGiftTypes represents the types of gifts accepted by a user or chat. type AcceptedGiftTypes struct { UnlimitedGifts bool `json:"unlimited_gifts"` LimitedGifts bool `json:"limited_gifts"` @@ -228,6 +298,7 @@ type AcceptedGiftTypes struct { GiftsFromChannels bool `json:"gifts_from_channels"` } +// UniqueGiftColors represents color information for a unique gift. type UniqueGiftColors struct { ModelCustomEmojiID string `json:"model_custom_emoji_id"` SymbolCustomEmojiID string `json:"symbol_custom_emoji_id"` @@ -237,11 +308,14 @@ type UniqueGiftColors struct { DarkThemeOtherColors []int `json:"dark_theme_other_colors"` } +// GiftBackground represents the background of a gift. type GiftBackground struct { CenterColor int `json:"center_color"` EdgeColor int `json:"edge_color"` TextColor int `json:"text_color"` } + +// Gift represents a gift that can be sent. type Gift struct { ID string `json:"id"` Sticker Sticker `json:"sticker"` @@ -257,10 +331,13 @@ type Gift struct { UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"` PublisherChat *Chat `json:"publisher_chat,omitempty"` } + +// Gifts represents a list of gifts. type Gifts struct { Gifts []Gift `json:"gifts"` } +// OwnedGiftType represents the type of an owned gift. type OwnedGiftType string const ( @@ -268,13 +345,14 @@ const ( OwnedGiftUniqueType OwnedGiftType = "unique" ) +// OwnedGift represents a gift owned by a user or chat. type OwnedGift struct { Type OwnedGiftType `json:"type"` OwnerGiftID *string `json:"owner_gift_id,omitempty"` SendDate *int `json:"send_date,omitempty"` IsSaved *bool `json:"is_saved,omitempty"` - // Поля, характерные для "regular" + // Fields specific to "regular" type Gift Gift `json:"gift"` SenderUser User `json:"sender_user,omitempty"` Text string `json:"text,omitempty"` @@ -287,12 +365,13 @@ type OwnedGift struct { IsUpgradeSeparate *bool `json:"is_upgrade_separate,omitempty"` UniqueGiftNumber *int `json:"unique_gift_number,omitempty"` - // Поля, характерные для "unique" + // Fields specific to "unique" type CanBeTransferred *bool `json:"can_be_transferred,omitempty"` TransferStarCount *int `json:"transfer_star_count,omitempty"` NextTransferDate *int `json:"next_transfer_date,omitempty"` } +// OwnedGifts represents a list of owned gifts with pagination. type OwnedGifts struct { TotalCount int `json:"total_count"` Gifts []OwnedGift `json:"gifts"` diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index 30931fd..892674d 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -1,5 +1,7 @@ package tgapi +// UploadPhotoP holds parameters for uploading a photo using the Uploader. +// See https://core.telegram.org/bots/api#sendphoto type UploadPhotoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -22,11 +24,16 @@ type UploadPhotoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// UploadPhoto uploads a photo and sends it as a message. +// file is the photo file to upload. +// See https://core.telegram.org/bots/api#sendphoto func (u *Uploader) UploadPhoto(params UploadPhotoP, file UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file) return req.Do(u) } +// UploadAudioP holds parameters for uploading an audio file using the Uploader. +// See https://core.telegram.org/bots/api#sendaudio type UploadAudioP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -51,11 +58,16 @@ type UploadAudioP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// UploadAudio uploads an audio file and sends it as a message. +// files are the audio file(s) to upload (typically one file). +// See https://core.telegram.org/bots/api#sendaudio func (u *Uploader) UploadAudio(params UploadAudioP, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...) return req.Do(u) } +// UploadDocumentP holds parameters for uploading a document using the Uploader. +// See https://core.telegram.org/bots/api#senddocument type UploadDocumentP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -77,11 +89,16 @@ type UploadDocumentP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// UploadDocument uploads a document and sends it as a message. +// files are the document file(s) to upload (typically one file). +// See https://core.telegram.org/bots/api#senddocument func (u *Uploader) UploadDocument(params UploadDocumentP, files ...UploaderFile) (Message, error) { req := NewUploaderRequest[Message]("sendDocument", params, files...) return req.Do(u) } +// UploadVideoP holds parameters for uploading a video using the Uploader. +// See https://core.telegram.org/bots/api#sendvideo type UploadVideoP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -110,11 +127,16 @@ type UploadVideoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// UploadVideo uploads a video and sends it as a message. +// files are the video file(s) to upload (typically one file). +// See https://core.telegram.org/bots/api#sendvideo func (u *Uploader) UploadVideo(params UploadVideoP, files ...UploaderFile) (Message, error) { req := NewUploaderRequest[Message]("sendVideo", params, files...) return req.Do(u) } +// UploadAnimationP holds parameters for uploading an animation using the Uploader. +// See https://core.telegram.org/bots/api#sendanimation type UploadAnimationP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -141,11 +163,16 @@ type UploadAnimationP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// UploadAnimation uploads an animation (GIF or H.264/MPEG-4 AVC video without sound) and sends it as a message. +// files are the animation file(s) to upload (typically one file). +// See https://core.telegram.org/bots/api#sendanimation func (u *Uploader) UploadAnimation(params UploadAnimationP, files ...UploaderFile) (Message, error) { req := NewUploaderRequest[Message]("sendAnimation", params, files...) return req.Do(u) } +// UploadVoiceP holds parameters for uploading a voice note using the Uploader. +// See https://core.telegram.org/bots/api#sendvoice type UploadVoiceP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -167,11 +194,16 @@ type UploadVoiceP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// UploadVoice uploads a voice note and sends it as a message. +// files are the voice file(s) to upload (typically one file). +// See https://core.telegram.org/bots/api#sendvoice func (u *Uploader) UploadVoice(params UploadVoiceP, files ...UploaderFile) (Message, error) { req := NewUploaderRequest[Message]("sendVoice", params, files...) return req.Do(u) } +// UploadVideoNoteP holds parameters for uploading a video note (rounded video) using the Uploader. +// See https://core.telegram.org/bots/api#sendvideonote type UploadVideoNoteP struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` @@ -191,15 +223,23 @@ type UploadVideoNoteP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } +// UploadVideoNote uploads a video note (rounded video) and sends it as a message. +// files are the video note file(s) to upload (typically one file). +// See https://core.telegram.org/bots/api#sendvideonote func (u *Uploader) UploadVideoNote(params UploadVideoNoteP, files ...UploaderFile) (Message, error) { req := NewUploaderRequest[Message]("sendVideoNote", params, files...) return req.Do(u) } +// UploadChatPhotoP holds parameters for uploading a chat photo using the Uploader. +// See https://core.telegram.org/bots/api#setchatphoto type UploadChatPhotoP struct { ChatID int64 `json:"chat_id"` } +// UploadChatPhoto uploads a new chat photo. +// photo is the photo file to upload. +// See https://core.telegram.org/bots/api#setchatphoto func (u *Uploader) UploadChatPhoto(params UploadChatPhotoP, photo UploaderFile) (Message, error) { req := NewUploaderRequest[Message]("sendChatPhoto", params, photo) return req.Do(u) diff --git a/tgapi/users_methods.go b/tgapi/users_methods.go index a2cad10..637c316 100644 --- a/tgapi/users_methods.go +++ b/tgapi/users_methods.go @@ -1,38 +1,53 @@ package tgapi +// GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method. +// See https://core.telegram.org/bots/api#getuserprofilephotos type GetUserProfilePhotosP struct { UserID int `json:"user_id"` Offset int `json:"offset,omitempty"` Limit int `json:"limit,omitempty"` } +// GetUserProfilePhotos returns a list of profile pictures for a user. +// See https://core.telegram.org/bots/api#getuserprofilephotos func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfilePhotos, error) { req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params) return req.Do(api) } +// GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method. +// See https://core.telegram.org/bots/api#getuserprofileaudios type GetUserProfileAudiosP struct { UserID int `json:"user_id"` Offset int `json:"offset,omitempty"` Limit int `json:"limit,omitempty"` } +// GetUserProfileAudios returns a list of profile audios for a user. +// See https://core.telegram.org/bots/api#getuserprofileaudios func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileAudios, error) { req := NewRequest[UserProfileAudios]("getUserProfileAudios", params) return req.Do(api) } +// SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method. +// See https://core.telegram.org/bots/api#setuseremojistatus type SetUserEmojiStatusP struct { UserID int `json:"user_id"` EmojiID string `json:"emoji_status_custom_emoji_id,omitempty"` ExpirationDate int `json:"emoji_status_expiration_date,omitempty"` } +// SetUserEmojiStatus sets a custom emoji status for a user. +// Returns true on success. +// See https://core.telegram.org/bots/api#setuseremojistatus func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) { req := NewRequest[bool]("setUserEmojiStatus", params) return req.Do(api) } +// GetUserGiftsP holds parameters for the GetUserGifts method. +// See https://core.telegram.org/bots/api#getusergifts type GetUserGiftsP struct { UserID int `json:"user_id"` ExcludeUnlimited bool `json:"exclude_unlimited,omitempty"` @@ -45,6 +60,8 @@ type GetUserGiftsP struct { Limit int `json:"limit,omitempty"` } +// GetUserGifts returns gifts owned by a user. +// See https://core.telegram.org/bots/api#getusergifts func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getUserGifts", params) return req.Do(api) diff --git a/tgapi/users_types.go b/tgapi/users_types.go index b826074..d086ce6 100644 --- a/tgapi/users_types.go +++ b/tgapi/users_types.go @@ -1,5 +1,7 @@ package tgapi +// User represents a Telegram user or bot. +// See https://core.telegram.org/bots/api#user type User struct { ID int `json:"id"` IsBot bool `json:"is_bot"` @@ -18,21 +20,31 @@ type User struct { AllowsUsersToCreateTopics *bool `json:"allows_users_to_create_topics,omitempty"` } +// UserProfilePhotos represents a user's profile photos. +// See https://core.telegram.org/bots/api#userprofilephotos type UserProfilePhotos struct { TotalCount int `json:"total_count"` Photos [][]PhotoSize `json:"photos"` } + +// UserProfileAudios represents a user's profile audios. +// See https://core.telegram.org/bots/api#userprofileaudios type UserProfileAudios struct { TotalCount int `json:"total_count"` Audios []Audio `json:"audios"` } +// UserRating represents a user's rating with level progression. +// See https://core.telegram.org/bots/api#userrating type UserRating struct { Level int `json:"level"` Rating int `json:"rating"` CurrentLevelRating int `json:"current_level_rating"` NextLevelRating int `json:"next_level_rating"` } + +// Birthdate represents a user's birthdate. +// See https://core.telegram.org/bots/api#birthdate type Birthdate struct { Day int `json:"day"` Month int `json:"month"` diff --git a/utils/version.go b/utils/version.go index ddcda17..834cb91 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.14" + VersionString = "1.0.0-beta.15" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 14 + VersionBeta = 15 ) -- 2.54.0 From c179a3f5f023d9018e2b586e957e67ef38d60a82 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 12 Mar 2026 18:04:46 +0300 Subject: [PATCH 34/99] v1.0.0 beta 16 --- bot.go | 1 + handler.go | 14 +++++++------- utils/version.go | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/bot.go b/bot.go index 5a41045..2a0daa5 100644 --- a/bot.go +++ b/bot.go @@ -257,6 +257,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { bot := &Bot[T]{ updateOffset: 0, errorTemplate: "%s", + payloadType: BotPayloadBase64, updateQueue: updateQueue, api: api, uploader: uploader, diff --git a/handler.go b/handler.go index a9730eb..a1ea281 100644 --- a/handler.go +++ b/handler.go @@ -148,13 +148,6 @@ func encodeBase64Payload(d CallbackData) (string, error) { base64.StdEncoding.Encode(dst, []byte(data)) return string(dst), nil } -func decodeBase64Payload(s string) (CallbackData, error) { - b, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return CallbackData{}, err - } - return decodeJsonPayload(string(b)) -} // func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) { // switch payloadType { @@ -165,6 +158,13 @@ func decodeBase64Payload(s string) (CallbackData, error) { // } // return "", ErrInvalidPayloadType // } +func decodeBase64Payload(s string) (CallbackData, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return CallbackData{}, err + } + return decodeJsonPayload(string(b)) +} func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) { switch payloadType { case BotPayloadBase64: diff --git a/utils/version.go b/utils/version.go index 834cb91..3ef653f 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.15" + VersionString = "1.0.0-beta.16" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 15 + VersionBeta = 16 ) -- 2.54.0 From e4203e8fc00715a50df86ecd9f0faf28357c3ae9 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 13 Mar 2026 09:58:06 +0300 Subject: [PATCH 35/99] v1.0.0 beta 17; removed examples --- README.md | 21 +++++++++++++-------- README_RU.md | 13 +++++++++---- examples/basic/.env | 7 ------- examples/basic/example.go | 30 ------------------------------ examples/basic/go.mod | 20 -------------------- examples/basic/go.sum | 19 ------------------- utils/version.go | 4 ++-- 7 files changed, 24 insertions(+), 90 deletions(-) delete mode 100644 examples/basic/.env delete mode 100644 examples/basic/example.go delete mode 100644 examples/basic/go.mod delete mode 100644 examples/basic/go.sum diff --git a/README.md b/README.md index 5af984b..37cdfa5 100644 --- a/README.md +++ b/README.md @@ -124,15 +124,21 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) { Provides access to the incoming message and useful reply methods: -- `Answer(text string)`: Sends a plain text message, automatically escaping MarkdownV2. -- `AnswerMarkdown(text string)`: Sends a message formatted with MarkdownV2 (you handle escaping). -- `AnswerText(text string)`: Sends a message with no parse_mode. -- `SendChatAction(action string)`: Sends a "typing", "uploading photo", etc., action. +- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none. +- `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping). +- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard. +- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. +- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. +- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) with. +- `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button. +- `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button. +- `SendChatAction(action string)`: Sends a “typing”, “uploading photo”, etc., action. - Fields: `Text`, `Args`, `From`, `Chat`, `Msg`, etc. +- And more methods and fields! ### Database Context -The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool) and it will be available in every command and middleware handler. +The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool), and it will be available in every command and middleware handler. ```go type MyDB struct { /* ... */ } @@ -185,16 +191,15 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { ### Important Notes - Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. -- If you need to run code after a command, you can call it from within the command itself or use a defer statement inside the middleware that wraps the next call (more advanced). ## ⚙️ Advanced Configuration -- **Inline Keyboards**: Build keyboards using laniakea.NewKeyboard() and AddRow(). +- **Inline Keyboards**: Build keyboards using laniakea.NewKeyboard(). - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control. ## 📝 License -This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. +This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details. ## 📚 Learn More [GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) diff --git a/README_RU.md b/README_RU.md index 000fe90..44b8acc 100644 --- a/README_RU.md +++ b/README_RU.md @@ -124,11 +124,17 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) { ### Контекст сообщения (MsgContext) Предоставляет доступ к входящему сообщению и полезные методы для ответа: -- `Answer(text string)`: Отправляет обычный текст, автоматически экранируя MarkdownV2. +- `Answer(text string)`: Отправляет сообщение с parse_mode none. - `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне). -- `AnswerText(text string)`: Отправляет сообщение без parse_mode. +- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой. +- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. +- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. +- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). +- `EditCallback(text string)`: Редактирует сообщение, форматируя его в MarkdownV2 (экранирование на вашей стороне), после нажатия Inline кнопки. +- `EditCallbackMarkdown(text string)`: Редактирует сообщение с parse_mode none после нажатия Inline кнопки. - `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д. - Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие. +- И много других методов и полей! ### Контекст базы данных (Database Context) Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件. @@ -184,10 +190,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { ### Важные замечания - Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. -- Если нужно выполнить код после команды, это можно сделать внутри самой команды или использовать отложенный вызов (defer) в middleware, который оборачивает следующий вызов (более продвинутый подход). ## ⚙️ Расширенная настройка -**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard() и AddRow(). +**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard(). **Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram. **Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля. diff --git a/examples/basic/.env b/examples/basic/.env deleted file mode 100644 index 2c2ca0b..0000000 --- a/examples/basic/.env +++ /dev/null @@ -1,7 +0,0 @@ -TG_TOKEN= -PREFIXES=/;! -DEBUG=true -USE_REQ_LOG=true -WRITE_TO_FILE=false -USE_TEST_SERVER=true -API_URL=http://127.0.0.1:8081 \ No newline at end of file diff --git a/examples/basic/example.go b/examples/basic/example.go deleted file mode 100644 index f8c5b41..0000000 --- a/examples/basic/example.go +++ /dev/null @@ -1,30 +0,0 @@ -package main - -import ( - "log" - - "git.nix13.pw/scuroneko/laniakea" -) - -func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { - ctx.Answer(ctx.Text) // User input WITHOUT command -} - -func main() { - opts := &laniakea.BotOpts{Token: "TOKEN"} - bot := laniakea.NewBot[laniakea.NoDB](opts) - defer bot.Close() - - p := laniakea.NewPlugin[laniakea.NoDB]("ping") - p.AddCommand(p.NewCommand(echo, "echo")) - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { - ctx.Answer("Pong") - }, "ping")) - - bot = bot.ErrorTemplate("Error\n\n%s").AddPlugins(p) - - if err := bot.AutoGenerateCommands(); err != nil { - log.Println(err) - } - bot.Run() -} diff --git a/examples/basic/go.mod b/examples/basic/go.mod deleted file mode 100644 index e957faf..0000000 --- a/examples/basic/go.mod +++ /dev/null @@ -1,20 +0,0 @@ -module example/basic - -go 1.26.1 - -require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.14 - -replace ( - git.nix13.pw/scuroneko/laniakea v1.0.0-beta.14 => ../../ -) - -require ( - git.nix13.pw/scuroneko/extypes v1.2.1 // indirect - git.nix13.pw/scuroneko/slog v1.0.2 // indirect - github.com/alitto/pond/v2 v2.7.0 // indirect - github.com/fatih/color v1.18.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/time v0.15.0 // indirect -) diff --git a/examples/basic/go.sum b/examples/basic/go.sum deleted file mode 100644 index 0b840ff..0000000 --- a/examples/basic/go.sum +++ /dev/null @@ -1,19 +0,0 @@ -git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5/RpQ= -git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= -git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13 h1:mRVxYh7CNrm8ccob+u6XxLzZRbs1fLNRg/nXaXY78yw= -git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13/go.mod h1:M8jwm195hzAl9bj9Bkl95WfHmWvuBX6micsdtOs/gmE= -git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= -git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= -github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= -github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/utils/version.go b/utils/version.go index 3ef653f..477125d 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.16" + VersionString = "1.0.0-beta.17" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 16 + VersionBeta = 17 ) -- 2.54.0 From 6ba8520bb7ca36130b9f3f5f25e5b994043cbf3d Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 13 Mar 2026 11:24:13 +0300 Subject: [PATCH 36/99] v1.0.0 beta 18 --- bot.go | 16 ++++++++++++---- cmd_generator.go | 9 +++++---- drafts.go | 17 ++++++++++++++++- handler.go | 11 +++++++++-- msg_context.go | 19 ++++++++++++++----- plugins.go | 31 ++++++++++++++++++++++--------- runners.go | 27 ++++++++++++++++++--------- utils/limiter.go | 4 +++- utils/version.go | 4 ++-- 9 files changed, 101 insertions(+), 37 deletions(-) diff --git a/bot.go b/bot.go index 2a0daa5..69f60a1 100644 --- a/bot.go +++ b/bot.go @@ -163,7 +163,7 @@ func LoadPrefixesFromEnv() []string { // bot := NewBot[MyDB](opts).DatabaseContext(&myDB) // // Use NoDB if no database is needed. -type DbContext interface{} +type DbContext any // NoDB is a placeholder type for bots that do not use a database. // Use Bot[NoDB] to indicate no dependency injection is required. @@ -599,12 +599,18 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { return } - bot.ExecRunners() + bot.ExecRunners(ctx) bot.logger.Infoln("Bot running. Press CTRL+C to exit.") // Start update polling in a goroutine go func() { + defer func() { + if r := recover(); r != nil { + bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r)) + } + close(bot.updateQueue) + }() for { select { case <-ctx.Done(): @@ -618,6 +624,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { } for _, u := range updates { + u := u // copy loop variable to avoid race condition select { case bot.updateQueue <- &u: case <-ctx.Done(): @@ -631,11 +638,12 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { // Start worker pool for concurrent update handling pool := pond.NewPool(16) for update := range bot.updateQueue { - update := update // capture loop variable + u := update // capture loop variable pool.Submit(func() { - bot.handle(update) + bot.handle(u) }) } + pool.Stop() // Wait for all tasks to complete and stop the pool } // Run starts the bot using a background context. diff --git a/cmd_generator.go b/cmd_generator.go index 3f797ad..3f2e0a8 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -41,6 +41,8 @@ var ErrTooManyCommands = errors.New("too many commands. max 100") // // Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}} // → Description: "Start the bot. Usage: /start [name]" +// Command{command: "echo", description: "Echo user input", args: []Arg{{text: "name", required: true}}} +// → Description: "Echo user input. Usage: /echo " func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { desc := "" if len(cmd.description) > 0 { @@ -50,16 +52,15 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { var descArgs []string for _, a := range cmd.args { if a.required { - descArgs = append(descArgs, a.text) + descArgs = append(descArgs, fmt.Sprintf("<%s>", a.text)) } else { descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text)) } } + usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " ")) if desc != "" { - desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " ")) - } else { - desc = fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " ")) + desc = fmt.Sprintf("%s. %s", desc, usage) } return tgapi.BotCommand{Command: cmd.command, Description: desc} } diff --git a/drafts.go b/drafts.go index e9d93f2..5db19fe 100644 --- a/drafts.go +++ b/drafts.go @@ -30,6 +30,7 @@ package laniakea import ( "math/rand/v2" + "sync" "sync/atomic" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -68,6 +69,7 @@ func (g *LinearDraftIdGenerator) Next() uint64 { // DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines // requires external synchronization. type DraftProvider struct { + mu sync.RWMutex api *tgapi.API drafts map[uint64]*Draft generator draftIdGenerator @@ -139,6 +141,8 @@ func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvid // // Returns the draft and true if found, or nil and false if not found. func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) { + p.mu.RLock() + defer p.mu.RUnlock() draft, ok := p.drafts[id] return draft, ok } @@ -150,8 +154,15 @@ func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) { // // After successful flush, each draft is removed from the provider and cleared. func (p *DraftProvider) FlushAll() error { - var lastErr error + p.mu.RLock() + drafts := make([]*Draft, 0, len(p.drafts)) for _, draft := range p.drafts { + drafts = append(drafts, draft) + } + p.mu.RUnlock() + + var lastErr error + for _, draft := range drafts { if err := draft.Flush(); err != nil { lastErr = err break // Stop on first error to avoid partial state @@ -201,7 +212,9 @@ func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { ID: id, Message: "", } + p.mu.Lock() p.drafts[id] = draft + p.mu.Unlock() return draft } @@ -253,7 +266,9 @@ func (d *Draft) Clear() { // want to cancel a draft without sending it. func (d *Draft) Delete() { if d.provider != nil { + d.provider.mu.Lock() delete(d.provider.drafts, d.ID) + d.provider.mu.Unlock() } d.Clear() } diff --git a/handler.go b/handler.go index a1ea281..23ad408 100644 --- a/handler.go +++ b/handler.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "strings" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -12,6 +13,12 @@ import ( var ErrInvalidPayloadType = errors.New("invalid payload type") func (bot *Bot[T]) handle(u *tgapi.Update) { + defer func() { + if r := recover(); r != nil { + bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) + } + }() + ctx := &MsgContext{ Update: *u, Api: bot.api, botLogger: bot.logger, @@ -84,7 +91,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { if !plugin.executeMiddlewares(ctx, bot.dbContext) { return } - go plugin.executeCmd(cmd, ctx, bot.dbContext) + plugin.executeCmd(cmd, ctx, bot.dbContext) return } } @@ -113,7 +120,7 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { if !plugin.executeMiddlewares(ctx, bot.dbContext) { return } - go plugin.executePayload(data.Command, ctx, bot.dbContext) + plugin.executePayload(data.Command, ctx, bot.dbContext) return } } diff --git a/msg_context.go b/msg_context.go index 276df29..5c2785e 100644 --- a/msg_context.go +++ b/msg_context.go @@ -22,6 +22,7 @@ package laniakea import ( "context" "fmt" + "time" "git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/slog" @@ -31,10 +32,12 @@ import ( // 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 - Msg *tgapi.Message - Update tgapi.Update - From *tgapi.User + Api *tgapi.API + Update tgapi.Update + + Msg *tgapi.Message + From *tgapi.User + CallbackMsgId int CallbackQueryId string FromID int @@ -385,7 +388,13 @@ func (ctx *MsgContext) error(err error) { func (ctx *MsgContext) Error(err error) { ctx.error(err) } func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { - c := context.Background() + 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 diff --git a/plugins.go b/plugins.go index 6255fe2..6f783f7 100644 --- a/plugins.go +++ b/plugins.go @@ -33,11 +33,14 @@ const ( CommandValueAnyType CommandValueType = "any" ) -// CommandRegexInt matches one or more digits. -var CommandRegexInt = regexp.MustCompile(`\d+`) - -// CommandRegexString matches any non-empty string. -var CommandRegexString = regexp.MustCompile(".+") +var ( + // CommandRegexInt matches one or more digits. + CommandRegexInt = regexp.MustCompile(`\d+`) + // CommandRegexString matches any non-empty string. + CommandRegexString = regexp.MustCompile(`.+`) + // CommandRegexBool matches true or false + CommandRegexBool = regexp.MustCompile(`true|false`) +) // ErrCmdArgCountMismatch is returned when the number of provided arguments // is less than the number of required arguments. @@ -58,15 +61,22 @@ type CommandArg struct { // NewCommandArg creates a new CommandArg with the given text and type. // Uses a default regex based on the type (string or int). // For CommandValueAnyType, no validation is performed. -func NewCommandArg(text string, valueType CommandValueType) *CommandArg { +func NewCommandArg(text string) *CommandArg { + return &CommandArg{CommandValueAnyType, text, CommandRegexString, false} +} + +func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { regex := CommandRegexString - switch valueType { + switch t { case CommandValueIntType: regex = CommandRegexInt + case CommandValueBoolType: + regex = CommandRegexBool case CommandValueAnyType: regex = nil // Skip validation } - return &CommandArg{valueType, text, regex, false} + c.regex = regex + return c } // SetRequired marks this argument as required. @@ -320,7 +330,10 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { // Otherwise, returns the result of the executor. func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool { if m.async { - go m.executor(ctx, db) + ctx := *ctx // copy context to avoid race condition + go func(ctx MsgContext) { + m.executor(&ctx, db) + }(ctx) return true } return m.executor(ctx, db) diff --git a/runners.go b/runners.go index 0774a37..98bc359 100644 --- a/runners.go +++ b/runners.go @@ -11,6 +11,7 @@ package laniakea import ( + "context" "time" ) @@ -83,7 +84,7 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { return r } -// ExecRunners executes all runners registered on the Bot. +// ExecRunners executes all runners registered on the Bot with context-based lifecycle management. // // It logs warnings for misconfigured runners: // - Sync, non-onetime runners are skipped (invalid configuration). @@ -92,11 +93,13 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { // Execution logic: // - onetime + async: Runs once in a goroutine. // - onetime + sync: Runs once synchronously; warns if slower than 2 seconds. -// - !onetime + async: Runs in an infinite loop with timeout between iterations. +// - !onetime + async: Runs in a loop with timeout between iterations until ctx.Done(). // - !onetime + sync: Skipped with warning. // -// This method is typically called once during bot startup. -func (bot *Bot[T]) ExecRunners() { +// Background runners listen for ctx.Done() and gracefully shut down when the context is canceled. +// +// This method is typically called once during bot startup in RunWithContext. +func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { // Validate configuration @@ -128,14 +131,20 @@ func (bot *Bot[T]) ExecRunners() { bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) } } else if !runner.onetime && runner.async { - // Background loop: periodic execution + // Background loop: periodic execution with graceful shutdown go func(r Runner[T]) { + ticker := time.NewTicker(r.timeout) + defer ticker.Stop() for { - err := r.fn(bot) - if err != nil { - bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) + select { + case <-ctx.Done(): + return + case <-ticker.C: + err := r.fn(bot) + if err != nil { + bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) + } } - time.Sleep(r.timeout) } }(runner) } diff --git a/utils/limiter.go b/utils/limiter.go index 65ce2c6..02c7330 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -193,8 +193,10 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro // getChatLimiter returns the rate limiter for the given chat, creating it if needed. // Uses 1 request per second with burst of 1 — conservative for per-user limits. -// Must be called with rl.chatMu held. func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { + rl.chatMu.Lock() + defer rl.chatMu.Unlock() + if lim, ok := rl.chatLimiters[chatID]; ok { return lim } diff --git a/utils/version.go b/utils/version.go index 477125d..3bf3187 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.17" + VersionString = "1.0.0-beta.18" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 17 + VersionBeta = 18 ) -- 2.54.0 From 5976fcd0b8583d0c28bf4e247329175f223129e7 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 13 Mar 2026 12:25:53 +0300 Subject: [PATCH 37/99] v1.0.0 beta 19 --- cmd_generator.go | 76 ++++++++++++++++++++++++++++++++++++------------ drafts.go | 62 +++++++++------------------------------ methods.go | 32 ++++++++++++++++++++ utils/version.go | 4 +-- 4 files changed, 106 insertions(+), 68 deletions(-) diff --git a/cmd_generator.go b/cmd_generator.go index 3f2e0a8..b29de40 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -61,23 +61,22 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " ")) if desc != "" { desc = fmt.Sprintf("%s. %s", desc, usage) + return tgapi.BotCommand{Command: cmd.command, Description: desc} } - return tgapi.BotCommand{Command: cmd.command, Description: desc} + return tgapi.BotCommand{Command: cmd.command, Description: usage} } // checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+ -// Return true if satisfy, else false. -func checkCmdRegex(cmd string) bool { - return CmdRegexp.MatchString(cmd) -} +// Return true if satisfied, else false. +func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) } -// generateBotCommandForPlugin collects all non-skipped commands from a Plugin[T] +// gatherCommandsForPlugin collects all non-skipped commands from a Plugin[T] // and converts them into tgapi.BotCommand objects. // // Commands marked with skipAutoCmd = true are excluded from auto-registration. // This allows plugins to opt out of automatic command generation (e.g., for // internal or hidden commands). -func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { +func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, cmd := range pl.commands { if cmd.skipAutoCmd { @@ -91,6 +90,21 @@ func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { return commands } +// gatherCommands collects all commands from all plugins +// and converts them into tgapi.BotCommand objects. +// See gatherCommandsForPlugin +func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { + commands := make([]tgapi.BotCommand, 0) + for _, pl := range bot.plugins { + if pl.skipAutoCmd { + continue + } + commands = append(commands, gatherCommandsForPlugin(pl)...) + bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name)) + } + return commands +} + // AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API // across three scopes: // - Private chats (users) @@ -120,17 +134,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error { return fmt.Errorf("failed to delete existing commands: %w", err) } - // Collect all non-skipped commands from all plugins - commands := make([]tgapi.BotCommand, 0) - for _, pl := range bot.plugins { - if pl.skipAutoCmd { - continue - } - commands = append(commands, generateBotCommandForPlugin(pl)...) - bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name)) - } - - // Enforce Telegram's 100-command limit + commands := gatherCommands(bot) if len(commands) > 100 { return ErrTooManyCommands } @@ -154,3 +158,39 @@ func (bot *Bot[T]) AutoGenerateCommands() error { return nil } + +// AutoGenerateCommandsForScope registers all plugin-defined commands with Telegram's Bot API +// for the specified command scope. It first deletes any existing commands in that scope +// to ensure a clean state, then sets the new set of commands. +// +// The scope parameter defines where the commands should be available (e.g., private chats, +// group chats, chat administrators). See tgapi.BotCommandScope and its predefined types. +// +// Returns ErrTooManyCommands if the total number of commands exceeds 100. +// Returns any API error from Telegram (e.g., network issues, invalid scope). +// +// Usage: +// +// privateScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType} +// if err := bot.AutoGenerateCommandsForScope(privateScope); err != nil { +// log.Fatal(err) +// } +func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error { + _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope}) + if err != nil { + return fmt.Errorf("failed to delete existing commands: %w", err) + } + commands := gatherCommands(bot) + if len(commands) > 100 { + return ErrTooManyCommands + } + + _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{ + Commands: commands, + Scope: scope, + }) + if err != nil { + return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err) + } + return nil +} diff --git a/drafts.go b/drafts.go index 5db19fe..445d3cd 100644 --- a/drafts.go +++ b/drafts.go @@ -29,6 +29,7 @@ package laniakea import ( + "errors" "math/rand/v2" "sync" "sync/atomic" @@ -36,6 +37,8 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +var ErrDraftChatIDZero = errors.New("zero draft chat ID") + // draftIdGenerator defines an interface for generating unique draft IDs. type draftIdGenerator interface { // Next returns the next unique draft ID. @@ -73,12 +76,6 @@ type DraftProvider struct { api *tgapi.API drafts map[uint64]*Draft generator draftIdGenerator - - // Internal defaults — not exposed directly to users. - chatID int64 - messageThreadID int - parseMode tgapi.ParseMode - entities []tgapi.MessageEntity } // NewRandomDraftProvider creates a new DraftProvider using random draft IDs. @@ -109,34 +106,6 @@ func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { } } -// SetChat sets the target chat and optional message thread for all drafts created -// by this provider. Must be called before NewDraft(). -// -// If not set, NewDraft() will create drafts with zero chatID, which will cause -// SendMessageDraft to fail. Use this method to avoid runtime errors. -func (p *DraftProvider) SetChat(chatID int64, messageThreadID int) *DraftProvider { - p.chatID = chatID - p.messageThreadID = messageThreadID - return p -} - -// SetParseMode sets the default parse mode for all new drafts. -// Overrides the parse mode passed to NewDraft() only if not specified there. -func (p *DraftProvider) SetParseMode(mode tgapi.ParseMode) *DraftProvider { - p.parseMode = mode - return p -} - -// SetEntities sets the default message entities (e.g., bold, links, mentions) -// to be copied into every new draft. -// -// Entities are shallow-copied — if you mutate the slice later, it will affect -// future drafts. For safety, pass a copy if needed. -func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvider { - p.entities = entities - return p -} - // GetDraft retrieves a draft by its ID. // // Returns the draft and true if found, or nil and false if not found. @@ -154,12 +123,13 @@ func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) { // // After successful flush, each draft is removed from the provider and cleared. func (p *DraftProvider) FlushAll() error { - p.mu.RLock() + p.mu.Lock() drafts := make([]*Draft, 0, len(p.drafts)) for _, draft := range p.drafts { drafts = append(drafts, draft) } - p.mu.RUnlock() + p.drafts = make(map[uint64]*Draft) + p.mu.Unlock() var lastErr error for _, draft := range drafts { @@ -197,20 +167,13 @@ type Draft struct { // // Panics if chatID is zero — call SetChat() on the provider first. func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { - if p.chatID == 0 { - panic("laniakea: DraftProvider.SetChat() must be called before NewDraft()") - } - id := p.generator.Next() draft := &Draft{ - api: p.api, - provider: p, - chatID: p.chatID, - messageThreadID: p.messageThreadID, - parseMode: parseMode, - entities: p.entities, // Shallow copy — caller must ensure immutability - ID: id, - Message: "", + api: p.api, + provider: p, + parseMode: parseMode, + ID: id, + Message: "", } p.mu.Lock() p.drafts[id] = draft @@ -310,6 +273,9 @@ func (d *Draft) Flush() error { // push is the internal helper for Push(). It updates the server draft via SendMessageDraft. func (d *Draft) push(text string) error { + if d.chatID == 0 { + return ErrDraftChatIDZero + } d.Message += text params := tgapi.SendMessageDraftP{ ChatID: d.chatID, diff --git a/methods.go b/methods.go index 21f9e46..f5eb41d 100644 --- a/methods.go +++ b/methods.go @@ -6,6 +6,38 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// Updates fetches new updates from Telegram API using long polling. +// It respects the bot's current update offset and automatically advances it +// after successful retrieval. The method supports selective update types +// through AllowedUpdates and includes optional request logging. +// +// Parameters: +// - None (uses bot's internal state for offset and allowed updates) +// +// Returns: +// - []tgapi.Update: slice of received updates (empty if none available) +// - error: any error encountered during the API call +// +// Behavior: +// 1. Uses the bot's current update offset (via GetUpdateOffset) +// 2. Requests updates with 30-second timeout +// 3. Filters updates by types specified in bot.GetUpdateTypes() +// 4. Logs raw update JSON if RequestLogger is configured +// 5. Automatically updates the offset to the last received update ID + 1 +// 6. Returns all received updates (empty slice if none) +// +// Note: This is a blocking call that waits up to 30 seconds for new updates. +// For non-blocking behavior, consider using webhooks instead. +// +// Example: +// +// updates, err := bot.Updates() +// if err != nil { +// log.Fatal(err) +// } +// for _, update := range updates { +// // process update +// } func (bot *Bot[T]) Updates() ([]tgapi.Update, error) { offset := bot.GetUpdateOffset() params := tgapi.UpdateParams{ diff --git a/utils/version.go b/utils/version.go index 3bf3187..1ec09b6 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.18" + VersionString = "1.0.0-beta.19" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 18 + VersionBeta = 19 ) -- 2.54.0 From 589e11b22d1a871221435d54bf77ede5122a1bf9 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 13 Mar 2026 13:25:26 +0300 Subject: [PATCH 38/99] docs fix --- bot.go | 206 ++++++++---------------------------------- bot_opts.go | 226 +++++++++++++++++++++++++++++++++++++++++++++++ cmd_generator.go | 9 -- doc.go | 55 ++++++++++++ drafts.go | 28 ------ keyboard.go | 10 --- l10n.go | 10 --- msg_context.go | 19 ---- plugins.go | 12 --- runners.go | 10 --- tgapi/types.go | 2 +- 11 files changed, 319 insertions(+), 268 deletions(-) create mode 100644 bot_opts.go create mode 100644 doc.go diff --git a/bot.go b/bot.go index 69f60a1..57ec81e 100644 --- a/bot.go +++ b/bot.go @@ -1,41 +1,9 @@ -// Package laniakea provides a modular, extensible framework for building scalable -// Telegram bots with support for plugins, middleware, localization, draft messages, -// rate limiting, structured logging, and dependency injection. -// -// The framework is designed around a fluent API for configuration and separation of concerns: -// -// - Plugins: Handle specific commands or events (e.g., /start, /help) -// - Middleware: Intercept and modify updates before plugins run (auth, logging, validation) -// - Runners: Background goroutines for cleanup, cron jobs, or monitoring -// - DraftProvider: Safely build and resume multi-step messages -// - L10n: Multi-language support via key-based translation -// - RateLimiter: Enforces Telegram API limits to avoid bans -// - Structured Logging: JSON stdout + optional file output with request-level tracing -// - Dependency Injection: Inject custom database contexts (e.g., *gorm.DB, *sql.DB) -// -// Example usage: -// -// bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()). -// DatabaseContext(&myDB). -// AddUpdateType(tgapi.UpdateTypeMessage). -// AddPrefixes("/", "!"). -// AddPlugins(&startPlugin, &helpPlugin). -// AddMiddleware(&authMiddleware, &logMiddleware). -// AddRunner(&cleanupRunner). -// AddL10n(l10n.New()) -// -// go bot.Run() -// -// All methods are thread-safe except direct field access. Use provided accessors -// (e.g., GetDBContext, SetUpdateOffset) for safe concurrent access. package laniakea import ( "context" "fmt" - "os" "sort" - "strconv" "strings" "sync" "time" @@ -47,113 +15,6 @@ import ( "github.com/alitto/pond/v2" ) -// BotOpts holds configuration options for initializing a Bot. -// -// Values are loaded from environment variables via LoadOptsFromEnv(). -// Use NewOpts() to create a zero-value struct and set fields manually. -type BotOpts struct { - // Token is the Telegram bot token (required). - Token string - - // UpdateTypes is a semicolon-separated list of update types to listen for. - // Example: "message;edited_message;callback_query" - // Defaults to empty (Telegram will return all types). - UpdateTypes []string - - // Debug enables debug-level logging. - Debug bool - - // ErrorTemplate is the format string used to wrap error messages sent to users. - // Use "%s" to insert the actual error. Example: "❌ Error: %s" - ErrorTemplate string - - // Prefixes is a list of command prefixes (e.g., ["/", "!"]). - // Defaults to ["/"] if not set via environment. - Prefixes []string - - // LoggerBasePath is the directory where log files are written. - // Defaults to "./". - LoggerBasePath string - - // UseRequestLogger enables detailed logging of all Telegram API requests. - UseRequestLogger bool - - // WriteToFile enables writing logs to files (main.log and requests.log). - WriteToFile bool - - // UseTestServer uses Telegram's test server (https://api.test.telegram.org). - UseTestServer bool - - // APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted). - APIUrl string - - // RateLimit is the maximum number of API requests per second. - // Telegram allows up to 30 req/s for most bots. Defaults to 30. - RateLimit int - - // DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing. - // Use this to prioritize responsiveness over reliability. - DropRLOverflow bool -} - -// NewOpts returns a new BotOpts with zero values. -func NewOpts() *BotOpts { return new(BotOpts) } - -// LoadOptsFromEnv loads BotOpts from environment variables. -// -// Environment variables: -// - TG_TOKEN: Bot token (required) -// - UPDATE_TYPES: semicolon-separated update types (e.g., "message;callback_query") -// - DEBUG: "true" to enable debug logging -// - ERROR_TEMPLATE: format string for error messages (e.g., "❌ %s") -// - PREFIXES: semicolon-separated prefixes (e.g., "/;!bot") -// - LOGGER_BASE_PATH: directory for log files (default: "./") -// - USE_REQ_LOG: "true" to enable request logging -// - WRITE_TO_FILE: "true" to write logs to files -// - USE_TEST_SERVER: "true" to use Telegram test server -// - API_URL: custom API endpoint -// - RATE_LIMIT: max requests per second (default: 30) -// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow -// -// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined. -func LoadOptsFromEnv() *BotOpts { - rateLimit := 30 - if rl := os.Getenv("RATE_LIMIT"); rl != "" { - if n, err := strconv.Atoi(rl); err == nil { - rateLimit = n - } - } - - return &BotOpts{ - Token: os.Getenv("TG_TOKEN"), - UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"), - - Debug: os.Getenv("DEBUG") == "true", - ErrorTemplate: os.Getenv("ERROR_TEMPLATE"), - Prefixes: LoadPrefixesFromEnv(), - - LoggerBasePath: os.Getenv("LOGGER_BASE_PATH"), - UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true", - WriteToFile: os.Getenv("WRITE_TO_FILE") == "true", - - UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", - APIUrl: os.Getenv("API_URL"), - - RateLimit: rateLimit, - DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", - } -} - -// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon. -// Defaults to ["/"] if not set. -func LoadPrefixesFromEnv() []string { - prefixesS, exists := os.LookupEnv("PREFIXES") - if !exists { - return []string{"/"} - } - return strings.Split(prefixesS, ";") -} - // DbContext is an interface representing the application's database context. // It is injected into plugins and middleware via Bot.DatabaseContext(). // @@ -169,6 +30,10 @@ type DbContext any // Use Bot[NoDB] to indicate no dependency injection is required. type NoDB struct{ DbContext } +// DbLogger is a function type that returns a slog.LoggerWriter for database logging. +// Used to inject database-specific log output (e.g., SQL queries, ORM events). +type DbLogger[T DbContext] func(db *T) slog.LoggerWriter + // BotPayloadType defines the serialization format for callback data payloads. type BotPayloadType string @@ -195,6 +60,7 @@ type Bot[T DbContext] struct { errorTemplate string username string payloadType BotPayloadType + maxWorkers int logger *slog.Logger // Main bot logger (JSON stdout + optional file) RequestLogger *slog.Logger // Optional request-level API logging @@ -254,10 +120,16 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { prefixes = []string{"/"} } + workers := 32 + if opts.MaxWorkers > 0 { + workers = opts.MaxWorkers + } + bot := &Bot[T]{ updateOffset: 0, errorTemplate: "%s", payloadType: BotPayloadBase64, + maxWorkers: workers, updateQueue: updateQueue, api: api, uploader: uploader, @@ -398,34 +270,6 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { return bot } -// DbLogger is a function type that returns a slog.LoggerWriter for database logging. -// Used to inject database-specific log output (e.g., SQL queries, ORM events). -type DbLogger[T DbContext] func(db *T) slog.LoggerWriter - -// AddDatabaseLoggerWriter adds a database logger writer to all loggers. -// -// The writer will receive logs from: -// - Main bot logger -// - Request logger (if enabled) -// - API and Uploader loggers -// -// Example: -// -// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter { -// return db.QueryLogger() -// }) -func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { - w := writer(bot.dbContext) - bot.logger.AddWriter(w) - if bot.RequestLogger != nil { - bot.RequestLogger.AddWriter(w) - } - for _, l := range bot.extraLoggers { - l.AddWriter(w) - } - return bot -} - // DatabaseContext injects a database context into the bot. // This context is accessible to plugins and middleware via GetDBContext(). func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] { @@ -569,13 +413,37 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { return bot } +// AddDatabaseLoggerWriter adds a database logger writer to all loggers. +// +// The writer will receive logs from: +// - Main bot logger +// - Request logger (if enabled) +// - API and Uploader loggers +// +// Example: +// +// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter { +// return db.QueryLogger() +// }) +func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { + w := writer(bot.dbContext) + bot.logger.AddWriter(w) + if bot.RequestLogger != nil { + bot.RequestLogger.AddWriter(w) + } + for _, l := range bot.extraLoggers { + l.AddWriter(w) + } + return bot +} + // RunWithContext starts the bot with a given context for graceful shutdown. // // This is the main entry point for bot execution. It: // - Validates required configuration (prefixes, plugins) // - Starts all registered runners as background goroutines // - Begins polling for updates via Telegram's GetUpdates API -// - Processes updates concurrently using a worker pool (16 goroutines) +// - Processes updates concurrently using a worker pool with size configurable via BotOpts.MaxWorkers // // The context controls graceful shutdown. When canceled, the bot: // - Stops polling for new updates @@ -636,7 +504,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { }() // Start worker pool for concurrent update handling - pool := pond.NewPool(16) + pool := pond.NewPool(bot.maxWorkers) for update := range bot.updateQueue { u := update // capture loop variable pool.Submit(func() { diff --git a/bot_opts.go b/bot_opts.go new file mode 100644 index 0000000..d9ef743 --- /dev/null +++ b/bot_opts.go @@ -0,0 +1,226 @@ +package laniakea + +import ( + "os" + "strconv" + "strings" + + "git.nix13.pw/scuroneko/laniakea/tgapi" +) + +// BotOpts holds configuration options for initializing a Bot. +// +// Values are loaded from environment variables via LoadOptsFromEnv(). +// Use NewOpts() to create a zero-value struct and set fields manually. +type BotOpts struct { + // Token is the Telegram bot token (required). + Token string + + // UpdateTypes is a list of update types to listen for. + // Example: "["message", "edited_message", "callback_query"]" + // Defaults to empty (Telegram will return all types). + UpdateTypes []tgapi.UpdateType + + // Debug enables debug-level logging. + Debug bool + + // ErrorTemplate is the format string used to wrap error messages sent to users. + // Use "%s" to insert the actual error. Example: "❌ Error: %s" + ErrorTemplate string + + // Prefixes is a list of command prefixes (e.g., ["/", "!"]). + // Defaults to ["/"] if not set via environment. + Prefixes []string + + // LoggerBasePath is the directory where log files are written. + // Defaults to "./". + LoggerBasePath string + + // UseRequestLogger enables detailed logging of all Telegram API requests. + UseRequestLogger bool + + // WriteToFile enables writing logs to files (main.log and requests.log). + WriteToFile bool + + // UseTestServer uses Telegram's test server (https://api.test.telegram.org). + UseTestServer bool + + // APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted). + APIUrl string + + // RateLimit is the maximum number of API requests per second. + // Telegram allows up to 30 req/s for most bots. Defaults to 30. + RateLimit int + + // DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing. + // Use this to prioritize responsiveness over reliability. + DropRLOverflow bool + + // MaxWorkers is the maximum number of concurrency running update handlers. + MaxWorkers int +} + +// LoadOptsFromEnv loads BotOpts from environment variables. +// +// Environment variables: +// - TG_TOKEN: Bot token (required) +// - UPDATE_TYPES: semicolon-separated update types (e.g., "message;callback_query") +// - DEBUG: "true" to enable debug logging +// - ERROR_TEMPLATE: format string for error messages (e.g., "❌ %s") +// - PREFIXES: semicolon-separated prefixes (e.g., "/;!bot") +// - LOGGER_BASE_PATH: directory for log files (default: "./") +// - USE_REQ_LOG: "true" to enable request logging +// - WRITE_TO_FILE: "true" to write logs to files +// - USE_TEST_SERVER: "true" to use Telegram test server +// - API_URL: custom API endpoint +// - RATE_LIMIT: max requests per second (default: 30) +// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow +// +// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined. +func LoadOptsFromEnv() *BotOpts { + rateLimit := 30 + if rl := os.Getenv("RATE_LIMIT"); rl != "" { + if n, err := strconv.Atoi(rl); err == nil { + rateLimit = n + } + } + + stringUpdateTypes := strings.Split(os.Getenv("UPDATE_TYPES"), ";") + updateTypes := make([]tgapi.UpdateType, len(stringUpdateTypes)) + for i, updateType := range stringUpdateTypes { + updateTypes[i] = tgapi.UpdateType(updateType) + } + + return &BotOpts{ + Token: os.Getenv("TG_TOKEN"), + UpdateTypes: updateTypes, + + Debug: os.Getenv("DEBUG") == "true", + ErrorTemplate: os.Getenv("ERROR_TEMPLATE"), + Prefixes: LoadPrefixesFromEnv(), + + LoggerBasePath: os.Getenv("LOGGER_BASE_PATH"), + UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true", + WriteToFile: os.Getenv("WRITE_TO_FILE") == "true", + + UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", + APIUrl: os.Getenv("API_URL"), + + RateLimit: rateLimit, + DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", + } +} + +// SetToken sets the Telegram bot token (required). +func (opts *BotOpts) SetToken(token string) *BotOpts { + opts.Token = token + return opts +} + +// SetUpdateTypes sets the list of update types to listen for. +// If empty (default), Telegram will return all update types. +// Example: opts.SetUpdateTypes("message", "callback_query") +func (opts *BotOpts) SetUpdateTypes(types ...tgapi.UpdateType) *BotOpts { + opts.UpdateTypes = types + return opts +} + +// SetDebug enables or disables debug-level logging. +// Default is false. +func (opts *BotOpts) SetDebug(debug bool) *BotOpts { + opts.Debug = debug + return opts +} + +// SetErrorTemplate sets the format string for error messages sent to users. +// Use "%s" to insert the actual error. Example: "❌ Error: %s" +// If not set, defaults to "%s". +func (opts *BotOpts) SetErrorTemplate(tpl string) *BotOpts { + opts.ErrorTemplate = tpl + return opts +} + +// SetPrefixes sets the command prefixes (e.g., "/", "!"). +// If not set via environment, defaults to ["/"]. +func (opts *BotOpts) SetPrefixes(prefixes ...string) *BotOpts { + opts.Prefixes = prefixes + return opts +} + +// SetLoggerBasePath sets the directory where log files are written. +// Defaults to "./". +func (opts *BotOpts) SetLoggerBasePath(path string) *BotOpts { + opts.LoggerBasePath = path + return opts +} + +// SetUseRequestLogger enables detailed logging of all Telegram API requests. +// Default is false. +func (opts *BotOpts) SetUseRequestLogger(use bool) *BotOpts { + opts.UseRequestLogger = use + return opts +} + +// SetWriteToFile enables writing logs to files (main.log and requests.log). +// Default is false. +func (opts *BotOpts) SetWriteToFile(write bool) *BotOpts { + opts.WriteToFile = write + return opts +} + +// SetUseTestServer enables using Telegram's test server (https://api.telegram.org/bot/test). +// Default is false. +func (opts *BotOpts) SetUseTestServer(use bool) *BotOpts { + opts.UseTestServer = use + return opts +} + +// SetAPIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted). +// If not set, defaults to "https://api.telegram.org". +func (opts *BotOpts) SetAPIUrl(url string) *BotOpts { + opts.APIUrl = url + return opts +} + +// SetRateLimit sets the maximum number of API requests per second. +// Telegram allows up to 30 req/s for most bots. Defaults to 30. +func (opts *BotOpts) SetRateLimit(limit int) *BotOpts { + opts.RateLimit = limit + return opts +} + +// SetDropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing. +// Use this to prioritize responsiveness over reliability. Default is false. +func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts { + opts.DropRLOverflow = drop + return opts +} + +// SetMaxWorkers sets the maximum number of concurrent update handlers. +// Must be called before NewBot, as the value is captured during bot creation. +// +// The optimal value depends on your bot's workload: +// - For I/O-bound handlers (e.g., database queries, external API calls), you may +// need more workers, but be mindful of downstream service limits. +// - For CPU-bound handlers, keep workers close to the number of CPU cores. +// +// Recommended starting points (adjust based on profiling and monitoring): +// - Small to medium bots with fast handlers: 16–32 +// - Medium to large bots with fast handlers: 32–64 +// - Large bots with heavy I/O: 64–128 (ensure your infrastructure can handle it) +// +// The default is 32. Monitor queue length and processing latency to fine-tune. +func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts { + opts.MaxWorkers = workers + return opts +} + +// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon. +// Defaults to ["/"] if not set. +func LoadPrefixesFromEnv() []string { + prefixesS, exists := os.LookupEnv("PREFIXES") + if !exists { + return []string{"/"} + } + return strings.Split(prefixesS, ";") +} diff --git a/cmd_generator.go b/cmd_generator.go index b29de40..93aa287 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -1,12 +1,3 @@ -// Package laniakea provides a framework for building Telegram bots with plugin-based -// command registration and automatic command scope management. -// -// This module automatically generates and registers bot commands across different -// chat scopes (private, group, admin) based on plugin-defined commands. -// -// Commands are derived from Plugin and Command structs, with optional descriptions -// and argument formatting. Automatic registration avoids manual command setup and -// ensures consistency across chat types. package laniakea import ( diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..a19a1ec --- /dev/null +++ b/doc.go @@ -0,0 +1,55 @@ +/* +Package laniakea provides a modular, extensible framework for building scalable Telegram bots. + +It offers a fluent API for configuration and separates concerns through several core concepts: + + - Bot: The central instance managing API communication, update processing, logging, + rate limiting, and dependency injection. Created via NewBot[T]. + + - Plugins: Organize commands and payloads into reusable units. + A plugin can have multiple commands and shared middlewares. + + - Commands: Named bot commands with descriptions, argument validation, and + execution logic. Automatically registrable across different chat scopes. + + - Middleware: Functions that intercept and modify updates before they reach plugins. + Useful for authentication, logging, validation, etc. Return false to stop processing. + + - MsgContext: Provides access to the incoming update and convenient methods for + responding, editing, deleting, and translating messages. Includes built-in rate limiting + and error handling. ⚠️ MarkdownV2 methods require manual escaping via EscapeMarkdownV2(). + + - InlineKeyboard: A fluent builder for constructing inline keyboards with styled buttons, + icons, URLs, and structured callback data (JSON or Base64). + + - DraftProvider: Manages ephemeral, multi-step message drafts with automatic ID generation + (random or linear). Drafts can be built incrementally and flushed atomically. + + - L10n: Simple key-based localization system with fallback language support. + + - Runners: Background goroutines for periodic tasks or one‑off initialization, + with configurable timeouts and async execution. + + - RateLimiting & Logging: Built‑in rate limiter (respects Telegram's retry_after) + and structured logging (JSON stdout + optional file output) with request‑level tracing. + + - Dependency Injection: Pass any custom database context (e.g., *sql.DB) to all handlers + via the type parameter T in Bot[T]. + +Example usage: + + bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()). + DatabaseContext(&myDB). + AddUpdateType(tgapi.UpdateTypeMessage). + AddPrefixes("/", "!"). + AddPlugins(&startPlugin, &helpPlugin). + AddMiddleware(&authMiddleware, &logMiddleware). + AddRunner(&cleanupRunner). + AddL10n(l10n.New()) + + bot.Run() + +All public methods are safe for concurrent use unless stated otherwise. +Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset). +*/ +package laniakea diff --git a/drafts.go b/drafts.go index 445d3cd..4cb4203 100644 --- a/drafts.go +++ b/drafts.go @@ -1,31 +1,3 @@ -// Package laniakea provides a safe, high-level interface for managing Telegram -// message drafts using the tgapi library. It allows creating, editing, and -// flushing drafts with automatic ID generation and optional bulk flushing. -// -// Drafts are designed to be ephemeral, mutable buffers that can be built up -// incrementally and then sent as final messages. The package ensures safe -// state management by copying entities and isolating draft contexts. -// -// Two draft ID generation strategies are supported: -// - Random: Cryptographically secure random IDs (default). Ideal for distributed systems. -// - Linear: Monotonically increasing IDs. Useful for persistence, debugging, or recovery. -// -// Example usage: -// -// provider := laniakea.NewRandomDraftProvider(api) -// -// draft := provider.NewDraft(tgapi.ParseModeMarkdown) -// draft.SetChat(-1001234567890, 0) -// draft.Push("*Hello*").Push(" **world**!") -// err := draft.Flush() // Sends message and deletes draft -// if err != nil { -// log.Printf("Failed to send draft: %v", err) -// } -// -// // Or flush all pending drafts at once: -// err = provider.FlushAll() // Sends all drafts and clears them -// -// Note: Drafts are NOT thread-safe. Concurrent access requires external synchronization. package laniakea import ( diff --git a/keyboard.go b/keyboard.go index 6890a97..5a0e218 100644 --- a/keyboard.go +++ b/keyboard.go @@ -1,13 +1,3 @@ -// Package laniakea provides a fluent builder system for constructing Telegram -// inline keyboards with callback data and custom styling. -// -// This package supports: -// - Button builders with style (danger/success/primary), icons, URLs, and callbacks -// - Line-based keyboard layout with configurable max row size -// - Structured, JSON-serialized callback data for bot command routing -// -// Keyboard construction is stateful and builder-style: methods return the receiver -// to enable chaining. Call Get() to finalize and retrieve the tgapi.ReplyMarkup. package laniakea import ( diff --git a/l10n.go b/l10n.go index 917d4ea..35fd5e8 100644 --- a/l10n.go +++ b/l10n.go @@ -1,13 +1,3 @@ -// Package laniakea provides a simple, key-based localization system for -// multi-language text translation. -// -// The system supports: -// - Multiple language entries per key (e.g., "ru", "en", "es") -// - Fallback language for missing translations -// - Key-as-fallback behavior: if a key or language is not found, returns the key itself -// -// This is designed for lightweight, static localization in bots or services -// where dynamic translation services are unnecessary. package laniakea // DictEntry represents a single localized entry with language-to-text mappings. diff --git a/msg_context.go b/msg_context.go index 5c2785e..519ef6a 100644 --- a/msg_context.go +++ b/msg_context.go @@ -1,22 +1,3 @@ -// Package laniakea provides a high-level context-based API for handling Telegram -// bot interactions, including message responses, callback queries, inline keyboards, -// localization, and message drafting. It wraps tgapi and adds convenience methods -// with built-in rate limiting, error handling, and i18n support. -// -// The core type is MsgContext, which encapsulates the state of a Telegram update -// and provides methods to respond, edit, delete, and translate messages. -// -// # Markdown Safety Warning -// -// All methods that accept MarkdownV2 formatting (e.g., AnswerMarkdown, EditCallbackfMarkdown) -// require that user-provided text be escaped using laniakea.EscapeMarkdownV2(). -// Failure to escape user input may result in Telegram API errors, malformed messages, -// or security issues. -// -// Example: -// -// text := laniakea.EscapeMarkdownV2(userInput) -// ctx.AnswerMarkdown("You said: " + text) package laniakea import ( diff --git a/plugins.go b/plugins.go index 6f783f7..7b80378 100644 --- a/plugins.go +++ b/plugins.go @@ -1,15 +1,3 @@ -// Package laniakea provides a structured system for defining and executing -// bot commands and payloads with middleware support, argument validation, -// and plugin-based organization. -// -// The core concepts are: -// - Command: A named bot command with arguments, description, and executor. -// - Plugin: A collection of commands and payloads, with shared middlewares. -// - Middleware: Interceptors that can validate, modify, or block execution. -// - CommandArg: Type-safe argument definitions with regex validation. -// -// This system is designed to be used with MsgContext from the laniakea package -// to handle Telegram bot interactions in a modular, type-safe way. package laniakea import ( diff --git a/runners.go b/runners.go index 98bc359..ccfbdde 100644 --- a/runners.go +++ b/runners.go @@ -1,13 +1,3 @@ -// Package laniakea provides a system for managing background and one-time -// runner functions that operate on a Bot instance, with support for -// asynchronous execution, timeouts, and lifecycle control. -// -// Runners are used for periodic tasks (e.g., cleanup, stats updates) or -// one-time initialization logic. They are executed via Bot.ExecRunners(). -// -// Important: Runners are not thread-safe for concurrent modification. -// Builder methods (Onetime, Async, Timeout) must be called sequentially -// and only before Execute(). package laniakea import ( diff --git a/tgapi/types.go b/tgapi/types.go index 6db8b2c..6a44faf 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -1,6 +1,6 @@ package tgapi -// UpdateType represents the type of an incoming update. +// UpdateType represents the type of incoming update. type UpdateType string const ( -- 2.54.0 From fb81bb91bdd3d0720f8b1625ab8d349c1a6eb21d Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 13 Mar 2026 13:37:20 +0300 Subject: [PATCH 39/99] v1.0.0 beta 20 --- utils/version.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/version.go b/utils/version.go index 1ec09b6..5e759e1 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.19" + VersionString = "1.0.0-beta.20" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 19 + VersionBeta = 20 ) -- 2.54.0 From 389ec9f9d75176534b1d67f37bbcc50956a03193 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 16 Mar 2026 10:39:33 +0300 Subject: [PATCH 40/99] v1.0.0 beta 21 --- handler.go | 6 ++--- keyboard.go | 24 ++++++++++++++--- msg_context.go | 9 +++++++ tgapi/api.go | 60 ++++++++++++++--------------------------- tgapi/uploader_api.go | 62 +++++++++++++++++++++++++++++-------------- utils/limiter.go | 24 ++++++++++------- utils/multipart.go | 22 ++++++++++----- utils/version.go | 4 +-- 8 files changed, 125 insertions(+), 86 deletions(-) diff --git a/handler.go b/handler.go index 23ad408..7157e1a 100644 --- a/handler.go +++ b/handler.go @@ -151,8 +151,8 @@ func encodeBase64Payload(d CallbackData) (string, error) { if err != nil { return "", err } - dst := make([]byte, base64.StdEncoding.EncodedLen(len([]byte(data)))) - base64.StdEncoding.Encode(dst, []byte(data)) + dst := make([]byte, base64.RawURLEncoding.EncodedLen(len([]byte(data)))) + base64.RawURLEncoding.Encode(dst, []byte(data)) return string(dst), nil } @@ -166,7 +166,7 @@ func encodeBase64Payload(d CallbackData) (string, error) { // return "", ErrInvalidPayloadType // } func decodeBase64Payload(s string) (CallbackData, error) { - b, err := base64.StdEncoding.DecodeString(s) + b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return CallbackData{}, err } diff --git a/keyboard.go b/keyboard.go index 5a0e218..b1daafd 100644 --- a/keyboard.go +++ b/keyboard.go @@ -109,16 +109,32 @@ type InlineKeyboard struct { payloadType BotPayloadType // Serialization format for callback data (JSON or Base64) } -// NewInlineKeyboard creates a new keyboard builder with the specified maximum +// NewInlineKeyboardJson creates a new keyboard builder with the specified maximum // number of buttons per row. // -// Example: NewInlineKeyboard(3) creates a keyboard with at most 3 buttons per line. -func NewInlineKeyboard(maxRow int) *InlineKeyboard { +// Example: NewInlineKeyboardJson(3) creates a keyboard with at most 3 buttons per line. +func NewInlineKeyboardJson(maxRow int) *InlineKeyboard { + return NewInlineKeyboard(BotPayloadJson, maxRow) +} + +// NewInlineKeyboardBase64 creates a new keyboard builder with the specified 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. +func NewInlineKeyboardBase64(maxRow int) *InlineKeyboard { + return NewInlineKeyboard(BotPayloadBase64, maxRow) +} + +// NewInlineKeyboard creates a new keyboard builder with the specified payload encoding +// type and maximum number of buttons per row. +// +// Use NewInlineKeyboardJson or NewInlineKeyboardBase64 for the common cases. +func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard { return &InlineKeyboard{ CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0), Lines: make([][]tgapi.InlineKeyboardButton, 0), maxRow: maxRow, - payloadType: BotPayloadBase64, + payloadType: payloadType, } } diff --git a/msg_context.go b/msg_context.go index 519ef6a..222923f 100644 --- a/msg_context.go +++ b/msg_context.go @@ -391,6 +391,9 @@ 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) } @@ -404,3 +407,9 @@ func (ctx *MsgContext) Translate(key string) string { 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) +} diff --git a/tgapi/api.go b/tgapi/api.go index 5c624fd..228e417 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -161,27 +161,13 @@ type TelegramRequest[R, P any] struct { chatId int64 } -// NewRequest and NewRequestWithChatID are DEPRECATED. -// They encourage unsafe, untyped usage and bypass Go's type safety. -// Instead, define explicit, type-safe methods for each Telegram API endpoint. -// -// Example: -// -// func (api *API) SendMessage(ctx context.Context, chatID int64, text string) (Message, error) { ... } -// -// This provides: -// -// ✅ Compile-time validation -// ✅ IDE autocompletion -// ✅ Clear API surface -// ✅ Better error messages -// -// DO NOT use these constructors in production code. -// This can be used ONLY for testing or if you NEED method, that wasn't added as function. +// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID. func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { return TelegramRequest[R, P]{method, params, 0} } +// NewRequestWithChatID creates an untyped TelegramRequest with an associated chat ID. +// The chat ID is used for per-chat rate limiting. func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { return TelegramRequest[R, P]{method, params, chatId} } @@ -191,12 +177,10 @@ func NewRequestWithChatID[R, P any](method string, params P, chatId int64) Teleg // Must be called within a worker pool context if using DoWithContext. func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { var zero R - - data, err := json.Marshal(r.params) + reqData, err := json.Marshal(r.params) if err != nil { return zero, fmt.Errorf("failed to marshal request: %w", err) } - buf := bytes.NewBuffer(data) methodPrefix := "" if api.useTestServer { @@ -204,7 +188,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro } url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method) - req, err := http.NewRequestWithContext(ctx, "POST", url, buf) + req, err := http.NewRequestWithContext(ctx, "POST", url, nil) if err != nil { return zero, fmt.Errorf("failed to create request: %w", err) } @@ -213,7 +197,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) req.Header.Set("Accept-Encoding", "gzip") - req.ContentLength = int64(len(data)) for { // Apply rate limiting before making the request @@ -222,22 +205,25 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro return zero, err } } + buf := bytes.NewBuffer(reqData) + req.Body = io.NopCloser(buf) + req.ContentLength = int64(len(reqData)) - api.logger.Debugln("REQ", url, string(data)) + api.logger.Debugln("REQ", url, string(reqData)) resp, err := api.client.Do(req) if err != nil { return zero, fmt.Errorf("HTTP request failed: %w", err) } - data, err = readBody(resp.Body) + respData, err := readBody(resp.Body) _ = resp.Body.Close() // ensure body is closed if err != nil { return zero, fmt.Errorf("failed to read response body: %w", err) } - api.logger.Debugln("RES", r.method, string(data)) + api.logger.Debugln("RES", r.method, string(respData)) - response, err := parseBody[R](data) + response, err := parseBody[R](respData) if err != nil { return zero, fmt.Errorf("failed to parse response: %w", err) } @@ -249,10 +235,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatId) // Apply cooldown to global or chat-specific limiter - if r.chatId > 0 { - api.Limiter.SetChatLock(r.chatId, after) - } else { - api.Limiter.SetGlobalLock(after) + if api.Limiter != nil { + if r.chatId > 0 { + api.Limiter.SetChatLock(r.chatId, after) + } else { + api.Limiter.SetGlobalLock(after) + } } // Wait and retry @@ -311,21 +299,13 @@ func readBody(body io.ReadCloser) ([]byte, error) { return io.ReadAll(reader) } -// parseBody unmarshals Telegram API response and returns structured result. -// Returns ErrRateLimit internally if error_code == 429 — caller must handle via response.Ok check. +// parseBody unmarshals a Telegram API response into a typed ApiResponse. +// Only returns an error on malformed JSON; non-OK responses are left for the caller to handle. func parseBody[R any](data []byte) (ApiResponse[R], error) { var resp ApiResponse[R] err := json.Unmarshal(data, &resp) if err != nil { return resp, fmt.Errorf("failed to unmarshal JSON: %w", err) } - - if !resp.Ok { - if resp.ErrorCode == 429 { - return resp, ErrRateLimit // internal use only - } - return resp, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description) - } - return resp, nil } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 3d0a60f..efa0451 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -3,7 +3,6 @@ package tgapi import ( "bytes" "context" - "errors" "fmt" "mime/multipart" "net/http" @@ -24,13 +23,18 @@ const ( UploaderThumbnailType UploaderFileType = "thumbnail" ) +// UploaderFileType represents the Telegram form field name for a file upload. type UploaderFileType string + +// UploaderFile holds the data and metadata for a single file to be uploaded. type UploaderFile struct { filename string data []byte field UploaderFileType } +// NewUploaderFile creates a new UploaderFile, auto-detecting the field type from the file extension. +// If detection is incorrect, use SetType to override. func NewUploaderFile(name string, data []byte) UploaderFile { t := uploaderTypeByExt(name) return UploaderFile{filename: name, data: data, field: t} @@ -56,6 +60,8 @@ func NewUploader(api *API) *Uploader { func (u *Uploader) Close() error { return u.logger.Close() } func (u *Uploader) GetLogger() *slog.Logger { return u.logger } +// UploaderRequest is a multipart file upload request to the Telegram API. +// Use NewUploaderRequest or NewUploaderRequestWithChatID to construct one. type UploaderRequest[R, P any] struct { method string files []UploaderFile @@ -63,40 +69,30 @@ type UploaderRequest[R, P any] struct { chatId int64 } +// NewUploaderRequest creates a new multipart upload request with no associated chat ID. func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0} } + +// NewUploaderRequestWithChatID creates a new multipart upload request with an associated chat ID. +// The chat ID is used for per-chat rate limiting. func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] { return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId} } func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { var zero R - buf, contentType, err := prepareMultipart(r.files, r.params) - if err != nil { - return zero, err - } - methodPrefix := "" if up.api.useTestServer { methodPrefix = "/test" } url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, r.method) - req, err := http.NewRequestWithContext(ctx, "POST", url, buf) - if err != nil { - return zero, err - } - req.Header.Set("Content-Type", contentType) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) - req.Header.Set("Accept-Encoding", "gzip") - req.ContentLength = int64(buf.Len()) for { if up.api.Limiter != nil { if up.api.dropOverflowLimit { if !up.api.Limiter.GlobalAllow() { - return zero, errors.New("rate limited") + return zero, utils.ErrDropOverflow } } else { if err := up.api.Limiter.GlobalWait(ctx); err != nil { @@ -105,6 +101,20 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, } } + buf, contentType, err := prepareMultipart(r.files, r.params) + if err != nil { + return zero, err + } + req, err := http.NewRequestWithContext(ctx, "POST", url, buf) + if err != nil { + return zero, err + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) + req.Header.Set("Accept-Encoding", "gzip") + req.ContentLength = int64(buf.Len()) + up.logger.Debugln("UPLOADER REQ", r.method) resp, err := up.api.client.Do(req) if err != nil { @@ -127,10 +137,12 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { after := *response.Parameters.RetryAfter up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatId) - if r.chatId > 0 { - up.api.Limiter.SetChatLock(r.chatId, after) - } else { - up.api.Limiter.SetGlobalLock(after) + if up.api.Limiter != nil { + if r.chatId > 0 { + up.api.Limiter.SetChatLock(r.chatId, after) + } else { + up.api.Limiter.SetGlobalLock(after) + } } select { @@ -145,6 +157,9 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, return response.Result, nil } } + +// DoWithContext executes the upload request asynchronously via the worker pool. +// Returns the result or error. Respects context cancellation. func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) { var zero R @@ -168,10 +183,15 @@ func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) return zero, ErrPoolUnexpected } } + +// Do executes the upload request synchronously with a background context. +// Use only for simple, non-critical uploads. func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) { return r.DoWithContext(context.Background(), up) } +// prepareMultipart builds a multipart form body from the given files and params. +// Params are encoded via utils.Encode. The writer boundary is finalized before returning. func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { buf := bytes.NewBuffer(nil) w := multipart.NewWriter(buf) @@ -204,6 +224,8 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str return buf, w.FormDataContentType(), nil } +// uploaderTypeByExt infers the Telegram upload field name from a file extension. +// Falls back to UploaderDocumentType for unrecognized extensions. func uploaderTypeByExt(filename string) UploaderFileType { ext := filepath.Ext(filename) switch ext { diff --git a/utils/limiter.go b/utils/limiter.go index 02c7330..f15811c 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -22,7 +22,7 @@ type RateLimiter struct { chatLocks map[int64]time.Time // per-chat cooldown timestamps chatLimiters map[int64]*rate.Limiter // per-chat token buckets (1 req/sec) - chatMu sync.Mutex // protects chatLocks and chatLimiters + chatMu sync.RWMutex // protects chatLocks and chatLimiters } // NewRateLimiter creates a new RateLimiter with default limits. @@ -107,9 +107,9 @@ func (rl *RateLimiter) Allow(chatID int64) bool { } // Check chat cooldown - rl.chatMu.Lock() + rl.chatMu.RLock() chatUntil, ok := rl.chatLocks[chatID] - rl.chatMu.Unlock() + rl.chatMu.RUnlock() if ok && !chatUntil.IsZero() && time.Now().Before(chatUntil) { return false } @@ -135,11 +135,15 @@ func (rl *RateLimiter) Allow(chatID int64) bool { // chatID == 0 means no specific chat context (e.g., inline query, webhook without chat). func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int64) error { if dropOverflow { - if chatID != 0 && !rl.Allow(chatID) { - return ErrDropOverflow - } - if !rl.GlobalAllow() { - return ErrDropOverflow + if chatID != 0 { + if !rl.Allow(chatID) { + + return ErrDropOverflow + } + } else { + if !rl.GlobalAllow() { + return ErrDropOverflow + } } } else if chatID != 0 { if err := rl.Wait(ctx, chatID); err != nil { @@ -175,9 +179,9 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { // waitForChatUnlock blocks until the specified chat's cooldown expires or context is done. // Does not check token bucket — only cooldown. func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { - rl.chatMu.Lock() + rl.chatMu.RLock() until, ok := rl.chatLocks[chatID] - rl.chatMu.Unlock() + rl.chatMu.RUnlock() if !ok || until.IsZero() || time.Now().After(until) { return nil diff --git a/utils/multipart.go b/utils/multipart.go index 0dfdac0..df6131f 100644 --- a/utils/multipart.go +++ b/utils/multipart.go @@ -49,11 +49,9 @@ func Encode[T any](w *multipart.Writer, req T) error { switch field.Kind() { case reflect.String: - if !isEmpty { - fw, err = w.CreateFormField(fieldName) - if err == nil { - _, err = fw.Write([]byte(field.String())) - } + fw, err = w.CreateFormField(fieldName) + if err == nil { + _, err = fw.Write([]byte(field.String())) } case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: fw, err = w.CreateFormField(fieldName) @@ -65,11 +63,17 @@ func Encode[T any](w *multipart.Writer, req T) error { if err == nil { _, err = fw.Write([]byte(strconv.FormatUint(field.Uint(), 10))) } - case reflect.Float32, reflect.Float64: + case reflect.Float32: + fw, err = w.CreateFormField(fieldName) + if err == nil { + _, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 32))) + } + case reflect.Float64: fw, err = w.CreateFormField(fieldName) if err == nil { _, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 64))) } + case reflect.Bool: fw, err = w.CreateFormField(fieldName) if err == nil { @@ -103,8 +107,12 @@ func Encode[T any](w *multipart.Writer, req T) error { _, err = fw.Write([]byte(strconv.FormatUint(elem.Uint(), 10))) case reflect.Bool: _, err = fw.Write([]byte(strconv.FormatBool(elem.Bool()))) - case reflect.Float32, reflect.Float64: + case reflect.Float32: + _, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 32))) + case reflect.Float64: _, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 64))) + default: + continue } if err != nil { break diff --git a/utils/version.go b/utils/version.go index 5e759e1..cc789d9 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.20" + VersionString = "1.0.0-beta.21" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 20 + VersionBeta = 21 ) -- 2.54.0 From 1e043da05df7773f741073c5ed699708e52fd498 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 17 Mar 2026 13:21:06 +0300 Subject: [PATCH 41/99] release: 1.0.0 beta 22 Implemented full tgapi method coverage from Telegram docs, aligned numeric ID/file_size types, and fixed method signatures/JSON tags.; Standardized GoDoc across exported APIs with Telegram links and refreshed README sections for MsgContext plus API/Uploader usage. --- README.md | 17 +++-- bot.go | 25 ++++++-- bot_opts.go | 2 +- cmd_generator.go | 29 ++------- drafts.go | 21 +++---- handler.go | 17 ++++- keyboard.go | 4 +- l10n.go | 2 +- msg_context.go | 83 +++++++++++++++++++------ plugins.go | 15 +---- runners.go | 7 ++- tgapi/api.go | 10 ++- tgapi/attachments_methods.go | 8 +-- tgapi/attachments_types.go | 2 +- tgapi/bot_methods.go | 10 +-- tgapi/bot_types.go | 4 +- tgapi/business_methods.go | 57 ++++++++++------- tgapi/business_types.go | 2 +- tgapi/chat_methods.go | 40 +++++++----- tgapi/chat_types.go | 8 +-- tgapi/errors.go | 1 + tgapi/games_methods.go | 69 +++++++++++++++++++++ tgapi/games_types.go | 9 +++ tgapi/inline_methods.go | 52 ++++++++++++++++ tgapi/inline_types.go | 26 ++++++++ tgapi/messages_methods.go | 66 +++++++++++--------- tgapi/messages_types.go | 21 ++++--- tgapi/methods.go | 61 ++++++++++++------ tgapi/methods_types.go | 35 +++++++++++ tgapi/passport_methods.go | 16 +++++ tgapi/passport_types.go | 5 ++ tgapi/payments_methods.go | 117 +++++++++++++++++++++++++++++++++++ tgapi/payments_types.go | 16 +++++ tgapi/pool.go | 67 +++++++++++++++----- tgapi/stars_methods.go | 53 ++++++++++++++++ tgapi/stars_types.go | 18 ++++++ tgapi/stickers_methods.go | 31 +++++++--- tgapi/stickers_types.go | 2 +- tgapi/types.go | 6 +- tgapi/uploader_api.go | 46 +++++++++----- tgapi/uploader_methods.go | 44 ++++++------- tgapi/users_methods.go | 16 ++--- tgapi/users_types.go | 2 +- utils.go | 10 ++- utils/limiter.go | 47 +++++++++++--- utils/multipart.go | 1 + utils/utils.go | 1 + utils/version.go | 4 +- 48 files changed, 921 insertions(+), 284 deletions(-) create mode 100644 tgapi/games_methods.go create mode 100644 tgapi/games_types.go create mode 100644 tgapi/inline_methods.go create mode 100644 tgapi/inline_types.go create mode 100644 tgapi/methods_types.go create mode 100644 tgapi/passport_methods.go create mode 100644 tgapi/passport_types.go create mode 100644 tgapi/payments_methods.go create mode 100644 tgapi/payments_types.go create mode 100644 tgapi/stars_methods.go create mode 100644 tgapi/stars_types.go diff --git a/README.md b/README.md index 37cdfa5..fe175e7 100644 --- a/README.md +++ b/README.md @@ -129,13 +129,22 @@ Provides access to the incoming message and useful reply methods: - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. -- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) with. +- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). - `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button. - `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button. -- `SendChatAction(action string)`: Sends a “typing”, “uploading photo”, etc., action. -- Fields: `Text`, `Args`, `From`, `Chat`, `Msg`, etc. +- `SendAction(action tgapi.ChatActionType)`: Sends a “typing”, “uploading photo”, etc., action. +- Fields: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId`, etc. - And more methods and fields! +### tgapi: API and Uploader + +`tgapi` provides two clients: + +- `API` for JSON requests (e.g., `SendMessage`, `EditMessageText`, methods using file_id/URL). +- `Uploader` for multipart uploads (e.g., `SendPhoto`, `SendDocument`, `SendVideo` with binary files). + +This split keeps method intent explicit: JSON-only calls go through `API`, file uploads go through `Uploader`. + ### Database Context The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool), and it will be available in every command and middleware handler. @@ -193,7 +202,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. ## ⚙️ Advanced Configuration -- **Inline Keyboards**: Build keyboards using laniakea.NewKeyboard(). +- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control. diff --git a/bot.go b/bot.go index 57ec81e..bf077ba 100644 --- a/bot.go +++ b/bot.go @@ -81,6 +81,8 @@ type Bot[T DbContext] struct { updateOffset int // Last processed update ID updateTypes []tgapi.UpdateType // Types of updates to fetch updateQueue chan *tgapi.Update // Internal queue for processing updates + runnerOnceWG sync.WaitGroup // Tracks one-time async runners + runnerBgWG sync.WaitGroup // Tracks background async runners } // NewBot creates and initializes a new Bot instance using the provided BotOpts. @@ -107,11 +109,13 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { // limiter = utils.NewRateLimiter() //} limiter := utils.NewRateLimiter() + limiter.SetGlobalRate(opts.RateLimit) apiOpts := tgapi.NewAPIOpts(opts.Token). SetAPIUrl(opts.APIUrl). UseTestServer(opts.UseTestServer). - SetLimiter(limiter) + SetLimiter(limiter). + SetLimiterDrop(opts.DropRLOverflow) api := tgapi.NewAPI(apiOpts) uploader := tgapi.NewUploader(api) @@ -137,7 +141,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { prefixes: prefixes, token: opts.Token, plugins: make([]Plugin[T], 0), - updateTypes: make([]tgapi.UpdateType, 0), + updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...), runners: make([]Runner[T], 0), extraLoggers: make([]*slog.Logger, 0), l10n: &L10n{}, @@ -285,9 +289,9 @@ func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { return bot } -// SetPayloadType sets the type, that bot will use for payload -// json - string `{"cmd": "command", "args": [...]} -// base64 - same json, but encoded in base64 string +// SetPayloadType sets the payload encoding type used for callback data. +// JSON stores payload as a string: `{"cmd":"command","args":[...]}`. +// Base64 stores the same JSON encoded as a Base64URL string. func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { bot.payloadType = t return bot @@ -309,7 +313,7 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { // ErrorTemplate sets the format string for error messages sent to users. // Use "%s" to insert the error message. -// Example: "❌ Error: %s" → "❌ Error: Command not found" +// Example: "❌ Error: %s" → "❌ Error: Command not found". func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { bot.errorTemplate = s return bot @@ -408,6 +412,7 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { if l == nil { bot.logger.Warn("AddL10n called with nil L10n; localization will be disabled") + return bot } bot.l10n = l return bot @@ -457,6 +462,12 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { // // ... later ... // cancel() // triggers graceful shutdown func (bot *Bot[T]) RunWithContext(ctx context.Context) { + defer func() { + if err := bot.Close(); err != nil { + bot.logger.Errorln(err) + } + }() + if len(bot.prefixes) == 0 { bot.logger.Fatalln("no prefixes defined") return @@ -512,6 +523,8 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { }) } pool.Stop() // Wait for all tasks to complete and stop the pool + bot.runnerOnceWG.Wait() + bot.runnerBgWG.Wait() } // Run starts the bot using a background context. diff --git a/bot_opts.go b/bot_opts.go index d9ef743..ef49264 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -119,7 +119,7 @@ func (opts *BotOpts) SetToken(token string) *BotOpts { // SetUpdateTypes sets the list of update types to listen for. // If empty (default), Telegram will return all update types. -// Example: opts.SetUpdateTypes("message", "callback_query") +// Example: opts.SetUpdateTypes("message", "callback_query"). func (opts *BotOpts) SetUpdateTypes(types ...tgapi.UpdateType) *BotOpts { opts.UpdateTypes = types return opts diff --git a/cmd_generator.go b/cmd_generator.go index 93aa287..388dfb0 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -9,6 +9,7 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// CmdRegexp matches command names allowed for Telegram command registration. var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$") // ErrTooManyCommands is returned when the total number of registered commands @@ -19,21 +20,7 @@ var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$") // bot initialization. var ErrTooManyCommands = errors.New("too many commands. max 100") -// generateBotCommand converts a Command[T] into a tgapi.BotCommand with a -// formatted description that includes usage instructions. -// -// The description is built as: -// -// ". Usage: / [] ..." -// -// Required arguments are shown as-is; optional arguments are wrapped in square brackets. -// -// Example: -// -// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}} -// → Description: "Start the bot. Usage: /start [name]" -// Command{command: "echo", description: "Echo user input", args: []Arg{{text: "name", required: true}}} -// → Description: "Echo user input. Usage: /echo " +// generateBotCommand builds a BotCommand description with generated usage text. func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { desc := "" if len(cmd.description) > 0 { @@ -57,16 +44,10 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { return tgapi.BotCommand{Command: cmd.command, Description: usage} } -// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+ -// Return true if satisfied, else false. +// checkCmdRegex reports whether cmd matches CmdRegexp. func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) } -// gatherCommandsForPlugin collects all non-skipped commands from a Plugin[T] -// and converts them into tgapi.BotCommand objects. -// -// Commands marked with skipAutoCmd = true are excluded from auto-registration. -// This allows plugins to opt out of automatic command generation (e.g., for -// internal or hidden commands). +// gatherCommandsForPlugin collects non-skipped, valid commands from one plugin. func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, cmd := range pl.commands { @@ -83,7 +64,7 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { // gatherCommands collects all commands from all plugins // and converts them into tgapi.BotCommand objects. -// See gatherCommandsForPlugin +// See gatherCommandsForPlugin. func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, pl := range bot.plugins { diff --git a/drafts.go b/drafts.go index 4cb4203..ff49b59 100644 --- a/drafts.go +++ b/drafts.go @@ -9,6 +9,7 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// ErrDraftChatIDZero is returned when a draft is used without setting a chat ID. var ErrDraftChatIDZero = errors.New("zero draft chat ID") // draftIdGenerator defines an interface for generating unique draft IDs. @@ -90,27 +91,25 @@ func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) { // FlushAll sends all pending drafts as final messages and clears them. // -// If any draft fails to send, FlushAll returns the error immediately and -// leaves other drafts unflushed. This allows for retry logic or logging. +// If one or more drafts fail to send, FlushAll still attempts all drafts and +// returns the first encountered error. // // After successful flush, each draft is removed from the provider and cleared. func (p *DraftProvider) FlushAll() error { - p.mu.Lock() + p.mu.RLock() drafts := make([]*Draft, 0, len(p.drafts)) for _, draft := range p.drafts { drafts = append(drafts, draft) } - p.drafts = make(map[uint64]*Draft) - p.mu.Unlock() + p.mu.RUnlock() - var lastErr error + var firstErr error for _, draft := range drafts { - if err := draft.Flush(); err != nil { - lastErr = err - break // Stop on first error to avoid partial state + if err := draft.Flush(); err != nil && firstErr == nil { + firstErr = err } } - return lastErr + return firstErr } // Draft represents a single message draft that can be edited and flushed. @@ -165,7 +164,7 @@ func (d *Draft) SetChat(chatID int64, messageThreadID int) *Draft { // SetEntities replaces the draft's message entities. // // Entities are stored by reference. If you plan to mutate the slice later, -// pass a copy: `SetEntities(append([]tgapi.MessageEntity{}, myEntities...))` +// pass a copy: `SetEntities(append([]tgapi.MessageEntity{}, myEntities...))`. func (d *Draft) SetEntities(entities []tgapi.MessageEntity) *Draft { d.entities = entities return d diff --git a/handler.go b/handler.go index 7157e1a..1afa576 100644 --- a/handler.go +++ b/handler.go @@ -10,6 +10,7 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// ErrInvalidPayloadType is returned when callback payload encoding type is unknown. var ErrInvalidPayloadType = errors.New("invalid payload type") func (bot *Bot[T]) handle(u *tgapi.Update) { @@ -28,7 +29,9 @@ func (bot *Bot[T]) handle(u *tgapi.Update) { payloadType: bot.payloadType, } for _, middleware := range bot.middlewares { - middleware.Execute(ctx, bot.dbContext) + if !middleware.Execute(ctx, bot.dbContext) { + return + } } if u.CallbackQuery != nil { @@ -42,6 +45,9 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { if update.Message == nil { return } + if update.Message.From == nil { + return + } var text string if len(update.Message.Text) > 0 { @@ -106,8 +112,13 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { ctx.FromID = update.CallbackQuery.From.ID ctx.From = &update.CallbackQuery.From - ctx.Msg = &update.CallbackQuery.Message - ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID + if update.CallbackQuery.Message != nil { + ctx.Msg = update.CallbackQuery.Message + ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID + } + if update.CallbackQuery.InlineMessageID != nil { + ctx.InlineMsgId = *update.CallbackQuery.InlineMessageID + } ctx.CallbackQueryId = update.CallbackQuery.ID ctx.Args = data.Args diff --git a/keyboard.go b/keyboard.go index b1daafd..3ea98f6 100644 --- a/keyboard.go +++ b/keyboard.go @@ -69,7 +69,7 @@ func (b InlineKbButtonBuilder) SetUrl(url string) InlineKbButtonBuilder { // Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool) // are safely serialized, but complex structs may not serialize usefully. // -// Example: SetCallbackDataJson("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]} +// Example: SetCallbackDataJson("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. func (b InlineKbButtonBuilder) SetCallbackDataJson(cmd string, args ...any) InlineKbButtonBuilder { b.callbackData = NewCallbackData(cmd, args...).ToJson() return b @@ -210,7 +210,7 @@ func (in *InlineKeyboard) AddLine() *InlineKeyboard { // Returns a pointer to a ReplyMarkup suitable for use with tgapi.SendMessage. func (in *InlineKeyboard) Get() *tgapi.ReplyMarkup { if in.CurrentLine.Len() > 0 { - in.Lines = append(in.Lines, in.CurrentLine) + in.AddLine() } return &tgapi.ReplyMarkup{InlineKeyboard: in.Lines} } diff --git a/l10n.go b/l10n.go index 35fd5e8..9f2268e 100644 --- a/l10n.go +++ b/l10n.go @@ -1,7 +1,7 @@ package laniakea // DictEntry represents a single localized entry with language-to-text mappings. -// Example: {"ru": "Привет", "en": "Hello"} +// Example: {"ru": "Привет", "en": "Hello"}. type DictEntry map[string]string // L10n is a localization manager that maps keys to language-specific strings. diff --git a/msg_context.go b/msg_context.go index 222923f..b287d95 100644 --- a/msg_context.go +++ b/msg_context.go @@ -19,9 +19,10 @@ type MsgContext struct { Msg *tgapi.Message From *tgapi.User + InlineMsgId string CallbackMsgId int CallbackQueryId string - FromID int + FromID int64 Prefix string Text string Args []string @@ -46,11 +47,19 @@ type AnswerMessage struct { // Used by Edit, EditMarkdown, EditCallback, etc. func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { params := tgapi.EditMessageTextP{ - MessageID: messageId, - ChatID: ctx.Msg.Chat.ID, 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() } @@ -59,8 +68,12 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard ctx.botLogger.Errorln(err) return nil } + resultMessageID := messageId + if msg.MessageID > 0 { + resultMessageID = msg.MessageID + } return &AnswerMessage{ - MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: false, + MessageID: resultMessageID, ctx: ctx, Text: text, IsMedia: false, } } @@ -79,9 +92,9 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { } // editCallback is an internal helper to edit the message associated with a callback query. -// Returns nil if CallbackMsgId is 0 (not a callback context). +// 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 { + if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" { ctx.botLogger.Errorln("Can't edit non-callback update message") return nil } @@ -113,18 +126,22 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb } // editPhotoText edits the caption of a photo/video message. -// Returns nil if messageId is 0. +// 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 { - if messageId == 0 { - ctx.botLogger.Errorln("Can't edit caption message, message ID zero") - return nil - } params := tgapi.EditMessageCaptionP{ - ChatID: ctx.Msg.Chat.ID, - MessageID: messageId, 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() } @@ -132,9 +149,14 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo 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: msg.MessageID, ctx: ctx, Text: text, IsMedia: true, + MessageID: resultMessageID, ctx: ctx, Text: text, IsMedia: true, } } @@ -165,6 +187,10 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo // 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, @@ -180,11 +206,6 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID } - cont := context.Background() - if err := ctx.Api.Limiter.Wait(cont, ctx.Msg.Chat.ID); err != nil { - ctx.botLogger.Errorln(err) - return nil - } msg, err := ctx.Api.SendMessage(params) if err != nil { ctx.botLogger.Errorln(err) @@ -233,6 +254,10 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) * // 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, @@ -294,6 +319,14 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an // 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, @@ -307,7 +340,13 @@ func (ctx *MsgContext) delete(messageId int) { func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) } // CallbackDelete deletes the message that triggered the callback query. -func (ctx *MsgContext) CallbackDelete() { ctx.delete(ctx.CallbackMsgId) } +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. @@ -338,6 +377,10 @@ func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, " // 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, } diff --git a/plugins.go b/plugins.go index 7b80378..091e449 100644 --- a/plugins.go +++ b/plugins.go @@ -26,7 +26,7 @@ var ( CommandRegexInt = regexp.MustCompile(`\d+`) // CommandRegexString matches any non-empty string. CommandRegexString = regexp.MustCompile(`.+`) - // CommandRegexBool matches true or false + // CommandRegexBool matches true or false. CommandRegexBool = regexp.MustCompile(`true|false`) ) @@ -53,6 +53,7 @@ func NewCommandArg(text string) *CommandArg { return &CommandArg{CommandValueAnyType, text, CommandRegexString, false} } +// SetValueType sets expected value type and switches built-in validation regexp. func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { regex := CommandRegexString switch t { @@ -96,7 +97,7 @@ func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandA } // NewPayload creates a new Command with the given executor, command payload string, and arguments. -// The command string can POTENTIALLY contain any symbols, but recommended to use only "_", "-", ".", a-Z, 0-9 +// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} } @@ -223,11 +224,6 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { return } - // Run plugin middlewares - if !p.executeMiddlewares(ctx, dbContext) { - return - } - // Run command-specific middlewares for _, m := range command.middlewares { if !m.Execute(ctx, dbContext) { @@ -254,11 +250,6 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T return } - // Run plugin middlewares - if !p.executeMiddlewares(ctx, dbContext) { - return - } - // Run command-specific middlewares for _, m := range command.middlewares { if !m.Execute(ctx, dbContext) { diff --git a/runners.go b/runners.go index ccfbdde..d64d3ab 100644 --- a/runners.go +++ b/runners.go @@ -98,12 +98,15 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { continue } if !runner.onetime && runner.async && runner.timeout == 0 { - bot.logger.Warnf("Background runner \"%s\" has no timeout — may cause tight loop\n", runner.name) + bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name) + continue } if runner.onetime && runner.async { // One-time async: fire and forget + bot.runnerOnceWG.Add(1) go func(r Runner[T]) { + defer bot.runnerOnceWG.Done() err := r.fn(bot) if err != nil { bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) @@ -122,7 +125,9 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { } } else if !runner.onetime && runner.async { // Background loop: periodic execution with graceful shutdown + bot.runnerBgWG.Add(1) go func(r Runner[T]) { + defer bot.runnerBgWG.Done() ticker := time.NewTicker(r.timeout) defer ticker.Stop() for { diff --git a/tgapi/api.go b/tgapi/api.go index 228e417..98e2c50 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -76,7 +76,11 @@ func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts { return opts } -// API is the main Telegram Bot API client. +// API is the main Telegram Bot API client for JSON requests. +// +// Use API methods when sending JSON payloads (for example with file_id, URL, or other +// non-multipart fields). For multipart file uploads, use Uploader. +// // It manages HTTP requests, rate limiting, retries, and connection pooling. type API struct { token string @@ -102,7 +106,7 @@ func NewAPI(opts *APIOpts) *API { } pool := newWorkerPool(16, 256) - pool.start(context.Background()) + pool.start() return &API{ token: opts.token, @@ -118,12 +122,14 @@ func NewAPI(opts *APIOpts) *API { // CloseApi shuts down the internal worker pool and closes the logger. // Must be called to avoid resource leaks. +// See https://core.telegram.org/bots/api func (api *API) CloseApi() error { api.pool.stop() return api.logger.Close() } // GetLogger returns the internal logger for custom logging. +// See https://core.telegram.org/bots/api func (api *API) GetLogger() *slog.Logger { return api.logger } diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 9e7222a..7fff755 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -15,7 +15,7 @@ type SendPhotoP struct { ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` HasSpoiler bool `json:"has_spoiler,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` + DisableNotifications bool `json:"disable_notification,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"` @@ -107,7 +107,7 @@ type SendVideoP struct { Duration int `json:"duration,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` - Cover int `json:"cover,omitempty"` + Cover string `json:"cover,omitempty"` StartTimestamp int `json:"start_timestamp,omitempty"` Caption string `json:"caption,omitempty"` @@ -276,7 +276,7 @@ type SendMediaGroupP struct { // SendMediaGroup sends a group of photos, videos, documents or audios as an album. // See https://core.telegram.org/bots/api#sendmediagroup -func (api *API) SendMediaGroup(params SendMediaGroupP) (Message, error) { - req := NewRequestWithChatID[Message]("sendMediaGroup", params, params.ChatID) +func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) { + req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID) return req.Do(api) } diff --git a/tgapi/attachments_types.go b/tgapi/attachments_types.go index 37cc19d..b0d2a89 100644 --- a/tgapi/attachments_types.go +++ b/tgapi/attachments_types.go @@ -70,5 +70,5 @@ type PhotoSize struct { FileUniqueID string `json:"file_unique_id"` Width int `json:"width"` Height int `json:"height"` - FileSize int `json:"file_size,omitempty"` + FileSize int64 `json:"file_size,omitempty"` } diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index 8946794..28e78f7 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -154,7 +154,7 @@ func (api *API) RemoveMyProfilePhoto() (bool, error) { // SetChatMenuButtonP holds parameters for the setChatMenuButton method. // See https://core.telegram.org/bots/api#setchatmenubutton type SetChatMenuButtonP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MenuButton MenuButtonType `json:"menu_button"` } @@ -169,7 +169,7 @@ func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) { // GetChatMenuButtonP holds parameters for the getChatMenuButton method. // See https://core.telegram.org/bots/api#getchatmenubutton type GetChatMenuButtonP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } // GetChatMenuButton returns the current menu button for the given chat. @@ -217,8 +217,8 @@ func (api *API) GetAvailableGifts() (Gifts, error) { // SendGiftP holds parameters for the sendGift method. // See https://core.telegram.org/bots/api#sendgift type SendGiftP struct { - UserID int `json:"user_id,omitempty"` - ChatID int `json:"chat_id,omitempty"` + UserID int64 `json:"user_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` GiftID string `json:"gift_id"` PayForUpgrade bool `json:"pay_for_upgrade"` Text string `json:"text"` @@ -237,7 +237,7 @@ func (api *API) SendGift(params SendGiftP) (bool, error) { // GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method. // See https://core.telegram.org/bots/api#giftpremiumsubscription type GiftPremiumSubscriptionP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` MonthCount int `json:"month_count"` StarCount int `json:"star_count"` Text string `json:"text,omitempty"` diff --git a/tgapi/bot_types.go b/tgapi/bot_types.go index b6baf2a..de660ee 100644 --- a/tgapi/bot_types.go +++ b/tgapi/bot_types.go @@ -31,8 +31,8 @@ const ( // See https://core.telegram.org/bots/api#botcommandscope type BotCommandScope struct { Type BotCommandScopeType `json:"type"` - ChatID *int `json:"chat_id,omitempty"` - UserID *int `json:"user_id,omitempty"` + ChatID *int64 `json:"chat_id,omitempty"` + UserID *int64 `json:"user_id,omitempty"` } // BotName represents the bot's name. diff --git a/tgapi/business_methods.go b/tgapi/business_methods.go index 9fcffda..476275c 100644 --- a/tgapi/business_methods.go +++ b/tgapi/business_methods.go @@ -3,7 +3,7 @@ package tgapi // VerifyUserP holds parameters for the verifyUser method. // See https://core.telegram.org/bots/api#verifyuser type VerifyUserP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` CustomDescription string `json:"custom_description,omitempty"` } @@ -18,7 +18,7 @@ func (api *API) VerifyUser(params VerifyUserP) (bool, error) { // VerifyChatP holds parameters for the verifyChat method. // See https://core.telegram.org/bots/api#verifychat type VerifyChatP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` CustomDescription string `json:"custom_description,omitempty"` } @@ -33,7 +33,7 @@ func (api *API) VerifyChat(params VerifyChatP) (bool, error) { // RemoveUserVerificationP holds parameters for the removeUserVerification method. // See https://core.telegram.org/bots/api#removeuserverification type RemoveUserVerificationP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // RemoveUserVerification removes a user's verification. @@ -47,7 +47,7 @@ func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, er // RemoveChatVerificationP holds parameters for the removeChatVerification method. // See https://core.telegram.org/bots/api#removechatverification type RemoveChatVerificationP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } // RemoveChatVerification removes a chat's verification. @@ -62,7 +62,7 @@ func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, er // See https://core.telegram.org/bots/api#readbusinessmessage type ReadBusinessMessageP struct { BusinessConnectionID string `json:"business_connection_id"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` } @@ -74,18 +74,31 @@ func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) { return req.Do(api) } -// DeleteBusinessMessageP holds parameters for the deleteBusinessMessage method. -// See https://core.telegram.org/bots/api#deletebusinessmessage -type DeleteBusinessMessageP struct { +// GetBusinessConnectionP holds parameters for the getBusinessConnection method. +// See https://core.telegram.org/bots/api#getbusinessconnection +type GetBusinessConnectionP struct { + BusinessConnectionID string `json:"business_connection_id"` +} + +// GetBusinessConnection returns information about a business connection. +// See https://core.telegram.org/bots/api#getbusinessconnection +func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessConnection, error) { + req := NewRequest[BusinessConnection]("getBusinessConnection", params) + return req.Do(api) +} + +// DeleteBusinessMessagesP holds parameters for the deleteBusinessMessages method. +// See https://core.telegram.org/bots/api#deletebusinessmessages +type DeleteBusinessMessagesP struct { BusinessConnectionID string `json:"business_connection_id"` MessageIDs []int `json:"message_ids"` } -// DeleteBusinessMessage deletes business messages. +// DeleteBusinessMessages deletes business messages. // Returns true on success. -// See https://core.telegram.org/bots/api#deletebusinessmessage -func (api *API) DeleteBusinessMessage(params DeleteBusinessMessageP) (bool, error) { - req := NewRequest[bool]("deleteBusinessMessage", params) +// See https://core.telegram.org/bots/api#deletebusinessmessages +func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, error) { + req := NewRequest[bool]("deleteBusinessMessages", params) return req.Do(api) } @@ -191,22 +204,22 @@ type GetBusinessAccountStarBalanceP struct { // GetBusinessAccountStarBalance returns the star balance of a business account. // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalanceP) (StarAmount, error) { - req := NewRequest[StarAmount]("getBusinessAccountGiftSettings", params) // Note: method name in call is incorrect, should be "getBusinessAccountStarBalance". We'll keep as is, but comment refers to correct. + req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params) return req.Do(api) } -// TransferBusinessAccountStartP holds parameters for the transferBusinessAccountStart method. -// See https://core.telegram.org/bots/api#transferbusinessaccountstart -type TransferBusinessAccountStartP struct { +// TransferBusinessAccountStarsP holds parameters for the transferBusinessAccountStars method. +// See https://core.telegram.org/bots/api#transferbusinessaccountstars +type TransferBusinessAccountStarsP struct { BusinessConnectionID string `json:"business_connection_id"` StarCount int `json:"star_count"` } -// TransferBusinessAccountStart transfers stars from a business account. +// TransferBusinessAccountStars transfers stars from a business account. // Returns true on success. -// See https://core.telegram.org/bots/api#transferbusinessaccountstart -func (api *API) TransferBusinessAccountStart(params TransferBusinessAccountStartP) (bool, error) { - req := NewRequest[bool]("transferBusinessAccountStart", params) +// See https://core.telegram.org/bots/api#transferbusinessaccountstars +func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStarsP) (bool, error) { + req := NewRequest[bool]("transferBusinessAccountStars", params) return req.Do(api) } @@ -270,7 +283,7 @@ func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) { type TransferGiftP struct { BusinessConnectionID string `json:"business_connection_id"` OwnedGiftID string `json:"owned_gift_id"` - NewOwnerChatID int `json:"new_owner_chat_id"` + NewOwnerChatID int64 `json:"new_owner_chat_id"` StarCount int `json:"star_count,omitempty"` } @@ -316,7 +329,7 @@ func (api *API) PostStoryVideo(params PostStoryP) (Story, error) { // See https://core.telegram.org/bots/api#repoststory type RepostStoryP struct { BusinessConnectionID string `json:"business_connection_id"` - FromChatID int `json:"from_chat_id"` + FromChatID int64 `json:"from_chat_id"` FromStoryID int `json:"from_story_id"` ActivePeriod int `json:"active_period"` PostToChatPage bool `json:"post_to_chat_page,omitempty"` diff --git a/tgapi/business_types.go b/tgapi/business_types.go index b2301ca..ca3c37a 100644 --- a/tgapi/business_types.go +++ b/tgapi/business_types.go @@ -54,7 +54,7 @@ type BusinessBotRights struct { type BusinessConnection struct { ID string `json:"id"` User User `json:"user"` - UserChatID int `json:"user_chat_id"` + UserChatID int64 `json:"user_chat_id"` Date int `json:"date"` Rights *BusinessBotRights `json:"rights,omitempty"` IsEnabled bool `json:"is_enabled"` diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index c9ba5c7..eb6ac4e 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -4,7 +4,7 @@ package tgapi // See https://core.telegram.org/bots/api#banchatmember type BanChatMemberP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` UntilDate int `json:"until_date,omitempty"` RevokeMessages bool `json:"revoke_messages,omitempty"` } @@ -21,7 +21,7 @@ func (api *API) BanChatMember(params BanChatMemberP) (bool, error) { // See https://core.telegram.org/bots/api#unbanchatmember type UnbanChatMemberP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` OnlyIfBanned bool `json:"only_if_banned"` } @@ -37,7 +37,7 @@ func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) { // See https://core.telegram.org/bots/api#restrictchatmember type RestrictChatMemberP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Permissions ChatPermissions `json:"permissions"` UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"` UntilDate int `json:"until_date,omitempty"` @@ -55,7 +55,7 @@ func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) { // See https://core.telegram.org/bots/api#promotechatmember type PromoteChatMember struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` IsAnonymous bool `json:"is_anonymous,omitempty"` CanManageChat bool `json:"can_manage_chat,omitempty"` @@ -88,7 +88,7 @@ func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) { // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle type SetChatAdministratorCustomTitleP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` CustomTitle string `json:"custom_title"` } @@ -104,7 +104,7 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto // See https://core.telegram.org/bots/api#setchatmembertag type SetChatMemberTagP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Tag string `json:"tag,omitempty"` } @@ -183,7 +183,7 @@ type CreateChatInviteLinkP struct { Name *string `json:"name,omitempty"` ExpireDate int `json:"expire_date,omitempty"` MemberLimit int `json:"member_limit,omitempty"` - CreatesJoinRequest int `json:"creates_join_request,omitempty"` + CreatesJoinRequest bool `json:"creates_join_request,omitempty"` } // CreateChatInviteLink creates an additional invite link for a chat. @@ -203,7 +203,7 @@ type EditChatInviteLinkP struct { Name string `json:"name,omitempty"` ExpireDate int `json:"expire_date,omitempty"` MemberLimit int `json:"member_limit,omitempty"` - CreatesJoinRequest int `json:"creates_join_request,omitempty"` + CreatesJoinRequest bool `json:"creates_join_request,omitempty"` } // EditChatInviteLink edits a non‑primary invite link. @@ -266,7 +266,7 @@ func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLi // See https://core.telegram.org/bots/api#approvechatjoinrequest type ApproveChatJoinRequestP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // ApproveChatJoinRequest approves a chat join request. @@ -281,7 +281,7 @@ func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, er // See https://core.telegram.org/bots/api#declinechatjoinrequest type DeclineChatJoinRequestP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // DeclineChatJoinRequest declines a chat join request. @@ -292,13 +292,23 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, er return req.Do(api) } -// SetChatPhoto is a stub method (needs implementation). -// Currently incomplete. -func (api *API) SetChatPhoto() { +// SetChatPhotoP holds parameters for the setChatPhoto method. +// See https://core.telegram.org/bots/api#setchatphoto +type SetChatPhotoP struct { + ChatID int64 `json:"chat_id"` +} + +// SetChatPhoto changes the chat photo. +// photo is the file to upload as the new photo. +// Returns True on success. +// See https://core.telegram.org/bots/api#setchatphoto +func (api *API) SetChatPhoto(params SetChatPhotoP, photo UploaderFile) (bool, error) { uploader := NewUploader(api) defer func() { _ = uploader.Close() }() + req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo.SetType(UploaderPhotoType)) + return req.Do(uploader) } // DeleteChatPhotoP holds parameters for the deleteChatPhoto method. @@ -449,7 +459,7 @@ func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) { // See https://core.telegram.org/bots/api#getchatmember type GetChatMemberP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // GetChatMember returns information about a member of a chat. @@ -492,7 +502,7 @@ func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) // See https://core.telegram.org/bots/api#getuserchatboosts type GetUserChatBoostsP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // GetUserChatBoosts returns the list of boosts a user has given to a chat. diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index e320fd7..a1d795b 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -26,7 +26,7 @@ const ( // ChatFullInfo contains full information about a chat. // See https://core.telegram.org/bots/api#chatfullinfo type ChatFullInfo struct { - ID int `json:"id"` + ID int64 `json:"id"` Type ChatType `json:"type"` Title string `json:"title"` Username string `json:"username"` @@ -78,7 +78,7 @@ type ChatFullInfo struct { StickerSetName *string `json:"sticker_set_name,omitempty"` CanSetStickerSet *bool `json:"can_set_sticker_set,omitempty"` CustomEmojiStickerSetName *string `json:"custom_emoji_sticker_set_name,omitempty"` - LinkedChatID *int `json:"linked_chat_id,omitempty"` + LinkedChatID *int64 `json:"linked_chat_id,omitempty"` Location *ChatLocation `json:"location,omitempty"` Rating *UserRating `json:"rating,omitempty"` @@ -108,7 +108,7 @@ type ChatPermissions struct { CanSendPolls bool `json:"can_send_polls"` CanSendOtherMessages bool `json:"can_send_other_messages"` CanAddWebPagePreview bool `json:"can_add_web_page_preview"` - CatEditTag bool `json:"cat_edit_tag"` // Note: field name likely a typo, should be "can_edit_tag" + CanEditTag bool `json:"can_edit_tag"` CanChangeInfo bool `json:"can_change_info"` CanInviteUsers bool `json:"can_invite_users"` CanPinMessages bool `json:"can_pin_messages"` @@ -127,7 +127,7 @@ type ChatLocation struct { type ChatInviteLink struct { InviteLink string `json:"invite_link"` Creator User `json:"creator"` - CreateJoinRequest bool `json:"create_join_request"` + CreateJoinRequest bool `json:"creates_join_request"` IsPrimary bool `json:"is_primary"` IsRevoked bool `json:"is_revoked"` diff --git a/tgapi/errors.go b/tgapi/errors.go index fa1d205..a7e83da 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -5,3 +5,4 @@ import "errors" var ErrRateLimit = errors.New("rate limit exceeded") var ErrPoolUnexpected = errors.New("unexpected response from pool") var ErrPoolQueueFull = errors.New("worker pool queue full") +var ErrPoolStopped = errors.New("worker pool stopped") diff --git a/tgapi/games_methods.go b/tgapi/games_methods.go new file mode 100644 index 0000000..4441edb --- /dev/null +++ b/tgapi/games_methods.go @@ -0,0 +1,69 @@ +package tgapi + +// SendGameP holds parameters for the sendGame method. +// See https://core.telegram.org/bots/api#sendgame +type SendGameP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + + GameShortName string `json:"game_short_name"` + + DisableNotification bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` + ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` +} + +// SendGame sends a game message. +// See https://core.telegram.org/bots/api#sendgame +func (api *API) SendGame(params SendGameP) (Message, error) { + req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID) + return req.Do(api) +} + +// SetGameScoreP holds parameters for the setGameScore method. +// See https://core.telegram.org/bots/api#setgamescore +type SetGameScoreP struct { + UserID int64 `json:"user_id"` + Score int `json:"score"` + Force bool `json:"force,omitempty"` + DisableEditMessage bool `json:"disable_edit_message,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` +} + +// SetGameScore sets a user's score in a game message. +// If inline_message_id is provided, returns a boolean success flag. +// Otherwise returns the edited Message. +// See https://core.telegram.org/bots/api#setgamescore +func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) { + var zero Message + if params.InlineMessageID != "" { + req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID) + res, err := req.Do(api) + return zero, res, err + } + req := NewRequestWithChatID[Message]("setGameScore", params, params.ChatID) + res, err := req.Do(api) + return res, false, err +} + +// GetGameHighScoresP holds parameters for the getGameHighScores method. +// See https://core.telegram.org/bots/api#getgamehighscores +type GetGameHighScoresP struct { + UserID int64 `json:"user_id"` + ChatID int64 `json:"chat_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` +} + +// GetGameHighScores returns game high score data for a user. +// See https://core.telegram.org/bots/api#getgamehighscores +func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, error) { + req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID) + return req.Do(api) +} diff --git a/tgapi/games_types.go b/tgapi/games_types.go new file mode 100644 index 0000000..f076a13 --- /dev/null +++ b/tgapi/games_types.go @@ -0,0 +1,9 @@ +package tgapi + +// GameHighScore represents one row in a game high score table. +// See https://core.telegram.org/bots/api#gamehighscore +type GameHighScore struct { + Position int `json:"position"` + User User `json:"user"` + Score int `json:"score"` +} diff --git a/tgapi/inline_methods.go b/tgapi/inline_methods.go new file mode 100644 index 0000000..1bcc48a --- /dev/null +++ b/tgapi/inline_methods.go @@ -0,0 +1,52 @@ +package tgapi + +// AnswerInlineQueryP holds parameters for the answerInlineQuery method. +// See https://core.telegram.org/bots/api#answerinlinequery +type AnswerInlineQueryP struct { + InlineQueryID string `json:"inline_query_id"` + Results []InlineQueryResult `json:"results"` + CacheTime int `json:"cache_time,omitempty"` + IsPersonal bool `json:"is_personal,omitempty"` + NextOffset string `json:"next_offset,omitempty"` + Button *InlineQueryResultsButton `json:"button,omitempty"` +} + +// AnswerInlineQuery sends answers to an inline query. +// Returns true on success. +// See https://core.telegram.org/bots/api#answerinlinequery +func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) { + req := NewRequest[bool]("answerInlineQuery", params) + return req.Do(api) +} + +// AnswerWebAppQueryP holds parameters for the answerWebAppQuery method. +// See https://core.telegram.org/bots/api#answerwebappquery +type AnswerWebAppQueryP struct { + WebAppQueryID string `json:"web_app_query_id"` + Result InlineQueryResult `json:"result"` +} + +// AnswerWebAppQuery sets the result of a Web App interaction. +// See https://core.telegram.org/bots/api#answerwebappquery +func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage, error) { + req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params) + return req.Do(api) +} + +// SavePreparedInlineMessageP holds parameters for the savePreparedInlineMessage method. +// See https://core.telegram.org/bots/api#savepreparedinlinemessage +type SavePreparedInlineMessageP struct { + UserID int64 `json:"user_id"` + Result InlineQueryResult `json:"result"` + AllowUserChats bool `json:"allow_user_chats,omitempty"` + AllowBotChats bool `json:"allow_bot_chats,omitempty"` + AllowGroupChats bool `json:"allow_group_chats,omitempty"` + AllowChannelChats bool `json:"allow_channel_chats,omitempty"` +} + +// SavePreparedInlineMessage stores a prepared message for Mini App users. +// See https://core.telegram.org/bots/api#savepreparedinlinemessage +func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (PreparedInlineMessage, error) { + req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params) + return req.Do(api) +} diff --git a/tgapi/inline_types.go b/tgapi/inline_types.go new file mode 100644 index 0000000..8dcab32 --- /dev/null +++ b/tgapi/inline_types.go @@ -0,0 +1,26 @@ +package tgapi + +// InlineQueryResult is a JSON-serializable inline query result object. +// See https://core.telegram.org/bots/api#inlinequeryresult +type InlineQueryResult map[string]any + +// InlineQueryResultsButton represents a button shown above inline query results. +// See https://core.telegram.org/bots/api#inlinequeryresultsbutton +type InlineQueryResultsButton struct { + Text string `json:"text"` + WebApp *WebAppInfo `json:"web_app,omitempty"` + StartParameter string `json:"start_parameter,omitempty"` +} + +// SentWebAppMessage describes an inline message sent by a Web App on behalf of a user. +// See https://core.telegram.org/bots/api#sentwebappmessage +type SentWebAppMessage struct { + InlineMessageID string `json:"inline_message_id,omitempty"` +} + +// PreparedInlineMessage describes a prepared inline message. +// See https://core.telegram.org/bots/api#preparedinlinemessage +type PreparedInlineMessage struct { + ID string `json:"id"` + ExpirationDate int `json:"expiration_date"` +} diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index 3892b4b..dc87f8b 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -12,7 +12,7 @@ type SendMessageP struct { ParseMode ParseMode `json:"parse_mode,omitempty"` Entities []MessageEntity `json:"entities,omitempty"` LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` + DisableNotifications bool `json:"disable_notification,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"` @@ -69,8 +69,8 @@ type ForwardMessagesP struct { // ForwardMessages forwards multiple messages. // Returns an array of message IDs of the sent messages. // See https://core.telegram.org/bots/api#forwardmessages -func (api *API) ForwardMessages(params ForwardMessagesP) ([]int, error) { - req := NewRequestWithChatID[[]int]("forwardMessages", params, params.ChatID) +func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) { + req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID) return req.Do(api) } @@ -103,8 +103,11 @@ type CopyMessageP struct { // Returns the MessageID of the sent copy. // See https://core.telegram.org/bots/api#copymessage func (api *API) CopyMessage(params CopyMessageP) (int, error) { - req := NewRequestWithChatID[int]("copyMessage", params, params.ChatID) - return req.Do(api) + msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).Do(api) + if err != nil { + return 0, err + } + return msgID.MessageID, nil } // CopyMessagesP holds parameters for the copyMessages method. @@ -124,18 +127,18 @@ type CopyMessagesP struct { // CopyMessages copies multiple messages. // Returns an array of message IDs of the sent copies. // See https://core.telegram.org/bots/api#copymessages -func (api *API) CopyMessages(params CopyMessagesP) ([]int, error) { - req := NewRequestWithChatID[[]int]("copyMessages", params, params.ChatID) +func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) { + req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID) return req.Do(api) } // SendLocationP holds parameters for the sendLocation method. // See https://core.telegram.org/bots/api#sendlocation type SendLocationP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` @@ -164,10 +167,10 @@ func (api *API) SendLocation(params SendLocationP) (Message, error) { // SendVenueP holds parameters for the sendVenue method. // See https://core.telegram.org/bots/api#sendvenue type SendVenueP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` @@ -198,10 +201,10 @@ func (api *API) SendVenue(params SendVenueP) (Message, error) { // SendContactP holds parameters for the sendContact method. // See https://core.telegram.org/bots/api#sendcontact type SendContactP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` PhoneNumber string `json:"phone_number"` FirstName string `json:"first_name"` @@ -228,12 +231,12 @@ func (api *API) SendContact(params SendContactP) (Message, error) { // SendPollP holds parameters for the sendPoll method. // See https://core.telegram.org/bots/api#sendpoll type SendPollP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` Question string `json:"question"` - QuestionParseMode ParseMode `json:"question_mode,omitempty"` + QuestionParseMode ParseMode `json:"question_parse_mode,omitempty"` QuestionEntities []MessageEntity `json:"question_entities,omitempty"` Options []InputPollOption `json:"options"` IsAnonymous bool `json:"is_anonymous,omitempty"` @@ -266,7 +269,7 @@ func (api *API) SendPoll(params SendPollP) (Message, error) { // SendChecklistP holds parameters for the sendChecklist method. // See https://core.telegram.org/bots/api#sendchecklist type SendChecklistP struct { - BusinessConnectionID int `json:"business_connection_id"` + BusinessConnectionID string `json:"business_connection_id"` ChatID int64 `json:"chat_id"` Checklist InputChecklist `json:"checklist"` @@ -288,10 +291,10 @@ func (api *API) SendChecklist(params SendChecklistP) (Message, error) { // SendDiceP holds parameters for the sendDice method. // See https://core.telegram.org/bots/api#senddice type SendDiceP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Emoji string `json:"emoji,omitempty"` @@ -313,6 +316,7 @@ func (api *API) SendDice(params SendDiceP) (Message, error) { } // SendMessageDraftP holds parameters for the sendMessageDraft method. +// See https://core.telegram.org/bots/api#sendmessagedraft type SendMessageDraftP struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -322,7 +326,9 @@ type SendMessageDraftP struct { Entities []MessageEntity `json:"entities,omitempty"` } -// SendMessageDraft sends a previously saved draft message. +// SendMessageDraft sends or updates a draft message in the target chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#sendmessagedraft func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) { req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID) return req.Do(api) @@ -425,7 +431,7 @@ type EditMessageMediaP struct { ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"` - Message InputMedia `json:"message"` + Media InputMedia `json:"media"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index cac182c..642c66b 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -2,6 +2,11 @@ package tgapi import "git.nix13.pw/scuroneko/extypes" +// MessageID represents a message identifier wrapper returned by some API methods. +type MessageID struct { + MessageID int `json:"message_id"` +} + // MessageReplyMarkup represents an inline keyboard markup for a message. // It is used in the Message type. type MessageReplyMarkup struct { @@ -113,8 +118,8 @@ type MessageEntity struct { // ReplyParameters describes the parameters to use when replying to a message. // See https://core.telegram.org/bots/api#replyparameters type ReplyParameters struct { - MessageID int `json:"message_id"` - ChatID int `json:"chat_id,omitempty"` + MessageID int `json:"message_id"` + ChatID int64 `json:"chat_id,omitempty"` AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` Quote string `json:"quote,omitempty"` @@ -179,11 +184,13 @@ type ReplyKeyboardMarkup struct { // CallbackQuery represents an incoming callback query from a callback button in an inline keyboard. // See https://core.telegram.org/bots/api#callbackquery type CallbackQuery struct { - ID string `json:"id"` - From User `json:"from"` - Message Message `json:"message"` - - Data string `json:"data"` + ID string `json:"id"` + From User `json:"from"` + Message *Message `json:"message,omitempty"` + InlineMessageID *string `json:"inline_message_id,omitempty"` + ChatInstance string `json:"chat_instance,omitempty"` + Data string `json:"data,omitempty"` + GameShortName string `json:"game_short_name,omitempty"` } // InputPollOption contains information about one answer option in a poll to be sent. diff --git a/tgapi/methods.go b/tgapi/methods.go index 44887d3..37bc843 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -6,26 +6,6 @@ import ( "net/http" ) -// ParseMode represents the text formatting mode for message parsing. -type ParseMode string - -const ( - // ParseMDV2 enables MarkdownV2 style parsing. - ParseMDV2 ParseMode = "MarkdownV2" - // ParseHTML enables HTML style parsing. - ParseHTML ParseMode = "HTML" - // ParseMD enables legacy Markdown style parsing. - ParseMD ParseMode = "Markdown" - // ParseNone disables any parsing. - ParseNone ParseMode = "None" -) - -// EmptyParams is a placeholder for methods that take no parameters. -type EmptyParams struct{} - -// NoParams is a convenient instance of EmptyParams. -var NoParams = EmptyParams{} - // UpdateParams holds parameters for the getUpdates method. // See https://core.telegram.org/bots/api#getupdates type UpdateParams struct { @@ -65,6 +45,47 @@ func (api *API) GetUpdates(params UpdateParams) ([]Update, error) { return req.Do(api) } +// SetWebhookP holds parameters for the setWebhook method. +// See https://core.telegram.org/bots/api#setwebhook +type SetWebhookP struct { + URL string `json:"url"` + Certificate string `json:"certificate,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` + DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` + SecretToken string `json:"secret_token,omitempty"` +} + +// SetWebhook sets a webhook URL for incoming updates. +// Returns true on success. +// See https://core.telegram.org/bots/api#setwebhook +func (api *API) SetWebhook(params SetWebhookP) (bool, error) { + req := NewRequest[bool]("setWebhook", params) + return req.Do(api) +} + +// DeleteWebhookP holds parameters for the deleteWebhook method. +// See https://core.telegram.org/bots/api#deletewebhook +type DeleteWebhookP struct { + DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` +} + +// DeleteWebhook removes the current webhook integration. +// Returns true on success. +// See https://core.telegram.org/bots/api#deletewebhook +func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) { + req := NewRequest[bool]("deleteWebhook", params) + return req.Do(api) +} + +// GetWebhookInfo returns the current webhook status. +// See https://core.telegram.org/bots/api#getwebhookinfo +func (api *API) GetWebhookInfo() (WebhookInfo, error) { + req := NewRequest[WebhookInfo]("getWebhookInfo", NoParams) + return req.Do(api) +} + // GetFileP holds parameters for the getFile method. // See https://core.telegram.org/bots/api#getfile type GetFileP struct { diff --git a/tgapi/methods_types.go b/tgapi/methods_types.go new file mode 100644 index 0000000..7e8a103 --- /dev/null +++ b/tgapi/methods_types.go @@ -0,0 +1,35 @@ +package tgapi + +// ParseMode represents the text formatting mode for message parsing. +type ParseMode string + +const ( + // ParseMDV2 enables MarkdownV2 style parsing. + ParseMDV2 ParseMode = "MarkdownV2" + // ParseHTML enables HTML style parsing. + ParseHTML ParseMode = "HTML" + // ParseMD enables legacy Markdown style parsing. + ParseMD ParseMode = "Markdown" + // ParseNone disables any parsing. + ParseNone ParseMode = "None" +) + +// EmptyParams is a placeholder for methods that take no parameters. +type EmptyParams struct{} + +// NoParams is a convenient instance of EmptyParams. +var NoParams = EmptyParams{} + +// WebhookInfo describes the current webhook status. +// See https://core.telegram.org/bots/api#webhookinfo +type WebhookInfo struct { + URL string `json:"url"` + HasCustomCertificate bool `json:"has_custom_certificate"` + PendingUpdateCount int `json:"pending_update_count"` + IPAddress string `json:"ip_address,omitempty"` + LastErrorDate int `json:"last_error_date,omitempty"` + LastErrorMessage string `json:"last_error_message,omitempty"` + LastSynchronizationErrorDate int `json:"last_synchronization_error_date,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + AllowedUpdates []string `json:"allowed_updates,omitempty"` +} diff --git a/tgapi/passport_methods.go b/tgapi/passport_methods.go new file mode 100644 index 0000000..d6e5ae8 --- /dev/null +++ b/tgapi/passport_methods.go @@ -0,0 +1,16 @@ +package tgapi + +// SetPassportDataErrorsP holds parameters for the setPassportDataErrors method. +// See https://core.telegram.org/bots/api#setpassportdataerrors +type SetPassportDataErrorsP struct { + UserID int64 `json:"user_id"` + Errors []PassportElementError `json:"errors"` +} + +// SetPassportDataErrors informs a user about Telegram Passport data errors. +// Returns true on success. +// See https://core.telegram.org/bots/api#setpassportdataerrors +func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, error) { + req := NewRequest[bool]("setPassportDataErrors", params) + return req.Do(api) +} diff --git a/tgapi/passport_types.go b/tgapi/passport_types.go new file mode 100644 index 0000000..b8e7ce7 --- /dev/null +++ b/tgapi/passport_types.go @@ -0,0 +1,5 @@ +package tgapi + +// PassportElementError is a JSON-serializable passport element error object. +// See https://core.telegram.org/bots/api#passportelementerror +type PassportElementError map[string]any diff --git a/tgapi/payments_methods.go b/tgapi/payments_methods.go new file mode 100644 index 0000000..ef87590 --- /dev/null +++ b/tgapi/payments_methods.go @@ -0,0 +1,117 @@ +package tgapi + +// SendInvoiceP holds parameters for the sendInvoice method. +// See https://core.telegram.org/bots/api#sendinvoice +type SendInvoiceP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + + Title string `json:"title"` + Description string `json:"description"` + Payload string `json:"payload"` + ProviderToken string `json:"provider_token,omitempty"` + Currency string `json:"currency"` + Prices []LabeledPrice `json:"prices"` + + MaxTipAmount int `json:"max_tip_amount,omitempty"` + SuggestedTipAmounts []int `json:"suggested_tip_amounts,omitempty"` + StartParameter string `json:"start_parameter,omitempty"` + ProviderData string `json:"provider_data,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + PhotoSize int `json:"photo_size,omitempty"` + PhotoWidth int `json:"photo_width,omitempty"` + PhotoHeight int `json:"photo_height,omitempty"` + NeedName bool `json:"need_name,omitempty"` + NeedPhoneNumber bool `json:"need_phone_number,omitempty"` + NeedEmail bool `json:"need_email,omitempty"` + NeedShippingAddress bool `json:"need_shipping_address,omitempty"` + SendPhoneToProvider bool `json:"send_phone_number_to_provider,omitempty"` + SendEmailToProvider bool `json:"send_email_to_provider,omitempty"` + IsFlexible bool `json:"is_flexible,omitempty"` + DisableNotification bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + + SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` + ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` + ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` +} + +// SendInvoice sends an invoice. +// See https://core.telegram.org/bots/api#sendinvoice +func (api *API) SendInvoice(params SendInvoiceP) (Message, error) { + req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID) + return req.Do(api) +} + +// CreateInvoiceLinkP holds parameters for the createInvoiceLink method. +// See https://core.telegram.org/bots/api#createinvoicelink +type CreateInvoiceLinkP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + + Title string `json:"title"` + Description string `json:"description"` + Payload string `json:"payload"` + ProviderToken string `json:"provider_token,omitempty"` + Currency string `json:"currency"` + Prices []LabeledPrice `json:"prices"` + + SubscriptionPeriod int `json:"subscription_period,omitempty"` + MaxTipAmount int `json:"max_tip_amount,omitempty"` + SuggestedTipAmounts []int `json:"suggested_tip_amounts,omitempty"` + ProviderData string `json:"provider_data,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + PhotoSize int `json:"photo_size,omitempty"` + PhotoWidth int `json:"photo_width,omitempty"` + PhotoHeight int `json:"photo_height,omitempty"` + NeedName bool `json:"need_name,omitempty"` + NeedPhoneNumber bool `json:"need_phone_number,omitempty"` + NeedEmail bool `json:"need_email,omitempty"` + NeedShippingAddress bool `json:"need_shipping_address,omitempty"` + SendPhoneToProvider bool `json:"send_phone_number_to_provider,omitempty"` + SendEmailToProvider bool `json:"send_email_to_provider,omitempty"` + IsFlexible bool `json:"is_flexible,omitempty"` +} + +// CreateInvoiceLink creates an invoice link. +// See https://core.telegram.org/bots/api#createinvoicelink +func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) { + req := NewRequest[string]("createInvoiceLink", params) + return req.Do(api) +} + +// AnswerShippingQueryP holds parameters for the answerShippingQuery method. +// See https://core.telegram.org/bots/api#answershippingquery +type AnswerShippingQueryP struct { + ShippingQueryID string `json:"shipping_query_id"` + OK bool `json:"ok"` + ShippingOptions []ShippingOption `json:"shipping_options,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// AnswerShippingQuery answers a shipping query. +// Returns true on success. +// See https://core.telegram.org/bots/api#answershippingquery +func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) { + req := NewRequest[bool]("answerShippingQuery", params) + return req.Do(api) +} + +// AnswerPreCheckoutQueryP holds parameters for the answerPreCheckoutQuery method. +// See https://core.telegram.org/bots/api#answerprecheckoutquery +type AnswerPreCheckoutQueryP struct { + PreCheckoutQueryID string `json:"pre_checkout_query_id"` + OK bool `json:"ok"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// AnswerPreCheckoutQuery answers a pre-checkout query. +// Returns true on success. +// See https://core.telegram.org/bots/api#answerprecheckoutquery +func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, error) { + req := NewRequest[bool]("answerPreCheckoutQuery", params) + return req.Do(api) +} diff --git a/tgapi/payments_types.go b/tgapi/payments_types.go new file mode 100644 index 0000000..50449cb --- /dev/null +++ b/tgapi/payments_types.go @@ -0,0 +1,16 @@ +package tgapi + +// LabeledPrice represents a price portion. +// See https://core.telegram.org/bots/api#labeledprice +type LabeledPrice struct { + Label string `json:"label"` + Amount int `json:"amount"` +} + +// ShippingOption represents one shipping option. +// See https://core.telegram.org/bots/api#shippingoption +type ShippingOption struct { + ID string `json:"id"` + Title string `json:"title"` + Prices []LabeledPrice `json:"prices"` +} diff --git a/tgapi/pool.go b/tgapi/pool.go index 774938a..8c15fa4 100644 --- a/tgapi/pool.go +++ b/tgapi/pool.go @@ -14,13 +14,16 @@ type workerPool struct { workers int // количество воркеров (горутин) wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке quit chan struct{} // канал для сигнала остановки + stopOnce sync.Once // гарантирует идемпотентную остановку пула started bool // флаг, указывающий, запущен ли пул + stopped bool // флаг, указывающий, что пул остановлен startedMu sync.Mutex // мьютекс для безопасного доступа к started } // requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата. // Используется только внутри пакета для передачи задач воркерам. type requestEnvelope struct { + ctx context.Context // контекст конкретной задачи doFunc func(context.Context) (any, error) // функция, выполняющая запрос resultCh chan requestResult // канал, через который воркер вернёт результат } @@ -53,7 +56,7 @@ func newWorkerPool(workers int, queueSize int) *workerPool { // start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди. // Метод идемпотентен: если пул уже запущен — ничего не делает. // Должен вызываться перед первым вызовом submit. -func (p *workerPool) start(ctx context.Context) { +func (p *workerPool) start() { p.startedMu.Lock() defer p.startedMu.Unlock() if p.started { @@ -64,7 +67,7 @@ func (p *workerPool) start(ctx context.Context) { // Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле for i := 0; i < p.workers; i++ { p.wg.Add(1) - go p.worker(ctx) // запускаем горутину с контекстом + go p.worker() // запускаем горутину } } @@ -72,8 +75,15 @@ func (p *workerPool) start(ctx context.Context) { // Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач. // Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта. func (p *workerPool) stop() { - close(p.quit) // сигнал для всех воркеров — выйти из цикла - p.wg.Wait() // ждём, пока все воркеры завершатся + p.stopOnce.Do(func() { + p.startedMu.Lock() + p.stopped = true + p.started = false + close(p.quit) // сигнал для всех воркеров — выйти из цикла + p.startedMu.Unlock() + + p.wg.Wait() // ждём, пока все воркеры завершатся + }) } // submit отправляет задачу в очередь и возвращает канал, через который будет получен результат. @@ -81,8 +91,15 @@ func (p *workerPool) stop() { // Канал результата имеет буфер 1, чтобы не блокировать воркера при записи. // Контекст используется для отмены задачи, если клиент отменил запрос до отправки. func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) { + p.startedMu.Lock() + if p.stopped || !p.started { + p.startedMu.Unlock() + return nil, ErrPoolStopped + } + // Проверяем, не превышена ли очередь if len(p.taskCh) >= p.queueSize { + p.startedMu.Unlock() return nil, ErrPoolQueueFull } @@ -91,6 +108,7 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, // Создаём обёртку задачи envelope := requestEnvelope{ + ctx: ctx, doFunc: do, resultCh: resultCh, } @@ -98,12 +116,15 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, // Пытаемся отправить задачу в очередь select { case <-ctx.Done(): + p.startedMu.Unlock() // Клиент отменил операцию до отправки — возвращаем ошибку отмены return nil, ctx.Err() case p.taskCh <- envelope: + p.startedMu.Unlock() // Успешно отправлено — возвращаем канал для чтения результата return resultCh, nil default: + p.startedMu.Unlock() // Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай return nil, ErrPoolQueueFull } @@ -117,26 +138,38 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, // - закрывает канал, чтобы клиент мог прочитать и завершить // // После закрытия quit-канала — воркер завершает работу. -func (p *workerPool) worker(ctx context.Context) { +func (p *workerPool) worker() { defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины for { select { case <-p.quit: - // Получен сигнал остановки — выходим из цикла - return + // Получен сигнал остановки — дренируем очередь и выходим. + // После stop() новые задачи не принимаются. + for { + select { + case envelope := <-p.taskCh: + p.executeEnvelope(envelope) + default: + return + } + } case envelope := <-p.taskCh: - // Выполняем задачу с переданным контекстом (клиентский или общий) - value, err := envelope.doFunc(ctx) - - // Записываем результат в канал — не блокируем, т.к. буфер 1 - envelope.resultCh <- requestResult{ - value: value, - err: err, - } - // Закрываем канал — клиент знает, что результат пришёл и больше не будет - close(envelope.resultCh) + p.executeEnvelope(envelope) } } } + +func (p *workerPool) executeEnvelope(envelope requestEnvelope) { + // Выполняем задачу с переданным контекстом (клиентский или общий) + value, err := envelope.doFunc(envelope.ctx) + + // Записываем результат в канал — не блокируем, т.к. буфер 1 + envelope.resultCh <- requestResult{ + value: value, + err: err, + } + // Закрываем канал — клиент знает, что результат пришёл и больше не будет + close(envelope.resultCh) +} diff --git a/tgapi/stars_methods.go b/tgapi/stars_methods.go new file mode 100644 index 0000000..7e3e0ab --- /dev/null +++ b/tgapi/stars_methods.go @@ -0,0 +1,53 @@ +package tgapi + +// GetStarTransactionsP holds parameters for the getStarTransactions method. +// See https://core.telegram.org/bots/api#getstartransactions +type GetStarTransactionsP struct { + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// GetMyStarBalance returns the bot's Telegram Star balance. +// See https://core.telegram.org/bots/api#getmystarbalance +func (api *API) GetMyStarBalance() (StarAmount, error) { + req := NewRequest[StarAmount]("getMyStarBalance", NoParams) + return req.Do(api) +} + +// GetStarTransactions returns Telegram Star transactions for the bot. +// See https://core.telegram.org/bots/api#getstartransactions +func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) { + req := NewRequest[StarTransactions]("getStarTransactions", params) + return req.Do(api) +} + +// RefundStarPaymentP holds parameters for the refundStarPayment method. +// See https://core.telegram.org/bots/api#refundstarpayment +type RefundStarPaymentP struct { + UserID int64 `json:"user_id"` + TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` +} + +// RefundStarPayment refunds a successful Telegram Stars payment. +// Returns true on success. +// See https://core.telegram.org/bots/api#refundstarpayment +func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) { + req := NewRequest[bool]("refundStarPayment", params) + return req.Do(api) +} + +// EditUserStarSubscriptionP holds parameters for the editUserStarSubscription method. +// See https://core.telegram.org/bots/api#edituserstarsubscription +type EditUserStarSubscriptionP struct { + UserID int64 `json:"user_id"` + TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` + IsCanceled bool `json:"is_canceled"` +} + +// EditUserStarSubscription cancels or re-enables a user star subscription extension. +// Returns true on success. +// See https://core.telegram.org/bots/api#edituserstarsubscription +func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool, error) { + req := NewRequest[bool]("editUserStarSubscription", params) + return req.Do(api) +} diff --git a/tgapi/stars_types.go b/tgapi/stars_types.go new file mode 100644 index 0000000..1bb4bc1 --- /dev/null +++ b/tgapi/stars_types.go @@ -0,0 +1,18 @@ +package tgapi + +// StarTransaction describes a Telegram Star transaction. +// See https://core.telegram.org/bots/api#startransaction +type StarTransaction struct { + ID string `json:"id"` + Amount int `json:"amount"` + NanostarAmount int `json:"nanostar_amount,omitempty"` + Date int `json:"date"` + Source map[string]any `json:"source,omitempty"` + Receiver map[string]any `json:"receiver,omitempty"` +} + +// StarTransactions contains a list of Telegram Star transactions. +// See https://core.telegram.org/bots/api#startransactions +type StarTransactions struct { + Transactions []StarTransaction `json:"transactions"` +} diff --git a/tgapi/stickers_methods.go b/tgapi/stickers_methods.go index ff0b944..77cad3b 100644 --- a/tgapi/stickers_methods.go +++ b/tgapi/stickers_methods.go @@ -49,10 +49,29 @@ func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticke return req.Do(api) } +// UploadStickerFileP holds parameters for the uploadStickerFile method. +// See https://core.telegram.org/bots/api#uploadstickerfile +type UploadStickerFileP struct { + UserID int64 `json:"user_id"` + StickerFormat InputStickerFormat `json:"sticker_format"` +} + +// UploadStickerFile uploads a sticker file for later use in sticker set methods. +// sticker is the file to upload. +// See https://core.telegram.org/bots/api#uploadstickerfile +func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFile) (File, error) { + uploader := NewUploader(api) + defer func() { + _ = uploader.Close() + }() + req := NewUploaderRequest[File]("uploadStickerFile", params, sticker.SetType(UploaderStickerType)) + return req.Do(uploader) +} + // CreateNewStickerSetP holds parameters for the createNewStickerSet method. // See https://core.telegram.org/bots/api#createnewstickerset type CreateNewStickerSetP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Name string `json:"name"` Title string `json:"title"` @@ -72,7 +91,7 @@ func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) { // AddStickerToSetP holds parameters for the addStickerToSet method. // See https://core.telegram.org/bots/api#addstickertoset type AddStickerToSetP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Name string `json:"name"` Sticker InputSticker `json:"sticker"` } @@ -117,7 +136,7 @@ func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error) // ReplaceStickerInSetP holds parameters for the replaceStickerInSet method. // See https://core.telegram.org/bots/api#replacestickerinset type ReplaceStickerInSetP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Name string `json:"name"` OldSticker string `json:"old_sticker"` Sticker InputSticker `json:"sticker"` @@ -195,7 +214,7 @@ func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) { // See https://core.telegram.org/bots/api#setstickersetthumbnail type SetStickerSetThumbnailP struct { Name string `json:"name"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Thumbnail string `json:"thumbnail"` Format InputStickerFormat `json:"format"` } @@ -218,9 +237,7 @@ type SetCustomEmojiStickerSetThumbnailP struct { // SetCustomEmojiStickerSetThumbnail sets the thumbnail of a custom emoji sticker set. // Returns True on success. // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail -// -// Note: This method uses SetStickerSetThumbnailP as its parameter type, which might be inconsistent. -func (api *API) SetCustomEmojiStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) { +func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSetThumbnailP) (bool, error) { req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params) return req.Do(api) } diff --git a/tgapi/stickers_types.go b/tgapi/stickers_types.go index d9056c8..883deb2 100644 --- a/tgapi/stickers_types.go +++ b/tgapi/stickers_types.go @@ -52,7 +52,7 @@ type Sticker struct { MaskPosition *MaskPosition `json:"mask_position,omitempty"` CustomEmojiID *string `json:"custom_emoji_id,omitempty"` NeedRepainting *bool `json:"need_repainting,omitempty"` - FileSize *int `json:"file_size,omitempty"` + FileSize *int64 `json:"file_size,omitempty"` } // StickerSet represents a sticker set. diff --git a/tgapi/types.go b/tgapi/types.go index 6a44faf..ed3c3d7 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -160,7 +160,7 @@ type PaidMediaPurchased struct { type File struct { FileId string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` - FileSize int `json:"file_size,omitempty"` + FileSize int64 `json:"file_size,omitempty"` FilePath string `json:"file_path,omitempty"` } @@ -175,7 +175,7 @@ type Audio struct { Title string `json:"title,omitempty"` FileName string `json:"file_name,omitempty"` MimeType string `json:"mime_type,omitempty"` - FileSize int `json:"file_size,omitempty"` + FileSize int64 `json:"file_size,omitempty"` Thumbnail *PhotoSize `json:"thumbnail,omitempty"` } @@ -234,7 +234,7 @@ type ChatMemberUpdated struct { type ChatJoinRequest struct { Chat Chat `json:"chat"` From User `json:"from"` - UserChatID int `json:"user_chat_id"` + UserChatID int64 `json:"user_chat_id"` Date int64 `json:"date"` Bio *string `json:"bio,omitempty"` InviteLink *ChatInviteLink `json:"invite_link,omitempty"` diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index efa0451..24cf8c5 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -14,13 +14,22 @@ import ( ) const ( - UploaderPhotoType UploaderFileType = "photo" - UploaderVideoType UploaderFileType = "video" - UploaderAudioType UploaderFileType = "audio" - UploaderDocumentType UploaderFileType = "document" - UploaderVoiceType UploaderFileType = "voice" + // UploaderPhotoType is the multipart field name for photo uploads. + UploaderPhotoType UploaderFileType = "photo" + // UploaderVideoType is the multipart field name for video uploads. + UploaderVideoType UploaderFileType = "video" + // UploaderAudioType is the multipart field name for audio uploads. + UploaderAudioType UploaderFileType = "audio" + // UploaderDocumentType is the multipart field name for document uploads. + UploaderDocumentType UploaderFileType = "document" + // UploaderVoiceType is the multipart field name for voice uploads. + UploaderVoiceType UploaderFileType = "voice" + // UploaderVideoNoteType is the multipart field name for video-note uploads. UploaderVideoNoteType UploaderFileType = "video_note" + // UploaderThumbnailType is the multipart field name for thumbnail uploads. UploaderThumbnailType UploaderFileType = "thumbnail" + // UploaderStickerType is the multipart field name for sticker uploads. + UploaderStickerType UploaderFileType = "sticker" ) // UploaderFileType represents the Telegram form field name for a file upload. @@ -40,24 +49,35 @@ func NewUploaderFile(name string, data []byte) UploaderFile { return UploaderFile{filename: name, data: data, field: t} } -// SetType used when auto-detect failed. -// i.e. you sending a voice message, but it detects as audio, or if you send audio with thumbnail +// SetType overrides the auto-detected upload field type. +// For example, use it when a voice file is detected as audio. func (f UploaderFile) SetType(t UploaderFileType) UploaderFile { f.field = t return f } +// Uploader is a Telegram Bot API client specialized for multipart file uploads. +// +// Use Uploader methods when you need to upload binary files directly +// (InputFile/multipart). For JSON-only calls (file_id, URL, plain params), use API. type Uploader struct { api *API logger *slog.Logger } +// NewUploader creates a multipart uploader bound to an API client. func NewUploader(api *API) *Uploader { logger := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("UPLOADER") logger.AddWriter(logger.CreateJsonStdoutWriter()) return &Uploader{api, logger} } -func (u *Uploader) Close() error { return u.logger.Close() } + +// Close flushes and closes uploader logger resources. +// See https://core.telegram.org/bots/api +func (u *Uploader) Close() error { return u.logger.Close() } + +// GetLogger returns uploader logger instance. +// See https://core.telegram.org/bots/api func (u *Uploader) GetLogger() *slog.Logger { return u.logger } // UploaderRequest is a multipart file upload request to the Telegram API. @@ -90,14 +110,8 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, for { if up.api.Limiter != nil { - if up.api.dropOverflowLimit { - if !up.api.Limiter.GlobalAllow() { - return zero, utils.ErrDropOverflow - } - } else { - if err := up.api.Limiter.GlobalWait(ctx); err != nil { - return zero, err - } + if err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatId); err != nil { + return zero, err } } diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index 892674d..0ab9547 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -24,10 +24,10 @@ type UploadPhotoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadPhoto uploads a photo and sends it as a message. +// SendPhoto uploads a photo via multipart and sends it as a message. // file is the photo file to upload. // See https://core.telegram.org/bots/api#sendphoto -func (u *Uploader) UploadPhoto(params UploadPhotoP, file UploaderFile) (Message, error) { +func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file) return req.Do(u) } @@ -58,10 +58,10 @@ type UploadAudioP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadAudio uploads an audio file and sends it as a message. +// SendAudio uploads an audio file via multipart and sends it as a message. // files are the audio file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendaudio -func (u *Uploader) UploadAudio(params UploadAudioP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...) return req.Do(u) } @@ -89,11 +89,11 @@ type UploadDocumentP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadDocument uploads a document and sends it as a message. +// SendDocument uploads a document via multipart and sends it as a message. // files are the document file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#senddocument -func (u *Uploader) UploadDocument(params UploadDocumentP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendDocument", params, files...) +func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...) return req.Do(u) } @@ -127,11 +127,11 @@ type UploadVideoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadVideo uploads a video and sends it as a message. +// SendVideo uploads a video via multipart and sends it as a message. // files are the video file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvideo -func (u *Uploader) UploadVideo(params UploadVideoP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendVideo", params, files...) +func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...) return req.Do(u) } @@ -163,11 +163,11 @@ type UploadAnimationP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadAnimation uploads an animation (GIF or H.264/MPEG-4 AVC video without sound) and sends it as a message. +// SendAnimation uploads an animation via multipart and sends it as a message. // files are the animation file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendanimation -func (u *Uploader) UploadAnimation(params UploadAnimationP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendAnimation", params, files...) +func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...) return req.Do(u) } @@ -194,11 +194,11 @@ type UploadVoiceP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadVoice uploads a voice note and sends it as a message. +// SendVoice uploads a voice note via multipart and sends it as a message. // files are the voice file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvoice -func (u *Uploader) UploadVoice(params UploadVoiceP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendVoice", params, files...) +func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...) return req.Do(u) } @@ -223,11 +223,11 @@ type UploadVideoNoteP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadVideoNote uploads a video note (rounded video) and sends it as a message. +// SendVideoNote uploads a video note via multipart and sends it as a message. // files are the video note file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvideonote -func (u *Uploader) UploadVideoNote(params UploadVideoNoteP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendVideoNote", params, files...) +func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...) return req.Do(u) } @@ -237,10 +237,10 @@ type UploadChatPhotoP struct { ChatID int64 `json:"chat_id"` } -// UploadChatPhoto uploads a new chat photo. +// SetChatPhoto uploads a new chat photo. // photo is the photo file to upload. // See https://core.telegram.org/bots/api#setchatphoto -func (u *Uploader) UploadChatPhoto(params UploadChatPhotoP, photo UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendChatPhoto", params, photo) +func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bool, error) { + req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo) return req.Do(u) } diff --git a/tgapi/users_methods.go b/tgapi/users_methods.go index 637c316..ff5ea01 100644 --- a/tgapi/users_methods.go +++ b/tgapi/users_methods.go @@ -3,9 +3,9 @@ package tgapi // GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method. // See https://core.telegram.org/bots/api#getuserprofilephotos type GetUserProfilePhotosP struct { - UserID int `json:"user_id"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` + UserID int64 `json:"user_id"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` } // GetUserProfilePhotos returns a list of profile pictures for a user. @@ -18,9 +18,9 @@ func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfileP // GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method. // See https://core.telegram.org/bots/api#getuserprofileaudios type GetUserProfileAudiosP struct { - UserID int `json:"user_id"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` + UserID int64 `json:"user_id"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` } // GetUserProfileAudios returns a list of profile audios for a user. @@ -33,7 +33,7 @@ func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileA // SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method. // See https://core.telegram.org/bots/api#setuseremojistatus type SetUserEmojiStatusP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` EmojiID string `json:"emoji_status_custom_emoji_id,omitempty"` ExpirationDate int `json:"emoji_status_expiration_date,omitempty"` } @@ -49,7 +49,7 @@ func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) { // GetUserGiftsP holds parameters for the GetUserGifts method. // See https://core.telegram.org/bots/api#getusergifts type GetUserGiftsP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` ExcludeUnlimited bool `json:"exclude_unlimited,omitempty"` ExcludeLimitedUpgradable bool `json:"exclude_limited_upgradable,omitempty"` ExcludeLimitedNonUpgradable bool `json:"exclude_limited_non_upgradable,omitempty"` diff --git a/tgapi/users_types.go b/tgapi/users_types.go index d086ce6..94eeeb8 100644 --- a/tgapi/users_types.go +++ b/tgapi/users_types.go @@ -3,7 +3,7 @@ package tgapi // User represents a Telegram user or bot. // See https://core.telegram.org/bots/api#user type User struct { - ID int `json:"id"` + ID int64 `json:"id"` IsBot bool `json:"is_bot"` FirstName string `json:"first_name"` LastName *string `json:"last_name,omitempty"` diff --git a/utils.go b/utils.go index 96456eb..7127692 100644 --- a/utils.go +++ b/utils.go @@ -6,7 +6,10 @@ import ( "git.nix13.pw/scuroneko/laniakea/utils" ) +// Ptr returns a pointer to v. func Ptr[T any](v T) *T { return &v } + +// Val returns dereferenced pointer value or def when p is nil. func Val[T any](p *T, def T) T { if p != nil { return *p @@ -14,8 +17,8 @@ func Val[T any](p *T, def T) T { return def } -// EscapeMarkdown -// Deprecated. Use MarkdownV2 +// EscapeMarkdown escapes special characters for legacy Telegram Markdown. +// Deprecated: Use EscapeMarkdownV2. func EscapeMarkdown(s string) string { s = strings.ReplaceAll(s, "_", `\_`) s = strings.ReplaceAll(s, "*", `\*`) @@ -40,6 +43,8 @@ func EscapeMarkdownV2(s string) string { } return s } + +// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments. func EscapePunctuation(s string) string { symbols := []string{".", "!", "-"} for _, symbol := range symbols { @@ -48,6 +53,7 @@ func EscapePunctuation(s string) string { return s } +// Version constants mirror values from the internal utils/version package. const ( VersionString = utils.VersionString VersionMajor = utils.VersionMajor diff --git a/utils/limiter.go b/utils/limiter.go index f15811c..67e05cd 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -36,6 +36,17 @@ func NewRateLimiter() *RateLimiter { } } +// SetGlobalRate overrides global request-per-second limit and burst. +// If rps <= 0, current settings are kept. +func (rl *RateLimiter) SetGlobalRate(rps int) { + if rps <= 0 { + return + } + rl.globalMu.Lock() + defer rl.globalMu.Unlock() + rl.globalLimiter = rate.NewLimiter(rate.Limit(rps), rps) +} + // SetGlobalLock sets a global cooldown period (e.g., after receiving 429 from Telegram). // If retryAfter <= 0, no lock is applied. func (rl *RateLimiter) SetGlobalLock(retryAfter int) { @@ -64,7 +75,11 @@ func (rl *RateLimiter) GlobalWait(ctx context.Context) error { if err := rl.waitForGlobalUnlock(ctx); err != nil { return err } - return rl.globalLimiter.Wait(ctx) + limiter := rl.getGlobalLimiter() + if limiter == nil { + return nil + } + return limiter.Wait(ctx) } // Wait blocks until a request for the given chat can be made. @@ -77,8 +92,21 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { if err := rl.waitForGlobalUnlock(ctx); err != nil { return err } - limiter := rl.getChatLimiter(chatID) - return limiter.Wait(ctx) + limiter := rl.getGlobalLimiter() + if limiter != nil { + if err := limiter.Wait(ctx); err != nil { + return err + } + } + chatLimiter := rl.getChatLimiter(chatID) + return chatLimiter.Wait(ctx) +} + +// getGlobalLimiter returns the global limiter safely under read lock. +func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter { + rl.globalMu.RLock() + defer rl.globalMu.RUnlock() + return rl.globalLimiter } // GlobalAllow checks if a global request can be made without blocking. @@ -91,7 +119,11 @@ func (rl *RateLimiter) GlobalAllow() bool { if !until.IsZero() && time.Now().Before(until) { return false } - return rl.globalLimiter.Allow() + limiter := rl.getGlobalLimiter() + if limiter == nil { + return true + } + return limiter.Allow() } // Allow checks if a request for the given chat can be made without blocking. @@ -115,13 +147,14 @@ func (rl *RateLimiter) Allow(chatID int64) bool { } // Check global token bucket - if !rl.globalLimiter.Allow() { + limiter := rl.getGlobalLimiter() + if limiter != nil && !limiter.Allow() { return false } // Check chat token bucket - limiter := rl.getChatLimiter(chatID) - return limiter.Allow() + chatLimiter := rl.getChatLimiter(chatID) + return chatLimiter.Allow() } // Check applies rate limiting based on configuration. diff --git a/utils/multipart.go b/utils/multipart.go index df6131f..8b2a72c 100644 --- a/utils/multipart.go +++ b/utils/multipart.go @@ -10,6 +10,7 @@ import ( "strings" ) +// Encode writes struct fields into multipart form-data using json tags as field names. func Encode[T any](w *multipart.Writer, req T) error { v := reflect.ValueOf(req) if v.Kind() == reflect.Ptr { diff --git a/utils/utils.go b/utils/utils.go index 51ce3c6..889cc1f 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,6 +6,7 @@ import ( "git.nix13.pw/scuroneko/slog" ) +// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL. func GetLoggerLevel() slog.LogLevel { level := slog.FATAL if os.Getenv("DEBUG") == "true" { diff --git a/utils/version.go b/utils/version.go index cc789d9..306615e 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.21" + VersionString = "1.0.0-beta.22" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 21 + VersionBeta = 22 ) -- 2.54.0 From 4ebe76dd4acce6b1080425a9041945f96f71b9aa Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 17 Mar 2026 16:17:26 +0300 Subject: [PATCH 42/99] fix: correct Telegram update/keyboard models and harden env parsing --- bot.go | 17 +++- bot_opts.go | 30 +++++-- bot_opts_test.go | 47 ++++++++++ cmd_generator.go | 19 ++-- cmd_generator_test.go | 64 ++++++++++++++ go.mod | 4 +- go.sum | 8 +- handler.go | 3 + handler_test.go | 14 +++ msg_context.go | 3 + msg_context_test.go | 64 ++++++++++++++ plugins.go | 7 +- plugins_test.go | 24 ++++++ tgapi/api.go | 1 - tgapi/api_test.go | 56 ++++++++++++ tgapi/business_types.go | 8 ++ tgapi/messages_types.go | 76 ++++++++++++++-- tgapi/messages_types_test.go | 37 ++++++++ tgapi/methods.go | 27 +++++- tgapi/methods_test.go | 115 ++++++++++++++++++++++++ tgapi/types.go | 52 +++++++++-- tgapi/types_test.go | 69 +++++++++++++++ tgapi/uploader_api.go | 1 - tgapi/uploader_api_test.go | 138 +++++++++++++++++++++++++++++ utils/multipart.go | 163 +++++++++++++++-------------------- utils/multipart_test.go | 85 ++++++++++++++++++ 26 files changed, 992 insertions(+), 140 deletions(-) create mode 100644 bot_opts_test.go create mode 100644 cmd_generator_test.go create mode 100644 handler_test.go create mode 100644 msg_context_test.go create mode 100644 plugins_test.go create mode 100644 tgapi/api_test.go create mode 100644 tgapi/messages_types_test.go create mode 100644 tgapi/methods_test.go create mode 100644 tgapi/types_test.go create mode 100644 tgapi/uploader_api_test.go create mode 100644 utils/multipart_test.go diff --git a/bot.go b/bot.go index bf077ba..a08ee4d 100644 --- a/bot.go +++ b/bot.go @@ -184,21 +184,34 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { // // Returns the first error encountered, if any. func (bot *Bot[T]) Close() error { + var firstErr error + if err := bot.uploader.Close(); err != nil { bot.logger.Errorln(err) + if firstErr == nil { + firstErr = err + } } if err := bot.api.CloseApi(); err != nil { bot.logger.Errorln(err) + if firstErr == nil { + firstErr = err + } } if bot.RequestLogger != nil { if err := bot.RequestLogger.Close(); err != nil { bot.logger.Errorln(err) + if firstErr == nil { + firstErr = err + } } } if err := bot.logger.Close(); err != nil { - return err + if firstErr == nil { + firstErr = err + } } - return nil + return firstErr } // initLoggers configures the main and optional request loggers. diff --git a/bot_opts.go b/bot_opts.go index ef49264..ae04a19 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -85,10 +85,10 @@ func LoadOptsFromEnv() *BotOpts { } } - stringUpdateTypes := strings.Split(os.Getenv("UPDATE_TYPES"), ";") - updateTypes := make([]tgapi.UpdateType, len(stringUpdateTypes)) - for i, updateType := range stringUpdateTypes { - updateTypes[i] = tgapi.UpdateType(updateType) + stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES")) + updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes)) + for _, updateType := range stringUpdateTypes { + updateTypes = append(updateTypes, tgapi.UpdateType(updateType)) } return &BotOpts{ @@ -222,5 +222,25 @@ func LoadPrefixesFromEnv() []string { if !exists { return []string{"/"} } - return strings.Split(prefixesS, ";") + prefixes := splitEnvList(prefixesS) + if len(prefixes) == 0 { + return []string{"/"} + } + return prefixes +} + +func splitEnvList(value string) []string { + if value == "" { + return nil + } + parts := strings.Split(value, ";") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + out = append(out, part) + } + return out } diff --git a/bot_opts_test.go b/bot_opts_test.go new file mode 100644 index 0000000..9f3c1be --- /dev/null +++ b/bot_opts_test.go @@ -0,0 +1,47 @@ +package laniakea + +import ( + "reflect" + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" +) + +func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) { + t.Setenv("UPDATE_TYPES", "") + + opts := LoadOptsFromEnv() + if len(opts.UpdateTypes) != 0 { + t.Fatalf("expected no update types, got %v", opts.UpdateTypes) + } +} + +func TestLoadOptsFromEnvSplitsAndTrimsUpdateTypes(t *testing.T) { + t.Setenv("UPDATE_TYPES", "message; ; callback_query ") + + opts := LoadOptsFromEnv() + want := []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery} + if !reflect.DeepEqual(opts.UpdateTypes, want) { + t.Fatalf("unexpected update types: got %v want %v", opts.UpdateTypes, want) + } +} + +func TestLoadPrefixesFromEnvDefaultsOnEmptyValue(t *testing.T) { + t.Setenv("PREFIXES", "") + + got := LoadPrefixesFromEnv() + want := []string{"/"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected prefixes: got %v want %v", got, want) + } +} + +func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) { + t.Setenv("PREFIXES", "/; ; ! ") + + got := LoadPrefixesFromEnv() + want := []string{"/", "!"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected prefixes: got %v want %v", got, want) + } +} diff --git a/cmd_generator.go b/cmd_generator.go index 388dfb0..a5b0d40 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -100,17 +100,17 @@ func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { // log.Fatal(err) // } func (bot *Bot[T]) AutoGenerateCommands() error { + commands := gatherCommands(bot) + if len(commands) > 100 { + return ErrTooManyCommands + } + // Clear existing commands to avoid duplication or stale entries _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) if err != nil { return fmt.Errorf("failed to delete existing commands: %w", err) } - commands := gatherCommands(bot) - if len(commands) > 100 { - return ErrTooManyCommands - } - // Register commands for each scope scopes := []*tgapi.BotCommandScope{ {Type: tgapi.BotCommandScopePrivateType}, @@ -148,15 +148,16 @@ func (bot *Bot[T]) AutoGenerateCommands() error { // log.Fatal(err) // } func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error { - _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope}) - if err != nil { - return fmt.Errorf("failed to delete existing commands: %w", err) - } commands := gatherCommands(bot) if len(commands) > 100 { return ErrTooManyCommands } + _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope}) + if err != nil { + return fmt.Errorf("failed to delete existing commands: %w", err) + } + _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{ Commands: commands, Scope: scope, diff --git a/cmd_generator_test.go b/cmd_generator_test.go new file mode 100644 index 0000000..2931170 --- /dev/null +++ b/cmd_generator_test.go @@ -0,0 +1,64 @@ +package laniakea + +import ( + "errors" + "io" + "net/http" + "strconv" + "strings" + "sync/atomic" + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/slog" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { + var calls atomic.Int64 + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)), + }, nil + }), + } + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.CloseApi(); err != nil { + t.Fatalf("CloseApi returned error: %v", err) + } + }() + + plugin := NewPlugin[NoDB]("overflow") + exec := func(ctx *MsgContext, db *NoDB) {} + for i := 0; i < 101; i++ { + plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) + } + + bot := &Bot[NoDB]{ + api: api, + logger: slog.CreateLogger(), + plugins: []Plugin[NoDB]{*plugin}, + } + + err := bot.AutoGenerateCommands() + if !errors.Is(err, ErrTooManyCommands) { + t.Fatalf("expected ErrTooManyCommands, got %v", err) + } + if calls.Load() != 0 { + t.Fatalf("expected no HTTP calls before limit validation, got %d", calls.Load()) + } +} diff --git a/go.mod b/go.mod index db3117a..b6c1042 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module git.nix13.pw/scuroneko/laniakea go 1.26 require ( - git.nix13.pw/scuroneko/extypes v1.2.1 - git.nix13.pw/scuroneko/slog v1.0.2 + git.nix13.pw/scuroneko/extypes v1.2.2 + git.nix13.pw/scuroneko/slog v1.1.2 github.com/alitto/pond/v2 v2.7.0 golang.org/x/time v0.15.0 ) diff --git a/go.sum b/go.sum index a1e3c21..72b92c1 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5/RpQ= -git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= -git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= -git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= +git.nix13.pw/scuroneko/extypes v1.2.2 h1:N54c1ejrPs1yfIkvYuwqI7B1+8S9mDv2GqQA6sct4dk= +git.nix13.pw/scuroneko/extypes v1.2.2/go.mod h1:b4XYk1OW1dVSiE2MT/OMuX/K/UItf1swytX6eroVYnk= +git.nix13.pw/scuroneko/slog v1.1.2 h1:pl7tV5FN25Yso7sLYoOgBXi9+jLo5BDJHWmHlNPjpY0= +git.nix13.pw/scuroneko/slog v1.1.2/go.mod h1:UcfRIHDqpVQHahBGM93awLDK8//AsAvOqBwwbWqMkjM= github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= diff --git a/handler.go b/handler.go index 1afa576..b07228d 100644 --- a/handler.go +++ b/handler.go @@ -138,6 +138,9 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { for _, prefix := range bot.prefixes { + if prefix == "" { + continue + } if strings.HasPrefix(text, prefix) { return prefix, true } diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..789dae5 --- /dev/null +++ b/handler_test.go @@ -0,0 +1,14 @@ +package laniakea + +import "testing" + +func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { + bot := &Bot[NoDB]{prefixes: []string{"", "/"}} + + if prefix, ok := bot.checkPrefixes("hello"); ok { + t.Fatalf("unexpected prefix match for plain text: %q", prefix) + } + if prefix, ok := bot.checkPrefixes("/start"); !ok || prefix != "/" { + t.Fatalf("unexpected prefix result: prefix=%q ok=%v", prefix, ok) + } +} diff --git a/msg_context.go b/msg_context.go index b287d95..725b59e 100644 --- a/msg_context.go +++ b/msg_context.go @@ -270,6 +270,9 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par 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 { diff --git a/msg_context_test.go b/msg_context_test.go new file mode 100644 index 0000000..f6cb124 --- /dev/null +++ b/msg_context_test.go @@ -0,0 +1,64 @@ +package laniakea + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/slog" +) + +func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.CloseApi(); err != nil { + t.Fatalf("CloseApi returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + Msg: &tgapi.Message{ + Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, + DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, + }, + botLogger: slog.CreateLogger(), + } + + answer := ctx.AnswerPhoto("photo-id", "caption") + if answer == nil { + t.Fatal("expected answer message") + } + if answer.MessageID != 9 { + t.Fatalf("unexpected message id: %d", answer.MessageID) + } + if got := gotBody["direct_messages_topic_id"]; got != float64(77) { + t.Fatalf("unexpected direct_messages_topic_id: %v", got) + } +} diff --git a/plugins.go b/plugins.go index 091e449..134c900 100644 --- a/plugins.go +++ b/plugins.go @@ -23,11 +23,11 @@ const ( var ( // CommandRegexInt matches one or more digits. - CommandRegexInt = regexp.MustCompile(`\d+`) + CommandRegexInt = regexp.MustCompile(`^\d+$`) // CommandRegexString matches any non-empty string. - CommandRegexString = regexp.MustCompile(`.+`) + CommandRegexString = regexp.MustCompile(`^.+$`) // CommandRegexBool matches true or false. - CommandRegexBool = regexp.MustCompile(`true|false`) + CommandRegexBool = regexp.MustCompile(`^(true|false)$`) ) // ErrCmdArgCountMismatch is returned when the number of provided arguments @@ -64,6 +64,7 @@ func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { case CommandValueAnyType: regex = nil // Skip validation } + c.valueType = t c.regex = regex return c } diff --git a/plugins_test.go b/plugins_test.go new file mode 100644 index 0000000..a8fd856 --- /dev/null +++ b/plugins_test.go @@ -0,0 +1,24 @@ +package laniakea + +import ( + "errors" + "testing" +) + +func TestValidateArgsRequiresFullMatch(t *testing.T) { + intCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "int", *NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) + if err := intCmd.validateArgs([]string{"123"}); err != nil { + t.Fatalf("expected valid integer argument, got %v", err) + } + if err := intCmd.validateArgs([]string{"123abc"}); !errors.Is(err, ErrCmdArgRegexpMismatch) { + t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) + } + + boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "bool", *NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) + if err := boolCmd.validateArgs([]string{"false"}); err != nil { + t.Fatalf("expected valid bool argument, got %v", err) + } + if err := boolCmd.validateArgs([]string{"falsey"}); !errors.Is(err, ErrCmdArgRegexpMismatch) { + t.Fatalf("expected ErrCmdArgRegexpMismatch for partial bool match, got %v", err) + } +} diff --git a/tgapi/api.go b/tgapi/api.go index 98e2c50..30a16c6 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -202,7 +202,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) - req.Header.Set("Accept-Encoding", "gzip") for { // Apply rate limiting before making the request diff --git a/tgapi/api_test.go b/tgapi/api_test.go new file mode 100644 index 0000000..2f3db3d --- /dev/null +++ b/tgapi/api_test.go @@ -0,0 +1,56 @@ +package tgapi + +import ( + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { + var gotPath string + var gotAcceptEncoding string + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotPath = req.URL.Path + gotAcceptEncoding = req.Header.Get("Accept-Encoding") + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test"}}`)), + }, nil + }), + } + + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.CloseApi(); err != nil { + t.Fatalf("CloseApi returned error: %v", err) + } + }() + + user, err := api.GetMe() + if err != nil { + t.Fatalf("GetMe returned error: %v", err) + } + if user.FirstName != "Test" { + t.Fatalf("unexpected first name: %q", user.FirstName) + } + if gotPath != "/bottoken/getMe" { + t.Fatalf("unexpected request path: %s", gotPath) + } + if gotAcceptEncoding != "" { + t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding) + } +} diff --git a/tgapi/business_types.go b/tgapi/business_types.go index ca3c37a..56a5f9f 100644 --- a/tgapi/business_types.go +++ b/tgapi/business_types.go @@ -60,6 +60,14 @@ type BusinessConnection struct { IsEnabled bool `json:"is_enabled"` } +// BusinessMessagesDeleted is received when messages are deleted from a connected business account. +// See https://core.telegram.org/bots/api#businessmessagesdeleted +type BusinessMessagesDeleted struct { + BusinessConnectionID string `json:"business_connection_id"` + Chat Chat `json:"chat"` + MessageIDs []int `json:"message_ids"` +} + // InputStoryContentType indicates the type of input story content. type InputStoryContentType string diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 642c66b..6ef5fe9 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -144,12 +144,12 @@ type LinkPreviewOptions struct { type ReplyMarkup struct { InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard,omitempty"` - Keyboard [][]int `json:"keyboard,omitempty"` - IsPersistent bool `json:"is_persistent,omitempty"` - ResizeKeyboard bool `json:"resize_keyboard,omitempty"` - OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"` - InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"` - Selective bool `json:"selective,omitempty"` + Keyboard [][]KeyboardButton `json:"keyboard,omitempty"` + IsPersistent bool `json:"is_persistent,omitempty"` + ResizeKeyboard bool `json:"resize_keyboard,omitempty"` + OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"` + InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"` + Selective bool `json:"selective,omitempty"` RemoveKeyboard bool `json:"remove_keyboard,omitempty"` @@ -165,6 +165,60 @@ type InlineKeyboardMarkup struct { // KeyboardButtonStyle represents the style of a keyboard button. type KeyboardButtonStyle string +const ( + KeyboardButtonStyleDanger KeyboardButtonStyle = "danger" + KeyboardButtonStyleSuccess KeyboardButtonStyle = "success" + KeyboardButtonStylePrimary KeyboardButtonStyle = "primary" +) + +// KeyboardButton represents one button of the reply keyboard. +// See https://core.telegram.org/bots/api#keyboardbutton +type KeyboardButton struct { + Text string `json:"text"` + IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` + Style KeyboardButtonStyle `json:"style,omitempty"` + RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"` + RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"` + RequestContact bool `json:"request_contact,omitempty"` + RequestLocation bool `json:"request_location,omitempty"` + RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"` + WebApp *WebAppInfo `json:"web_app,omitempty"` +} + +// KeyboardButtonRequestUsers defines criteria used to request suitable users. +// See https://core.telegram.org/bots/api#keyboardbuttonrequestusers +type KeyboardButtonRequestUsers struct { + RequestID int `json:"request_id"` + UserIsBot *bool `json:"user_is_bot,omitempty"` + UserIsPremium *bool `json:"user_is_premium,omitempty"` + MaxQuantity int `json:"max_quantity,omitempty"` + RequestName bool `json:"request_name,omitempty"` + RequestUsername bool `json:"request_username,omitempty"` + RequestPhoto bool `json:"request_photo,omitempty"` +} + +// KeyboardButtonRequestChat defines criteria used to request a suitable chat. +// See https://core.telegram.org/bots/api#keyboardbuttonrequestchat +type KeyboardButtonRequestChat struct { + RequestID int `json:"request_id"` + ChatIsChannel bool `json:"chat_is_channel"` + ChatIsForum *bool `json:"chat_is_forum,omitempty"` + ChatHasUsername *bool `json:"chat_has_username,omitempty"` + ChatIsCreated *bool `json:"chat_is_created,omitempty"` + UserAdministratorRights *ChatAdministratorRights `json:"user_administrator_rights,omitempty"` + BotAdministratorRights *ChatAdministratorRights `json:"bot_administrator_rights,omitempty"` + BotIsMember bool `json:"bot_is_member,omitempty"` + RequestTitle bool `json:"request_title,omitempty"` + RequestUsername bool `json:"request_username,omitempty"` + RequestPhoto bool `json:"request_photo,omitempty"` +} + +// KeyboardButtonPollType represents the type of a poll that may be created from a keyboard button. +// See https://core.telegram.org/bots/api#keyboardbuttonpolltype +type KeyboardButtonPollType struct { + Type PollType `json:"type,omitempty"` +} + // InlineKeyboardButton represents one button of an inline keyboard. // See https://core.telegram.org/bots/api#inlinekeyboardbutton type InlineKeyboardButton struct { @@ -178,7 +232,12 @@ type InlineKeyboardButton struct { // ReplyKeyboardMarkup represents a custom keyboard with reply options. // See https://core.telegram.org/bots/api#replykeyboardmarkup type ReplyKeyboardMarkup struct { - Keyboard [][]int `json:"keyboard"` + Keyboard [][]KeyboardButton `json:"keyboard"` + IsPersistent bool `json:"is_persistent,omitempty"` + ResizeKeyboard bool `json:"resize_keyboard,omitempty"` + OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"` + InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"` + Selective bool `json:"selective,omitempty"` } // CallbackQuery represents an incoming callback query from a callback button in an inline keyboard. @@ -238,7 +297,8 @@ const ( ChatActionUploadDocument ChatActionType = "upload_document" ChatActionChooseSticker ChatActionType = "choose_sticker" ChatActionFindLocation ChatActionType = "find_location" - ChatActionUploadVideoNone ChatActionType = "upload_video_none" + ChatActionUploadVideoNote ChatActionType = "upload_video_note" + ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote ) // MessageReactionUpdated represents a change of a reaction on a message. diff --git a/tgapi/messages_types_test.go b/tgapi/messages_types_test.go new file mode 100644 index 0000000..25c304a --- /dev/null +++ b/tgapi/messages_types_test.go @@ -0,0 +1,37 @@ +package tgapi + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestReplyKeyboardMarkupMarshalsKeyboardButtons(t *testing.T) { + markup := ReplyKeyboardMarkup{ + Keyboard: [][]KeyboardButton{{ + { + Text: "Create poll", + RequestPoll: &KeyboardButtonPollType{Type: PollTypeQuiz}, + }, + }}, + } + + data, err := json.Marshal(markup) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + + got := string(data) + if !strings.Contains(got, `"keyboard":[[{"text":"Create poll","request_poll":{"type":"quiz"}}]]`) { + t.Fatalf("unexpected reply keyboard JSON: %s", got) + } +} + +func TestChatActionUploadVideoNoteValue(t *testing.T) { + if ChatActionUploadVideoNote != "upload_video_note" { + t.Fatalf("unexpected chat action value: %q", ChatActionUploadVideoNote) + } + if ChatActionUploadVideoNone != ChatActionUploadVideoNote { + t.Fatalf("expected deprecated alias to match upload_video_note, got %q", ChatActionUploadVideoNone) + } +} diff --git a/tgapi/methods.go b/tgapi/methods.go index 37bc843..fb3edcb 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -1,9 +1,12 @@ package tgapi import ( + "context" "fmt" "io" "net/http" + + "git.nix13.pw/scuroneko/laniakea/utils" ) // UpdateParams holds parameters for the getUpdates method. @@ -12,7 +15,7 @@ type UpdateParams struct { Offset *int `json:"offset,omitempty"` Limit *int `json:"limit,omitempty"` Timeout *int `json:"timeout,omitempty"` - AllowedUpdates []UpdateType `json:"allowed_updates"` + AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` } // GetMe returns basic information about the bot. @@ -103,13 +106,31 @@ func (api *API) GetFile(params GetFileP) (File, error) { // The link is usually obtained from File.FilePath. // See https://core.telegram.org/bots/api#file func (api *API) GetFileByLink(link string) ([]byte, error) { - u := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", api.token, link) - res, err := http.Get(u) + methodPrefix := "" + if api.useTestServer { + methodPrefix = "/test" + } + u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) + + res, err := api.client.Do(req) if err != nil { return nil, err } defer func() { _ = res.Body.Close() }() + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10)) + if readErr != nil { + return nil, fmt.Errorf("unexpected status %d", res.StatusCode) + } + return nil, fmt.Errorf("unexpected status %d: %s", res.StatusCode, string(body)) + } return io.ReadAll(res.Body) } diff --git a/tgapi/methods_test.go b/tgapi/methods_test.go new file mode 100644 index 0000000..649ff50 --- /dev/null +++ b/tgapi/methods_test.go @@ -0,0 +1,115 @@ +package tgapi + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) { + var gotPath string + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotPath = req.URL.Path + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("payload")), + }, nil + }), + } + + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.CloseApi(); err != nil { + t.Fatalf("CloseApi returned error: %v", err) + } + }() + + data, err := api.GetFileByLink("files/report.txt") + if err != nil { + t.Fatalf("GetFileByLink returned error: %v", err) + } + if string(data) != "payload" { + t.Fatalf("unexpected payload: %q", string(data)) + } + if gotPath != "/file/bottoken/files/report.txt" { + t.Fatalf("unexpected request path: %s", gotPath) + } +} + +func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) { + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("missing\n")), + }, nil + }), + } + + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.CloseApi(); err != nil { + t.Fatalf("CloseApi returned error: %v", err) + } + }() + + _, err := api.GetFileByLink("files/report.txt") + if err == nil { + t.Fatal("expected error for non-2xx response") + } +} + +func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) { + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":[]}`)), + }, nil + }), + } + + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.CloseApi(); err != nil { + t.Fatalf("CloseApi returned error: %v", err) + } + }() + + updates, err := api.GetUpdates(UpdateParams{}) + if err != nil { + t.Fatalf("GetUpdates returned error: %v", err) + } + if len(updates) != 0 { + t.Fatalf("expected no updates, got %d", len(updates)) + } + if _, exists := gotBody["allowed_updates"]; exists { + t.Fatalf("expected allowed_updates to be omitted, got %v", gotBody["allowed_updates"]) + } +} diff --git a/tgapi/types.go b/tgapi/types.go index ed3c3d7..d96632b 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -1,5 +1,7 @@ package tgapi +import "encoding/json" + // UpdateType represents the type of incoming update. type UpdateType string @@ -23,8 +25,10 @@ const ( UpdateTypeBusinessMessage UpdateType = "business_message" // UpdateTypeEditedBusinessMessage is an edited business message update. UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message" - // UpdateTypeDeletedBusinessMessage is a deleted business message update. - UpdateTypeDeletedBusinessMessage UpdateType = "deleted_business_message" + // UpdateTypeDeletedBusinessMessages is a deleted business messages update. + UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages" + // UpdateTypeDeletedBusinessMessage is kept as a backward-compatible alias. + UpdateTypeDeletedBusinessMessage UpdateType = UpdateTypeDeletedBusinessMessages // UpdateTypeInlineQuery is an inline query update. UpdateTypeInlineQuery UpdateType = "inline_query" @@ -63,17 +67,18 @@ type Update struct { ChannelPost *Message `json:"channel_post,omitempty"` EditedChannelPost *Message `json:"edited_channel_post,omitempty"` - BusinessConnection *BusinessConnection `json:"business_connection,omitempty"` - BusinessMessage *Message `json:"business_message,omitempty"` - EditedBusinessMessage *Message `json:"edited_business_message,omitempty"` - DeletedBusinessMessage *Message `json:"deleted_business_messages,omitempty"` - MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` - MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` + BusinessConnection *BusinessConnection `json:"business_connection,omitempty"` + BusinessMessage *Message `json:"business_message,omitempty"` + EditedBusinessMessage *Message `json:"edited_business_message,omitempty"` + DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"` + DeletedBusinessMessage *BusinessMessagesDeleted `json:"-"` + MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` + MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` InlineQuery *InlineQuery `json:"inline_query,omitempty"` ChosenInlineResult *ChosenInlineResult `json:"chosen_inline_result,omitempty"` CallbackQuery *CallbackQuery `json:"callback_query,omitempty"` - ShippingQuery ShippingQuery `json:"shipping_query,omitempty"` + ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"` PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"` PurchasedPaidMedia *PaidMediaPurchased `json:"purchased_paid_media,omitempty"` @@ -86,6 +91,35 @@ type Update struct { RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` } +func (u *Update) syncDeletedBusinessMessages() { + if u.DeletedBusinessMessages != nil { + u.DeletedBusinessMessage = u.DeletedBusinessMessages + return + } + if u.DeletedBusinessMessage != nil { + u.DeletedBusinessMessages = u.DeletedBusinessMessage + } +} + +// UnmarshalJSON keeps the deprecated DeletedBusinessMessage alias in sync. +func (u *Update) UnmarshalJSON(data []byte) error { + type alias Update + var aux alias + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *u = Update(aux) + u.syncDeletedBusinessMessages() + return nil +} + +// MarshalJSON emits the canonical deleted_business_messages field. +func (u Update) MarshalJSON() ([]byte, error) { + u.syncDeletedBusinessMessages() + type alias Update + return json.Marshal(alias(u)) +} + // InlineQuery represents an incoming inline query. // See https://core.telegram.org/bots/api#inlinequery type InlineQuery struct { diff --git a/tgapi/types_test.go b/tgapi/types_test.go new file mode 100644 index 0000000..5b18efc --- /dev/null +++ b/tgapi/types_test.go @@ -0,0 +1,69 @@ +package tgapi + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestUpdateDeletedBusinessMessagesUnmarshalSetsAlias(t *testing.T) { + var update Update + err := json.Unmarshal([]byte(`{ + "update_id": 1, + "deleted_business_messages": { + "business_connection_id": "conn", + "chat": {"id": 42, "type": "private"}, + "message_ids": [3, 5] + } + }`), &update) + if err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + + if update.DeletedBusinessMessages == nil { + t.Fatal("expected DeletedBusinessMessages to be populated") + } + if update.DeletedBusinessMessage == nil { + t.Fatal("expected deprecated DeletedBusinessMessage alias to be populated") + } + if update.DeletedBusinessMessages != update.DeletedBusinessMessage { + t.Fatal("expected deleted business message fields to share the same payload") + } + if got := update.DeletedBusinessMessages.MessageIDs; len(got) != 2 || got[0] != 3 || got[1] != 5 { + t.Fatalf("unexpected message ids: %v", got) + } +} + +func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) { + update := Update{ + UpdateID: 1, + DeletedBusinessMessage: &BusinessMessagesDeleted{ + BusinessConnectionID: "conn", + Chat: Chat{ID: 42, Type: string(ChatTypePrivate)}, + MessageIDs: []int{7}, + }, + } + + data, err := json.Marshal(update) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + + got := string(data) + if !strings.Contains(got, `"deleted_business_messages"`) { + t.Fatalf("expected canonical deleted_business_messages field, got %s", got) + } + if strings.Contains(got, `"deleted_business_message"`) { + t.Fatalf("unexpected singular deleted_business_message field, got %s", got) + } +} + +func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) { + var update Update + if err := json.Unmarshal([]byte(`{"update_id":1}`), &update); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if update.ShippingQuery != nil { + t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery) + } +} diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 24cf8c5..a950030 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -126,7 +126,6 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString)) - req.Header.Set("Accept-Encoding", "gzip") req.ContentLength = int64(buf.Len()) up.logger.Debugln("UPLOADER REQ", r.method) diff --git a/tgapi/uploader_api_test.go b/tgapi/uploader_api_test.go new file mode 100644 index 0000000..7cf55dd --- /dev/null +++ b/tgapi/uploader_api_test.go @@ -0,0 +1,138 @@ +package tgapi + +import ( + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + "testing" +) + +func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *testing.T) { + var ( + gotPath string + gotAcceptEncoding string + gotFields map[string]string + gotFileName string + gotFileData []byte + roundTripErr error + ) + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotPath = req.URL.Path + gotAcceptEncoding = req.Header.Get("Accept-Encoding") + + gotFields, gotFileName, gotFileData, roundTripErr = readMultipartRequest(req) + if roundTripErr != nil { + roundTripErr = fmt.Errorf("readMultipartRequest: %w", roundTripErr) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":5,"date":1}}`)), + }, nil + }), + } + + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.CloseApi(); err != nil { + t.Fatalf("CloseApi returned error: %v", err) + } + }() + + uploader := NewUploader(api) + defer func() { + if err := uploader.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + msg, err := uploader.SendPhoto( + UploadPhotoP{ + ChatID: 42, + CaptionEntities: []MessageEntity{{ + Type: MessageEntityBold, + Offset: 0, + Length: 4, + }}, + ReplyMarkup: &ReplyMarkup{ + InlineKeyboard: [][]InlineKeyboardButton{{ + {Text: "A", CallbackData: "b"}, + }}, + }, + }, + NewUploaderFile("photo.jpg", []byte("img")), + ) + if err != nil { + t.Fatalf("SendPhoto returned error: %v", err) + } + if msg.MessageID != 5 { + t.Fatalf("unexpected message id: %d", msg.MessageID) + } + if roundTripErr != nil { + t.Fatalf("multipart parse failed: %v", roundTripErr) + } + if gotPath != "/bottoken/sendPhoto" { + t.Fatalf("unexpected request path: %s", gotPath) + } + if gotAcceptEncoding != "" { + t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding) + } + if got := gotFields["chat_id"]; got != "42" { + t.Fatalf("chat_id mismatch: %q", got) + } + if got := gotFields["caption_entities"]; got != `[{"type":"bold","offset":0,"length":4}]` { + t.Fatalf("caption_entities mismatch: %q", got) + } + if got := gotFields["reply_markup"]; got != `{"inline_keyboard":[[{"text":"A","callback_data":"b"}]]}` { + t.Fatalf("reply_markup mismatch: %q", got) + } + if gotFileName != "photo.jpg" { + t.Fatalf("unexpected file name: %q", gotFileName) + } + if string(gotFileData) != "img" { + t.Fatalf("unexpected file content: %q", string(gotFileData)) + } +} + +func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) { + _, params, err := mime.ParseMediaType(req.Header.Get("Content-Type")) + if err != nil { + return nil, "", nil, err + } + reader := multipart.NewReader(req.Body, params["boundary"]) + + fields := make(map[string]string) + var fileName string + var fileData []byte + for { + part, err := reader.NextPart() + if err == io.EOF { + return fields, fileName, fileData, nil + } + if err != nil { + return nil, "", nil, err + } + + data, err := io.ReadAll(part) + if err != nil { + return nil, "", nil, err + } + + if part.FileName() != "" { + fileName = part.FileName() + fileData = data + continue + } + fields[part.FormName()] = string(data) + } +} diff --git a/utils/multipart.go b/utils/multipart.go index 8b2a72c..4ee0bfe 100644 --- a/utils/multipart.go +++ b/utils/multipart.go @@ -1,6 +1,7 @@ package utils import ( + "encoding/json" "fmt" "io" "mime/multipart" @@ -12,12 +13,8 @@ import ( // Encode writes struct fields into multipart form-data using json tags as field names. func Encode[T any](w *multipart.Writer, req T) error { - v := reflect.ValueOf(req) - if v.Kind() == reflect.Ptr { - v = v.Elem() - } - - if v.Kind() != reflect.Struct { + v := unwrapMultipartValue(reflect.ValueOf(req)) + if !v.IsValid() || v.Kind() != reflect.Struct { return fmt.Errorf("req must be a struct") } @@ -33,6 +30,9 @@ func Encode[T any](w *multipart.Writer, req T) error { parts := strings.Split(jsonTag, ",") fieldName := parts[0] + if fieldName == "" { + fieldName = fieldType.Name + } if fieldName == "-" { continue } @@ -43,96 +43,73 @@ func Encode[T any](w *multipart.Writer, req T) error { continue } - var ( - fw io.Writer - err error - ) - - switch field.Kind() { - case reflect.String: - fw, err = w.CreateFormField(fieldName) - if err == nil { - _, err = fw.Write([]byte(field.String())) - } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - fw, err = w.CreateFormField(fieldName) - if err == nil { - _, err = fw.Write([]byte(strconv.FormatInt(field.Int(), 10))) - } - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - fw, err = w.CreateFormField(fieldName) - if err == nil { - _, err = fw.Write([]byte(strconv.FormatUint(field.Uint(), 10))) - } - case reflect.Float32: - fw, err = w.CreateFormField(fieldName) - if err == nil { - _, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 32))) - } - case reflect.Float64: - fw, err = w.CreateFormField(fieldName) - if err == nil { - _, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 64))) - } - - case reflect.Bool: - fw, err = w.CreateFormField(fieldName) - if err == nil { - _, err = fw.Write([]byte(strconv.FormatBool(field.Bool()))) - } - case reflect.Slice: - if field.Type().Elem().Kind() == reflect.Uint8 && !field.IsNil() { - // Handle []byte as file upload (e.g., thumbnail) - filename := fieldType.Tag.Get("filename") - if filename == "" { - filename = fieldName - } - fw, err = w.CreateFormFile(fieldName, filename) - if err == nil { - _, err = fw.Write(field.Bytes()) - } - } else if !field.IsNil() { - // Handle []string, []int, etc. — send as multiple fields with same name - for j := 0; j < field.Len(); j++ { - elem := field.Index(j) - fw, err = w.CreateFormField(fieldName) - if err != nil { - break - } - switch elem.Kind() { - case reflect.String: - _, err = fw.Write([]byte(elem.String())) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - _, err = fw.Write([]byte(strconv.FormatInt(elem.Int(), 10))) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - _, err = fw.Write([]byte(strconv.FormatUint(elem.Uint(), 10))) - case reflect.Bool: - _, err = fw.Write([]byte(strconv.FormatBool(elem.Bool()))) - case reflect.Float32: - _, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 32))) - case reflect.Float64: - _, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 64))) - default: - continue - } - if err != nil { - break - } - } - } - - case reflect.Struct: - // Don't serialize structs as JSON — flatten them! - // Telegram doesn't support nested JSON in form-data. - // If you need nested data, use separate fields (e.g., ParseMode, CaptionEntities) - // This is a design choice — you should avoid nested structs in params. - return fmt.Errorf("nested structs are not supported in params — use flat fields") - } - - if err != nil { + if err := writeMultipartField(w, fieldName, fieldType.Tag.Get("filename"), field); err != nil { return err } } return nil } + +func unwrapMultipartValue(v reflect.Value) reflect.Value { + for v.IsValid() && (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) { + if v.IsNil() { + return reflect.Value{} + } + v = v.Elem() + } + return v +} + +func writeMultipartField(w *multipart.Writer, fieldName, filename string, field reflect.Value) error { + value := unwrapMultipartValue(field) + if !value.IsValid() { + return nil + } + + switch value.Kind() { + case reflect.String: + return writeMultipartValue(w, fieldName, []byte(value.String())) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return writeMultipartValue(w, fieldName, []byte(strconv.FormatInt(value.Int(), 10))) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return writeMultipartValue(w, fieldName, []byte(strconv.FormatUint(value.Uint(), 10))) + case reflect.Float32: + return writeMultipartValue(w, fieldName, []byte(strconv.FormatFloat(value.Float(), 'f', -1, 32))) + case reflect.Float64: + return writeMultipartValue(w, fieldName, []byte(strconv.FormatFloat(value.Float(), 'f', -1, 64))) + case reflect.Bool: + return writeMultipartValue(w, fieldName, []byte(strconv.FormatBool(value.Bool()))) + case reflect.Slice: + if value.Type().Elem().Kind() == reflect.Uint8 { + if filename == "" { + filename = fieldName + } + fw, err := w.CreateFormFile(fieldName, filename) + if err != nil { + return err + } + _, err = fw.Write(value.Bytes()) + return err + } + } + + // Telegram expects nested objects and arrays in multipart requests as JSON strings. + data, err := json.Marshal(value.Interface()) + if err != nil { + return err + } + if string(data) == "null" { + return nil + } + return writeMultipartValue(w, fieldName, data) +} + +func writeMultipartValue(w *multipart.Writer, fieldName string, value []byte) error { + fw, err := w.CreateFormField(fieldName) + if err != nil { + return err + } + _, err = io.Copy(fw, strings.NewReader(string(value))) + return err +} diff --git a/utils/multipart_test.go b/utils/multipart_test.go new file mode 100644 index 0000000..479ed05 --- /dev/null +++ b/utils/multipart_test.go @@ -0,0 +1,85 @@ +package utils_test + +import ( + "bytes" + "io" + "mime/multipart" + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/laniakea/utils" +) + +type multipartEncodeParams struct { + ChatID int64 `json:"chat_id"` + MessageThreadID *int `json:"message_thread_id,omitempty"` + ReplyMarkup *tgapi.ReplyMarkup `json:"reply_markup,omitempty"` + CaptionEntities []tgapi.MessageEntity `json:"caption_entities,omitempty"` + ReplyParameters *tgapi.ReplyParameters `json:"reply_parameters,omitempty"` +} + +func TestEncodeMultipartJSONFields(t *testing.T) { + threadID := 7 + params := multipartEncodeParams{ + ChatID: 42, + MessageThreadID: &threadID, + ReplyMarkup: &tgapi.ReplyMarkup{ + InlineKeyboard: [][]tgapi.InlineKeyboardButton{{ + {Text: "A", CallbackData: "b"}, + }}, + }, + CaptionEntities: []tgapi.MessageEntity{{ + Type: tgapi.MessageEntityBold, + Offset: 0, + Length: 4, + }}, + } + + body := bytes.NewBuffer(nil) + writer := multipart.NewWriter(body) + if err := utils.Encode(writer, params); err != nil { + t.Fatalf("Encode returned error: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("writer.Close returned error: %v", err) + } + + got := readMultipartFields(t, body.Bytes(), writer.Boundary()) + if got["chat_id"] != "42" { + t.Fatalf("chat_id mismatch: %q", got["chat_id"]) + } + if got["message_thread_id"] != "7" { + t.Fatalf("message_thread_id mismatch: %q", got["message_thread_id"]) + } + if got["reply_markup"] != `{"inline_keyboard":[[{"text":"A","callback_data":"b"}]]}` { + t.Fatalf("reply_markup mismatch: %q", got["reply_markup"]) + } + if got["caption_entities"] != `[{"type":"bold","offset":0,"length":4}]` { + t.Fatalf("caption_entities mismatch: %q", got["caption_entities"]) + } + if _, ok := got["reply_parameters"]; ok { + t.Fatalf("reply_parameters should be omitted when nil") + } +} + +func readMultipartFields(t *testing.T, body []byte, boundary string) map[string]string { + t.Helper() + + reader := multipart.NewReader(bytes.NewReader(body), boundary) + fields := make(map[string]string) + for { + part, err := reader.NextPart() + if err == io.EOF { + return fields + } + if err != nil { + t.Fatalf("NextPart returned error: %v", err) + } + + data, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll returned error: %v", err) + } + fields[part.FormName()] = string(data) + } +} -- 2.54.0 From 2fc171d9a3c5fb8c9145c548168c9e4d4e9e921e Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 19 Mar 2026 14:07:03 +0300 Subject: [PATCH 43/99] fix shutdown long polling cancellation and database logger close --- bot.go | 48 +++++++++++++++++++++--------------------------- methods.go | 14 ++++++++------ tgapi/methods.go | 5 +++++ utils/version.go | 4 ++-- 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/bot.go b/bot.go index a08ee4d..e70f02c 100644 --- a/bot.go +++ b/bot.go @@ -2,6 +2,7 @@ package laniakea import ( "context" + "errors" "fmt" "sort" "strings" @@ -174,7 +175,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { return bot } -// Close gracefully shuts down the bot. +// Close gracefully shuts down bot-owned resources. // // Closes: // - Uploader (waits for pending uploads) @@ -182,36 +183,31 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { // - RequestLogger (if enabled) // - Main logger // -// Returns the first error encountered, if any. +// RunWithContext does not call Close automatically. The caller is responsible +// for invoking Close after RunWithContext returns to release these resources. +// +// Returns a joined error containing all shutdown failures, if any. func (bot *Bot[T]) Close() error { - var firstErr error + var e []error if err := bot.uploader.Close(); err != nil { bot.logger.Errorln(err) - if firstErr == nil { - firstErr = err - } + e = append(e, err) } if err := bot.api.CloseApi(); err != nil { bot.logger.Errorln(err) - if firstErr == nil { - firstErr = err - } + e = append(e, err) } if bot.RequestLogger != nil { if err := bot.RequestLogger.Close(); err != nil { bot.logger.Errorln(err) - if firstErr == nil { - firstErr = err - } + e = append(e, err) } } if err := bot.logger.Close(); err != nil { - if firstErr == nil { - firstErr = err - } + e = append(e, err) } - return firstErr + return errors.Join(e...) } // initLoggers configures the main and optional request loggers. @@ -466,7 +462,10 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { // The context controls graceful shutdown. When canceled, the bot: // - Stops polling for new updates // - Finishes processing currently queued updates -// - Closes all resources (API, uploader, loggers) +// - Waits for registered runners to exit +// +// RunWithContext does not close API, uploader, or logger resources on return. +// The caller must invoke Close after RunWithContext finishes. // // Example: // @@ -474,13 +473,8 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { // go bot.RunWithContext(ctx) // // ... later ... // cancel() // triggers graceful shutdown +// _ = bot.Close() func (bot *Bot[T]) RunWithContext(ctx context.Context) { - defer func() { - if err := bot.Close(); err != nil { - bot.logger.Errorln(err) - } - }() - if len(bot.prefixes) == 0 { bot.logger.Fatalln("no prefixes defined") return @@ -508,15 +502,15 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { case <-ctx.Done(): return default: - updates, err := bot.Updates() + updates, err := bot.Updates(ctx) if err != nil { bot.logger.Errorln("failed to fetch updates:", err) - time.Sleep(2 * time.Second) // exponential backoff + time.Sleep(time.Second) // exponential backoff continue } - for _, u := range updates { - u := u // copy loop variable to avoid race condition + for _, update := range updates { + u := update // copy loop variable to avoid race condition select { case bot.updateQueue <- &u: case <-ctx.Done(): diff --git a/methods.go b/methods.go index f5eb41d..e10efba 100644 --- a/methods.go +++ b/methods.go @@ -1,6 +1,7 @@ package laniakea import ( + "context" "encoding/json" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -12,7 +13,7 @@ import ( // through AllowedUpdates and includes optional request logging. // // Parameters: -// - None (uses bot's internal state for offset and allowed updates) +// - ctx: request context used to cancel the in-flight long polling request // // Returns: // - []tgapi.Update: slice of received updates (empty if none available) @@ -26,19 +27,20 @@ import ( // 5. Automatically updates the offset to the last received update ID + 1 // 6. Returns all received updates (empty slice if none) // -// Note: This is a blocking call that waits up to 30 seconds for new updates. -// For non-blocking behavior, consider using webhooks instead. +// Note: This is a blocking call that waits up to 30 seconds for new updates, +// unless ctx is canceled earlier. For non-blocking behavior, consider using +// webhooks instead. // // Example: // -// updates, err := bot.Updates() +// updates, err := bot.Updates(ctx) // if err != nil { // log.Fatal(err) // } // for _, update := range updates { // // process update // } -func (bot *Bot[T]) Updates() ([]tgapi.Update, error) { +func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { offset := bot.GetUpdateOffset() params := tgapi.UpdateParams{ Offset: Ptr(offset), @@ -46,7 +48,7 @@ func (bot *Bot[T]) Updates() ([]tgapi.Update, error) { AllowedUpdates: bot.GetUpdateTypes(), } - updates, err := bot.api.GetUpdates(params) + updates, err := bot.api.GetUpdatesWithContext(ctx, params) if err != nil { return nil, err } diff --git a/tgapi/methods.go b/tgapi/methods.go index fb3edcb..7375e9b 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -48,6 +48,11 @@ func (api *API) GetUpdates(params UpdateParams) ([]Update, error) { return req.Do(api) } +func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) ([]Update, error) { + req := NewRequest[[]Update]("getUpdates", params) + return req.DoWithContext(ctx, api) +} + // SetWebhookP holds parameters for the setWebhook method. // See https://core.telegram.org/bots/api#setwebhook type SetWebhookP struct { diff --git a/utils/version.go b/utils/version.go index 306615e..6b5a3c3 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.22" + VersionString = "1.0.0-rc.2" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 22 + VersionBeta = 2 ) -- 2.54.0 From c59dd1fe8e5aa579ba5c2207d0d574353a95079d Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 19 Mar 2026 14:13:51 +0300 Subject: [PATCH 44/99] Handle canceled update polling without shutdown delay --- bot.go | 5 +++-- utils/version.go | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bot.go b/bot.go index e70f02c..cc42c25 100644 --- a/bot.go +++ b/bot.go @@ -7,7 +7,6 @@ import ( "sort" "strings" "sync" - "time" "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -504,8 +503,10 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { default: updates, err := bot.Updates(ctx) if err != nil { + if errors.Is(err, context.Canceled) { + return + } bot.logger.Errorln("failed to fetch updates:", err) - time.Sleep(time.Second) // exponential backoff continue } diff --git a/utils/version.go b/utils/version.go index 6b5a3c3..fec1438 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-rc.2" + VersionString = "1.0.0-rc.3" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 2 + VersionBeta = 3 ) -- 2.54.0 From 0b1a58a5143aa09e8cb592aff2f3dba1b4cdbfc2 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 19 Mar 2026 14:23:23 +0300 Subject: [PATCH 45/99] readme change --- README.md | 6 ++++++ README_RU.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index fe175e7..580c99b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s go get git.nix13.pw/scuroneko/laniakea ``` +or + +```bash +go get github.com/scuroneko/laniakea +``` + ## 🚀 Quick Start (with step-by-step explanation) Here is a minimal echo/ping bot example with detailed comments. diff --git a/README_RU.md b/README_RU.md index 44b8acc..02b10cb 100644 --- a/README_RU.md +++ b/README_RU.md @@ -29,6 +29,12 @@ go get git.nix13.pw/scuroneko/laniakea ``` +или + +```bash +go get github.com/scuroneko/laniakea +``` + ## 🚀 Быстрый старт (с пошаговыми комментариями) Вот минимальный пример бота "echo/ping" с подробными комментариями. -- 2.54.0 From 6b9075c72260effba22f49d0b58a55d7f422582c Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 19 Mar 2026 15:10:49 +0300 Subject: [PATCH 46/99] feat(tgapi): add context-aware API/uploader methods and align params/docs with Telegram Bot API --- bot.go | 4 + tgapi/attachments_methods.go | 98 +++++++++-- tgapi/bot_methods.go | 146 +++++++++++++++++ tgapi/business_methods.go | 194 ++++++++++++++++++++++ tgapi/chat_methods.go | 258 +++++++++++++++++++++++++++++ tgapi/forum_methods.go | 106 ++++++++++++ tgapi/games_methods.go | 33 ++++ tgapi/inline_methods.go | 26 +++ tgapi/messages_methods.go | 303 ++++++++++++++++++++++++++++++++--- tgapi/methods.go | 76 ++++++++- tgapi/passport_methods.go | 10 ++ tgapi/payments_methods.go | 41 ++++- tgapi/stars_methods.go | 34 ++++ tgapi/stickers_methods.go | 138 ++++++++++++++++ tgapi/uploader_api.go | 2 + tgapi/uploader_methods.go | 110 +++++++++++++ tgapi/users_methods.go | 34 ++++ utils/version.go | 4 +- 18 files changed, 1582 insertions(+), 35 deletions(-) diff --git a/bot.go b/bot.go index cc42c25..803a0ed 100644 --- a/bot.go +++ b/bot.go @@ -193,6 +193,10 @@ func (bot *Bot[T]) Close() error { bot.logger.Errorln(err) e = append(e, err) } + if _, err := bot.api.Close(); err != nil { + bot.logger.Errorln(err) + e = append(e, err) + } if err := bot.api.CloseApi(); err != nil { bot.logger.Errorln(err) e = append(e, err) diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 7fff755..078d1d0 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // SendPhotoP holds parameters for the sendPhoto method. // See https://core.telegram.org/bots/api#sendphoto type SendPhotoP struct { @@ -32,6 +34,14 @@ func (api *API) SendPhoto(params SendPhotoP) (Message, error) { return req.Do(api) } +// SendPhotoWithContext is the context-aware variant of SendPhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendphoto +func (api *API) SendPhotoWithContext(ctx context.Context, params SendPhotoP) (Message, error) { + req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendAudioP holds parameters for the sendAudio method. // See https://core.telegram.org/bots/api#sendaudio type SendAudioP struct { @@ -47,6 +57,7 @@ type SendAudioP struct { Duration int `json:"duration,omitempty"` Performer string `json:"performer,omitempty"` Title string `json:"title,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` @@ -65,6 +76,14 @@ func (api *API) SendAudio(params SendAudioP) (Message, error) { return req.Do(api) } +// SendAudioWithContext is the context-aware variant of SendAudio. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendaudio +func (api *API) SendAudioWithContext(ctx context.Context, params SendAudioP) (Message, error) { + req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendDocumentP holds parameters for the sendDocument method. // See https://core.telegram.org/bots/api#senddocument type SendDocumentP struct { @@ -73,10 +92,12 @@ type SendDocumentP struct { MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` - Document string `json:"document"` - Caption string `json:"caption,omitempty"` - ParseMode ParseMode `json:"parse_mode,omitempty"` - CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` + Document string `json:"document"` + Thumbnail string `json:"thumbnail,omitempty"` + Caption string `json:"caption,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` + DisableContentTypeDetection bool `json:"disable_content_type_detection,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` @@ -95,6 +116,14 @@ func (api *API) SendDocument(params SendDocumentP) (Message, error) { return req.Do(api) } +// SendDocumentWithContext is the context-aware variant of SendDocument. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#senddocument +func (api *API) SendDocumentWithContext(ctx context.Context, params SendDocumentP) (Message, error) { + req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendVideoP holds parameters for the sendVideo method. // See https://core.telegram.org/bots/api#sendvideo type SendVideoP struct { @@ -103,11 +132,12 @@ type SendVideoP struct { MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` - Video string `json:"video"` - Duration int `json:"duration,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - Cover string `json:"cover,omitempty"` + Video string `json:"video"` + Thumbnail string `json:"thumbnail,omitempty"` + Duration int `json:"duration,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Cover string `json:"cover,omitempty"` StartTimestamp int `json:"start_timestamp,omitempty"` Caption string `json:"caption,omitempty"` @@ -134,6 +164,14 @@ func (api *API) SendVideo(params SendVideoP) (Message, error) { return req.Do(api) } +// SendVideoWithContext is the context-aware variant of SendVideo. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendvideo +func (api *API) SendVideoWithContext(ctx context.Context, params SendVideoP) (Message, error) { + req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendAnimationP holds parameters for the sendAnimation method. // See https://core.telegram.org/bots/api#sendanimation type SendAnimationP struct { @@ -143,6 +181,7 @@ type SendAnimationP struct { DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Animation string `json:"animation"` + Thumbnail string `json:"thumbnail,omitempty"` Duration int `json:"duration,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` @@ -169,6 +208,14 @@ func (api *API) SendAnimation(params SendAnimationP) (Message, error) { return req.Do(api) } +// SendAnimationWithContext is the context-aware variant of SendAnimation. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendanimation +func (api *API) SendAnimationWithContext(ctx context.Context, params SendAnimationP) (Message, error) { + req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendVoiceP holds parameters for the sendVoice method. // See https://core.telegram.org/bots/api#sendvoice type SendVoiceP struct { @@ -199,6 +246,14 @@ func (api *API) SendVoice(params *SendVoiceP) (Message, error) { return req.Do(api) } +// SendVoiceWithContext is the context-aware variant of SendVoice. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendvoice +func (api *API) SendVoiceWithContext(ctx context.Context, params *SendVoiceP) (Message, error) { + req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendVideoNoteP holds parameters for the sendVideoNote method. // See https://core.telegram.org/bots/api#sendvideonote type SendVideoNoteP struct { @@ -208,6 +263,7 @@ type SendVideoNoteP struct { DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` VideoNote string `json:"video_note"` + Thumbnail string `json:"thumbnail,omitempty"` Duration int `json:"duration,omitempty"` Length int `json:"length,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"` @@ -227,6 +283,14 @@ func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) { return req.Do(api) } +// SendVideoNoteWithContext is the context-aware variant of SendVideoNote. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendvideonote +func (api *API) SendVideoNoteWithContext(ctx context.Context, params SendVideoNoteP) (Message, error) { + req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendPaidMediaP holds parameters for the sendPaidMedia method. // See https://core.telegram.org/bots/api#sendpaidmedia type SendPaidMediaP struct { @@ -258,6 +322,14 @@ func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) { return req.Do(api) } +// SendPaidMediaWithContext is the context-aware variant of SendPaidMedia. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendpaidmedia +func (api *API) SendPaidMediaWithContext(ctx context.Context, params SendPaidMediaP) (Message, error) { + req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendMediaGroupP holds parameters for the sendMediaGroup method. // See https://core.telegram.org/bots/api#sendmediagroup type SendMediaGroupP struct { @@ -280,3 +352,11 @@ func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) { req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID) return req.Do(api) } + +// SendMediaGroupWithContext is the context-aware variant of SendMediaGroup. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendmediagroup +func (api *API) SendMediaGroupWithContext(ctx context.Context, params SendMediaGroupP) ([]Message, error) { + req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index 28e78f7..5956755 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // SetMyCommandsP holds parameters for the setMyCommands method. // See https://core.telegram.org/bots/api#setmycommands type SetMyCommandsP struct { @@ -16,6 +18,14 @@ func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) { return req.Do(api) } +// SetMyCommandsWithContext is the context-aware variant of SetMyCommands. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setmycommands +func (api *API) SetMyCommandsWithContext(ctx context.Context, params SetMyCommandsP) (bool, error) { + req := NewRequest[bool]("setMyCommands", params) + return req.DoWithContext(ctx, api) +} + // DeleteMyCommandsP holds parameters for the deleteMyCommands method. // See https://core.telegram.org/bots/api#deletemycommands type DeleteMyCommandsP struct { @@ -31,6 +41,14 @@ func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) { return req.Do(api) } +// DeleteMyCommandsWithContext is the context-aware variant of DeleteMyCommands. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletemycommands +func (api *API) DeleteMyCommandsWithContext(ctx context.Context, params DeleteMyCommandsP) (bool, error) { + req := NewRequest[bool]("deleteMyCommands", params) + return req.DoWithContext(ctx, api) +} + // GetMyCommands holds parameters for the getMyCommands method. // See https://core.telegram.org/bots/api#getmycommands type GetMyCommands struct { @@ -45,6 +63,14 @@ func (api *API) GetMyCommands(params GetMyCommands) ([]BotCommand, error) { return req.Do(api) } +// GetMyCommandsWithContext is the context-aware variant of GetMyCommands. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getmycommands +func (api *API) GetMyCommandsWithContext(ctx context.Context, params GetMyCommands) ([]BotCommand, error) { + req := NewRequest[[]BotCommand]("getMyCommands", params) + return req.DoWithContext(ctx, api) +} + // SetMyName holds parameters for the setMyName method. // See https://core.telegram.org/bots/api#setmyname type SetMyName struct { @@ -60,6 +86,14 @@ func (api *API) SetMyName(params SetMyName) (bool, error) { return req.Do(api) } +// SetMyNameWithContext is the context-aware variant of SetMyName. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setmyname +func (api *API) SetMyNameWithContext(ctx context.Context, params SetMyName) (bool, error) { + req := NewRequest[bool]("setMyName", params) + return req.DoWithContext(ctx, api) +} + // GetMyName holds parameters for the getMyName method. // See https://core.telegram.org/bots/api#getmyname type GetMyName struct { @@ -73,6 +107,14 @@ func (api *API) GetMyName(params GetMyName) (BotName, error) { return req.Do(api) } +// GetMyNameWithContext is the context-aware variant of GetMyName. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getmyname +func (api *API) GetMyNameWithContext(ctx context.Context, params GetMyName) (BotName, error) { + req := NewRequest[BotName]("getMyName", params) + return req.DoWithContext(ctx, api) +} + // SetMyDescription holds parameters for the setMyDescription method. // See https://core.telegram.org/bots/api#setmydescription type SetMyDescription struct { @@ -88,6 +130,14 @@ func (api *API) SetMyDescription(params SetMyDescription) (bool, error) { return req.Do(api) } +// SetMyDescriptionWithContext is the context-aware variant of SetMyDescription. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setmydescription +func (api *API) SetMyDescriptionWithContext(ctx context.Context, params SetMyDescription) (bool, error) { + req := NewRequest[bool]("setMyDescription", params) + return req.DoWithContext(ctx, api) +} + // GetMyDescription holds parameters for the getMyDescription method. // See https://core.telegram.org/bots/api#getmydescription type GetMyDescription struct { @@ -101,6 +151,14 @@ func (api *API) GetMyDescription(params GetMyDescription) (BotDescription, error return req.Do(api) } +// GetMyDescriptionWithContext is the context-aware variant of GetMyDescription. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getmydescription +func (api *API) GetMyDescriptionWithContext(ctx context.Context, params GetMyDescription) (BotDescription, error) { + req := NewRequest[BotDescription]("getMyDescription", params) + return req.DoWithContext(ctx, api) +} + // SetMyShortDescription holds parameters for the setMyShortDescription method. // See https://core.telegram.org/bots/api#setmyshortdescription type SetMyShortDescription struct { @@ -116,6 +174,14 @@ func (api *API) SetMyShortDescription(params SetMyShortDescription) (bool, error return req.Do(api) } +// SetMyShortDescriptionWithContext is the context-aware variant of SetMyShortDescription. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setmyshortdescription +func (api *API) SetMyShortDescriptionWithContext(ctx context.Context, params SetMyShortDescription) (bool, error) { + req := NewRequest[bool]("setMyShortDescription", params) + return req.DoWithContext(ctx, api) +} + // GetMyShortDescription holds parameters for the getMyShortDescription method. // See https://core.telegram.org/bots/api#getmyshortdescription type GetMyShortDescription struct { @@ -129,6 +195,14 @@ func (api *API) GetMyShortDescription(params GetMyShortDescription) (BotShortDes return req.Do(api) } +// GetMyShortDescriptionWithContext is the context-aware variant of GetMyShortDescription. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getmyshortdescription +func (api *API) GetMyShortDescriptionWithContext(ctx context.Context, params GetMyShortDescription) (BotShortDescription, error) { + req := NewRequest[BotShortDescription]("getMyShortDescription", params) + return req.DoWithContext(ctx, api) +} + // SetMyProfilePhotoP holds parameters for the setMyProfilePhoto method. // See https://core.telegram.org/bots/api#setmyprofilephoto type SetMyProfilePhotoP struct { @@ -143,6 +217,14 @@ func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) { return req.Do(api) } +// SetMyProfilePhotoWithContext is the context-aware variant of SetMyProfilePhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setmyprofilephoto +func (api *API) SetMyProfilePhotoWithContext(ctx context.Context, params SetMyProfilePhotoP) (bool, error) { + req := NewRequest[bool]("setMyProfilePhoto", params) + return req.DoWithContext(ctx, api) +} + // RemoveMyProfilePhoto removes the bot's profile photo. // Returns true on success. // See https://core.telegram.org/bots/api#removemyprofilephoto @@ -151,6 +233,14 @@ func (api *API) RemoveMyProfilePhoto() (bool, error) { return req.Do(api) } +// RemoveMyProfilePhotoWithContext is the context-aware variant of RemoveMyProfilePhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#removemyprofilephoto +func (api *API) RemoveMyProfilePhotoWithContext(ctx context.Context) (bool, error) { + req := NewRequest[bool]("removeMyProfilePhoto", NoParams) + return req.DoWithContext(ctx, api) +} + // SetChatMenuButtonP holds parameters for the setChatMenuButton method. // See https://core.telegram.org/bots/api#setchatmenubutton type SetChatMenuButtonP struct { @@ -166,6 +256,14 @@ func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) { return req.Do(api) } +// SetChatMenuButtonWithContext is the context-aware variant of SetChatMenuButton. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setchatmenubutton +func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChatMenuButtonP) (bool, error) { + req := NewRequest[bool]("setChatMenuButton", params) + return req.DoWithContext(ctx, api) +} + // GetChatMenuButtonP holds parameters for the getChatMenuButton method. // See https://core.telegram.org/bots/api#getchatmenubutton type GetChatMenuButtonP struct { @@ -179,6 +277,14 @@ func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, er return req.Do(api) } +// GetChatMenuButtonWithContext is the context-aware variant of GetChatMenuButton. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getchatmenubutton +func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (BaseMenuButton, error) { + req := NewRequest[BaseMenuButton]("getChatMenuButton", params) + return req.DoWithContext(ctx, api) +} + // SetMyDefaultAdministratorRightsP holds parameters for the setMyDefaultAdministratorRights method. // See https://core.telegram.org/bots/api#setmydefaultadministratorrights type SetMyDefaultAdministratorRightsP struct { @@ -194,6 +300,14 @@ func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministrator return req.Do(api) } +// SetMyDefaultAdministratorRightsWithContext is the context-aware variant of SetMyDefaultAdministratorRights. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setmydefaultadministratorrights +func (api *API) SetMyDefaultAdministratorRightsWithContext(ctx context.Context, params SetMyDefaultAdministratorRightsP) (bool, error) { + req := NewRequest[bool]("setMyDefaultAdministratorRights", params) + return req.DoWithContext(ctx, api) +} + // GetMyDefaultAdministratorRightsP holds parameters for the getMyDefaultAdministratorRights method. // See https://core.telegram.org/bots/api#getmydefaultadministratorrights type GetMyDefaultAdministratorRightsP struct { @@ -207,6 +321,14 @@ func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministrator return req.Do(api) } +// GetMyDefaultAdministratorRightsWithContext is the context-aware variant of GetMyDefaultAdministratorRights. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getmydefaultadministratorrights +func (api *API) GetMyDefaultAdministratorRightsWithContext(ctx context.Context, params GetMyDefaultAdministratorRightsP) (ChatAdministratorRights, error) { + req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params) + return req.DoWithContext(ctx, api) +} + // GetAvailableGifts returns the list of gifts that can be sent by the bot. // See https://core.telegram.org/bots/api#getavailablegifts func (api *API) GetAvailableGifts() (Gifts, error) { @@ -214,6 +336,14 @@ func (api *API) GetAvailableGifts() (Gifts, error) { return req.Do(api) } +// GetAvailableGiftsWithContext is the context-aware variant of GetAvailableGifts. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getavailablegifts +func (api *API) GetAvailableGiftsWithContext(ctx context.Context) (Gifts, error) { + req := NewRequest[Gifts]("getAvailableGifts", NoParams) + return req.DoWithContext(ctx, api) +} + // SendGiftP holds parameters for the sendGift method. // See https://core.telegram.org/bots/api#sendgift type SendGiftP struct { @@ -234,6 +364,14 @@ func (api *API) SendGift(params SendGiftP) (bool, error) { return req.Do(api) } +// SendGiftWithContext is the context-aware variant of SendGift. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendgift +func (api *API) SendGiftWithContext(ctx context.Context, params SendGiftP) (bool, error) { + req := NewRequest[bool]("sendGift", params) + return req.DoWithContext(ctx, api) +} + // GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method. // See https://core.telegram.org/bots/api#giftpremiumsubscription type GiftPremiumSubscriptionP struct { @@ -252,3 +390,11 @@ func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool, req := NewRequest[bool]("giftPremiumSubscription", params) return req.Do(api) } + +// GiftPremiumSubscriptionWithContext is the context-aware variant of GiftPremiumSubscription. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#giftpremiumsubscription +func (api *API) GiftPremiumSubscriptionWithContext(ctx context.Context, params GiftPremiumSubscriptionP) (bool, error) { + req := NewRequest[bool]("giftPremiumSubscription", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/business_methods.go b/tgapi/business_methods.go index 476275c..e86a4c7 100644 --- a/tgapi/business_methods.go +++ b/tgapi/business_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // VerifyUserP holds parameters for the verifyUser method. // See https://core.telegram.org/bots/api#verifyuser type VerifyUserP struct { @@ -15,6 +17,14 @@ func (api *API) VerifyUser(params VerifyUserP) (bool, error) { return req.Do(api) } +// VerifyUserWithContext is the context-aware variant of VerifyUser. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#verifyuser +func (api *API) VerifyUserWithContext(ctx context.Context, params VerifyUserP) (bool, error) { + req := NewRequest[bool]("verifyUser", params) + return req.DoWithContext(ctx, api) +} + // VerifyChatP holds parameters for the verifyChat method. // See https://core.telegram.org/bots/api#verifychat type VerifyChatP struct { @@ -30,6 +40,14 @@ func (api *API) VerifyChat(params VerifyChatP) (bool, error) { return req.Do(api) } +// VerifyChatWithContext is the context-aware variant of VerifyChat. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#verifychat +func (api *API) VerifyChatWithContext(ctx context.Context, params VerifyChatP) (bool, error) { + req := NewRequest[bool]("verifyChat", params) + return req.DoWithContext(ctx, api) +} + // RemoveUserVerificationP holds parameters for the removeUserVerification method. // See https://core.telegram.org/bots/api#removeuserverification type RemoveUserVerificationP struct { @@ -44,6 +62,14 @@ func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, er return req.Do(api) } +// RemoveUserVerificationWithContext is the context-aware variant of RemoveUserVerification. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#removeuserverification +func (api *API) RemoveUserVerificationWithContext(ctx context.Context, params RemoveUserVerificationP) (bool, error) { + req := NewRequest[bool]("removeUserVerification", params) + return req.DoWithContext(ctx, api) +} + // RemoveChatVerificationP holds parameters for the removeChatVerification method. // See https://core.telegram.org/bots/api#removechatverification type RemoveChatVerificationP struct { @@ -58,6 +84,14 @@ func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, er return req.Do(api) } +// RemoveChatVerificationWithContext is the context-aware variant of RemoveChatVerification. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#removechatverification +func (api *API) RemoveChatVerificationWithContext(ctx context.Context, params RemoveChatVerificationP) (bool, error) { + req := NewRequest[bool]("removeChatVerification", params) + return req.DoWithContext(ctx, api) +} + // ReadBusinessMessageP holds parameters for the readBusinessMessage method. // See https://core.telegram.org/bots/api#readbusinessmessage type ReadBusinessMessageP struct { @@ -74,6 +108,14 @@ func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) { return req.Do(api) } +// ReadBusinessMessageWithContext is the context-aware variant of ReadBusinessMessage. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#readbusinessmessage +func (api *API) ReadBusinessMessageWithContext(ctx context.Context, params ReadBusinessMessageP) (bool, error) { + req := NewRequest[bool]("readBusinessMessage", params) + return req.DoWithContext(ctx, api) +} + // GetBusinessConnectionP holds parameters for the getBusinessConnection method. // See https://core.telegram.org/bots/api#getbusinessconnection type GetBusinessConnectionP struct { @@ -87,6 +129,14 @@ func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessCo return req.Do(api) } +// GetBusinessConnectionWithContext is the context-aware variant of GetBusinessConnection. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getbusinessconnection +func (api *API) GetBusinessConnectionWithContext(ctx context.Context, params GetBusinessConnectionP) (BusinessConnection, error) { + req := NewRequest[BusinessConnection]("getBusinessConnection", params) + return req.DoWithContext(ctx, api) +} + // DeleteBusinessMessagesP holds parameters for the deleteBusinessMessages method. // See https://core.telegram.org/bots/api#deletebusinessmessages type DeleteBusinessMessagesP struct { @@ -102,6 +152,14 @@ func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, er return req.Do(api) } +// DeleteBusinessMessagesWithContext is the context-aware variant of DeleteBusinessMessages. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletebusinessmessages +func (api *API) DeleteBusinessMessagesWithContext(ctx context.Context, params DeleteBusinessMessagesP) (bool, error) { + req := NewRequest[bool]("deleteBusinessMessages", params) + return req.DoWithContext(ctx, api) +} + // SetBusinessAccountNameP holds parameters for the setBusinessAccountName method. // See https://core.telegram.org/bots/api#setbusinessaccountname type SetBusinessAccountNameP struct { @@ -118,6 +176,14 @@ func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, er return req.Do(api) } +// SetBusinessAccountNameWithContext is the context-aware variant of SetBusinessAccountName. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setbusinessaccountname +func (api *API) SetBusinessAccountNameWithContext(ctx context.Context, params SetBusinessAccountNameP) (bool, error) { + req := NewRequest[bool]("setBusinessAccountName", params) + return req.DoWithContext(ctx, api) +} + // SetBusinessAccountUsernameP holds parameters for the setBusinessAccountUsername method. // See https://core.telegram.org/bots/api#setbusinessaccountusername type SetBusinessAccountUsernameP struct { @@ -133,6 +199,14 @@ func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) ( return req.Do(api) } +// SetBusinessAccountUsernameWithContext is the context-aware variant of SetBusinessAccountUsername. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setbusinessaccountusername +func (api *API) SetBusinessAccountUsernameWithContext(ctx context.Context, params SetBusinessAccountUsernameP) (bool, error) { + req := NewRequest[bool]("setBusinessAccountUsername", params) + return req.DoWithContext(ctx, api) +} + // SetBusinessAccountBioP holds parameters for the setBusinessAccountBio method. // See https://core.telegram.org/bots/api#setbusinessaccountbio type SetBusinessAccountBioP struct { @@ -148,6 +222,14 @@ func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, erro return req.Do(api) } +// SetBusinessAccountBioWithContext is the context-aware variant of SetBusinessAccountBio. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setbusinessaccountbio +func (api *API) SetBusinessAccountBioWithContext(ctx context.Context, params SetBusinessAccountBioP) (bool, error) { + req := NewRequest[bool]("setBusinessAccountBio", params) + return req.DoWithContext(ctx, api) +} + // SetBusinessAccountProfilePhoto holds parameters for the setBusinessAccountProfilePhoto method. // See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto type SetBusinessAccountProfilePhoto struct { @@ -164,6 +246,14 @@ func (api *API) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfileP return req.Do(api) } +// SetBusinessAccountProfilePhotoWithContext is the context-aware variant of SetBusinessAccountProfilePhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto +func (api *API) SetBusinessAccountProfilePhotoWithContext(ctx context.Context, params SetBusinessAccountProfilePhoto) (bool, error) { + req := NewRequest[bool]("setBusinessAccountProfilePhoto", params) + return req.DoWithContext(ctx, api) +} + // RemoveBusinessAccountProfilePhotoP holds parameters for the removeBusinessAccountProfilePhoto method. // See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto type RemoveBusinessAccountProfilePhotoP struct { @@ -179,6 +269,14 @@ func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountPr return req.Do(api) } +// RemoveBusinessAccountProfilePhotoWithContext is the context-aware variant of RemoveBusinessAccountProfilePhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto +func (api *API) RemoveBusinessAccountProfilePhotoWithContext(ctx context.Context, params RemoveBusinessAccountProfilePhotoP) (bool, error) { + req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params) + return req.DoWithContext(ctx, api) +} + // SetBusinessAccountGiftSettingsP holds parameters for the setBusinessAccountGiftSettings method. // See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings type SetBusinessAccountGiftSettingsP struct { @@ -195,6 +293,14 @@ func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSett return req.Do(api) } +// SetBusinessAccountGiftSettingsWithContext is the context-aware variant of SetBusinessAccountGiftSettings. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings +func (api *API) SetBusinessAccountGiftSettingsWithContext(ctx context.Context, params SetBusinessAccountGiftSettingsP) (bool, error) { + req := NewRequest[bool]("setBusinessAccountGiftSettings", params) + return req.DoWithContext(ctx, api) +} + // GetBusinessAccountStarBalanceP holds parameters for the getBusinessAccountStarBalance method. // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance type GetBusinessAccountStarBalanceP struct { @@ -208,6 +314,14 @@ func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalan return req.Do(api) } +// GetBusinessAccountStarBalanceWithContext is the context-aware variant of GetBusinessAccountStarBalance. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance +func (api *API) GetBusinessAccountStarBalanceWithContext(ctx context.Context, params GetBusinessAccountStarBalanceP) (StarAmount, error) { + req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params) + return req.DoWithContext(ctx, api) +} + // TransferBusinessAccountStarsP holds parameters for the transferBusinessAccountStars method. // See https://core.telegram.org/bots/api#transferbusinessaccountstars type TransferBusinessAccountStarsP struct { @@ -223,6 +337,14 @@ func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars return req.Do(api) } +// TransferBusinessAccountStarsWithContext is the context-aware variant of TransferBusinessAccountStars. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#transferbusinessaccountstars +func (api *API) TransferBusinessAccountStarsWithContext(ctx context.Context, params TransferBusinessAccountStarsP) (bool, error) { + req := NewRequest[bool]("transferBusinessAccountStars", params) + return req.DoWithContext(ctx, api) +} + // GetBusinessAccountGiftsP holds parameters for the getBusinessAccountGifts method. // See https://core.telegram.org/bots/api#getbusinessaccountgifts type GetBusinessAccountGiftsP struct { @@ -246,6 +368,14 @@ func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedG return req.Do(api) } +// GetBusinessAccountGiftsWithContext is the context-aware variant of GetBusinessAccountGifts. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getbusinessaccountgifts +func (api *API) GetBusinessAccountGiftsWithContext(ctx context.Context, params GetBusinessAccountGiftsP) (OwnedGifts, error) { + req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params) + return req.DoWithContext(ctx, api) +} + // ConvertGiftToStarsP holds parameters for the convertGiftToStars method. // See https://core.telegram.org/bots/api#convertgifttostars type ConvertGiftToStarsP struct { @@ -261,6 +391,14 @@ func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) { return req.Do(api) } +// ConvertGiftToStarsWithContext is the context-aware variant of ConvertGiftToStars. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#convertgifttostars +func (api *API) ConvertGiftToStarsWithContext(ctx context.Context, params ConvertGiftToStarsP) (bool, error) { + req := NewRequest[bool]("convertGiftToStars", params) + return req.DoWithContext(ctx, api) +} + // UpgradeGiftP holds parameters for the upgradeGift method. // See https://core.telegram.org/bots/api#upgradegift type UpgradeGiftP struct { @@ -278,6 +416,14 @@ func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) { return req.Do(api) } +// UpgradeGiftWithContext is the context-aware variant of UpgradeGift. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#upgradegift +func (api *API) UpgradeGiftWithContext(ctx context.Context, params UpgradeGiftP) (bool, error) { + req := NewRequest[bool]("upgradeGift", params) + return req.DoWithContext(ctx, api) +} + // TransferGiftP holds parameters for the transferGift method. // See https://core.telegram.org/bots/api#transfergift type TransferGiftP struct { @@ -295,6 +441,14 @@ func (api *API) TransferGift(params TransferGiftP) (bool, error) { return req.Do(api) } +// TransferGiftWithContext is the context-aware variant of TransferGift. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#transfergift +func (api *API) TransferGiftWithContext(ctx context.Context, params TransferGiftP) (bool, error) { + req := NewRequest[bool]("transferGift", params) + return req.DoWithContext(ctx, api) +} + // PostStoryP holds parameters for the postStory method. // See https://core.telegram.org/bots/api#poststory type PostStoryP struct { @@ -318,6 +472,14 @@ func (api *API) PostStoryPhoto(params PostStoryP) (Story, error) { return req.Do(api) } +// PostStoryPhotoWithContext is the context-aware variant of PostStoryPhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#poststory +func (api *API) PostStoryPhotoWithContext(ctx context.Context, params PostStoryP) (Story, error) { + req := NewRequest[Story]("postStory", params) + return req.DoWithContext(ctx, api) +} + // PostStoryVideo posts a story with a video. // See https://core.telegram.org/bots/api#poststory func (api *API) PostStoryVideo(params PostStoryP) (Story, error) { @@ -325,6 +487,14 @@ func (api *API) PostStoryVideo(params PostStoryP) (Story, error) { return req.Do(api) } +// PostStoryVideoWithContext is the context-aware variant of PostStoryVideo. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#poststory +func (api *API) PostStoryVideoWithContext(ctx context.Context, params PostStoryP) (Story, error) { + req := NewRequest[Story]("postStory", params) + return req.DoWithContext(ctx, api) +} + // RepostStoryP holds parameters for the repostStory method. // See https://core.telegram.org/bots/api#repoststory type RepostStoryP struct { @@ -344,6 +514,14 @@ func (api *API) RepostStory(params RepostStoryP) (Story, error) { return req.Do(api) } +// RepostStoryWithContext is the context-aware variant of RepostStory. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#repoststory +func (api *API) RepostStoryWithContext(ctx context.Context, params RepostStoryP) (Story, error) { + req := NewRequest[Story]("repostStory", params) + return req.DoWithContext(ctx, api) +} + // EditStoryP holds parameters for the editStory method. // See https://core.telegram.org/bots/api#editstory type EditStoryP struct { @@ -365,6 +543,14 @@ func (api *API) EditStory(params EditStoryP) (Story, error) { return req.Do(api) } +// EditStoryWithContext is the context-aware variant of EditStory. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editstory +func (api *API) EditStoryWithContext(ctx context.Context, params EditStoryP) (Story, error) { + req := NewRequest[Story]("editStory", params) + return req.DoWithContext(ctx, api) +} + // DeleteStoryP holds parameters for the deleteStory method. // See https://core.telegram.org/bots/api#deletestory type DeleteStoryP struct { @@ -379,3 +565,11 @@ func (api *API) DeleteStory(params DeleteStoryP) (bool, error) { req := NewRequest[bool]("deleteStory", params) return req.Do(api) } + +// DeleteStoryWithContext is the context-aware variant of DeleteStory. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletestory +func (api *API) DeleteStoryWithContext(ctx context.Context, params DeleteStoryP) (bool, error) { + req := NewRequest[bool]("deleteStory", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index eb6ac4e..9799984 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // BanChatMemberP holds parameters for the banChatMember method. // See https://core.telegram.org/bots/api#banchatmember type BanChatMemberP struct { @@ -17,6 +19,14 @@ func (api *API) BanChatMember(params BanChatMemberP) (bool, error) { return req.Do(api) } +// BanChatMemberWithContext is the context-aware variant of BanChatMember. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#banchatmember +func (api *API) BanChatMemberWithContext(ctx context.Context, params BanChatMemberP) (bool, error) { + req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // UnbanChatMemberP holds parameters for the unbanChatMember method. // See https://core.telegram.org/bots/api#unbanchatmember type UnbanChatMemberP struct { @@ -33,6 +43,14 @@ func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) { return req.Do(api) } +// UnbanChatMemberWithContext is the context-aware variant of UnbanChatMember. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#unbanchatmember +func (api *API) UnbanChatMemberWithContext(ctx context.Context, params UnbanChatMemberP) (bool, error) { + req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // RestrictChatMemberP holds parameters for the restrictChatMember method. // See https://core.telegram.org/bots/api#restrictchatmember type RestrictChatMemberP struct { @@ -51,6 +69,14 @@ func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) { return req.Do(api) } +// RestrictChatMemberWithContext is the context-aware variant of RestrictChatMember. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#restrictchatmember +func (api *API) RestrictChatMemberWithContext(ctx context.Context, params RestrictChatMemberP) (bool, error) { + req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // PromoteChatMember holds parameters for the promoteChatMember method. // See https://core.telegram.org/bots/api#promotechatmember type PromoteChatMember struct { @@ -84,6 +110,14 @@ func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) { return req.Do(api) } +// PromoteChatMemberWithContext is the context-aware variant of PromoteChatMember. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#promotechatmember +func (api *API) PromoteChatMemberWithContext(ctx context.Context, params PromoteChatMember) (bool, error) { + req := NewRequestWithChatID[bool]("promoteChatMember", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SetChatAdministratorCustomTitleP holds parameters for the setChatAdministratorCustomTitle method. // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle type SetChatAdministratorCustomTitleP struct { @@ -100,6 +134,14 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto return req.Do(api) } +// SetChatAdministratorCustomTitleWithContext is the context-aware variant of SetChatAdministratorCustomTitle. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle +func (api *API) SetChatAdministratorCustomTitleWithContext(ctx context.Context, params SetChatAdministratorCustomTitleP) (bool, error) { + req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SetChatMemberTagP holds parameters for the setChatMemberTag method. // See https://core.telegram.org/bots/api#setchatmembertag type SetChatMemberTagP struct { @@ -116,6 +158,14 @@ func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) { return req.Do(api) } +// SetChatMemberTagWithContext is the context-aware variant of SetChatMemberTag. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setchatmembertag +func (api *API) SetChatMemberTagWithContext(ctx context.Context, params SetChatMemberTagP) (bool, error) { + req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // BanChatSenderChatP holds parameters for the banChatSenderChat method. // See https://core.telegram.org/bots/api#banchatsenderchat type BanChatSenderChatP struct { @@ -131,6 +181,14 @@ func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) { return req.Do(api) } +// BanChatSenderChatWithContext is the context-aware variant of BanChatSenderChat. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#banchatsenderchat +func (api *API) BanChatSenderChatWithContext(ctx context.Context, params BanChatSenderChatP) (bool, error) { + req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // UnbanChatSenderChatP holds parameters for the unbanChatSenderChat method. // See https://core.telegram.org/bots/api#unbanchatsenderchat type UnbanChatSenderChatP struct { @@ -146,6 +204,14 @@ func (api *API) UnbanChatSenderChat(params UnbanChatSenderChatP) (bool, error) { return req.Do(api) } +// UnbanChatSenderChatWithContext is the context-aware variant of UnbanChatSenderChat. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#unbanchatsenderchat +func (api *API) UnbanChatSenderChatWithContext(ctx context.Context, params UnbanChatSenderChatP) (bool, error) { + req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SetChatPermissionsP holds parameters for the setChatPermissions method. // See https://core.telegram.org/bots/api#setchatpermissions type SetChatPermissionsP struct { @@ -162,6 +228,14 @@ func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) { return req.Do(api) } +// SetChatPermissionsWithContext is the context-aware variant of SetChatPermissions. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setchatpermissions +func (api *API) SetChatPermissionsWithContext(ctx context.Context, params SetChatPermissionsP) (bool, error) { + req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // ExportChatInviteLinkP holds parameters for the exportChatInviteLink method. // See https://core.telegram.org/bots/api#exportchatinvitelink type ExportChatInviteLinkP struct { @@ -176,6 +250,14 @@ func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, erro return req.Do(api) } +// ExportChatInviteLinkWithContext is the context-aware variant of ExportChatInviteLink. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#exportchatinvitelink +func (api *API) ExportChatInviteLinkWithContext(ctx context.Context, params ExportChatInviteLinkP) (string, error) { + req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // CreateChatInviteLinkP holds parameters for the createChatInviteLink method. // See https://core.telegram.org/bots/api#createchatinvitelink type CreateChatInviteLinkP struct { @@ -194,6 +276,14 @@ func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLi return req.Do(api) } +// CreateChatInviteLinkWithContext is the context-aware variant of CreateChatInviteLink. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#createchatinvitelink +func (api *API) CreateChatInviteLinkWithContext(ctx context.Context, params CreateChatInviteLinkP) (ChatInviteLink, error) { + req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // EditChatInviteLinkP holds parameters for the editChatInviteLink method. // See https://core.telegram.org/bots/api#editchatinvitelink type EditChatInviteLinkP struct { @@ -214,6 +304,14 @@ func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink, return req.Do(api) } +// EditChatInviteLinkWithContext is the context-aware variant of EditChatInviteLink. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editchatinvitelink +func (api *API) EditChatInviteLinkWithContext(ctx context.Context, params EditChatInviteLinkP) (ChatInviteLink, error) { + req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // CreateChatSubscriptionInviteLinkP holds parameters for the createChatSubscriptionInviteLink method. // See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink type CreateChatSubscriptionInviteLinkP struct { @@ -231,6 +329,14 @@ func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionIn return req.Do(api) } +// CreateChatSubscriptionInviteLinkWithContext is the context-aware variant of CreateChatSubscriptionInviteLink. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink +func (api *API) CreateChatSubscriptionInviteLinkWithContext(ctx context.Context, params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) { + req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // EditChatSubscriptionInviteLinkP holds parameters for the editChatSubscriptionInviteLink method. // See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink type EditChatSubscriptionInviteLinkP struct { @@ -247,6 +353,14 @@ func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInvite return req.Do(api) } +// EditChatSubscriptionInviteLinkWithContext is the context-aware variant of EditChatSubscriptionInviteLink. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink +func (api *API) EditChatSubscriptionInviteLinkWithContext(ctx context.Context, params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) { + req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // RevokeChatInviteLinkP holds parameters for the revokeChatInviteLink method. // See https://core.telegram.org/bots/api#revokechatinvitelink type RevokeChatInviteLinkP struct { @@ -262,6 +376,14 @@ func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLi return req.Do(api) } +// RevokeChatInviteLinkWithContext is the context-aware variant of RevokeChatInviteLink. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#revokechatinvitelink +func (api *API) RevokeChatInviteLinkWithContext(ctx context.Context, params RevokeChatInviteLinkP) (ChatInviteLink, error) { + req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // ApproveChatJoinRequestP holds parameters for the approveChatJoinRequest method. // See https://core.telegram.org/bots/api#approvechatjoinrequest type ApproveChatJoinRequestP struct { @@ -277,6 +399,14 @@ func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, er return req.Do(api) } +// ApproveChatJoinRequestWithContext is the context-aware variant of ApproveChatJoinRequest. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#approvechatjoinrequest +func (api *API) ApproveChatJoinRequestWithContext(ctx context.Context, params ApproveChatJoinRequestP) (bool, error) { + req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // DeclineChatJoinRequestP holds parameters for the declineChatJoinRequest method. // See https://core.telegram.org/bots/api#declinechatjoinrequest type DeclineChatJoinRequestP struct { @@ -292,6 +422,14 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, er return req.Do(api) } +// DeclineChatJoinRequestWithContext is the context-aware variant of DeclineChatJoinRequest. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#declinechatjoinrequest +func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params DeclineChatJoinRequestP) (bool, error) { + req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SetChatPhotoP holds parameters for the setChatPhoto method. // See https://core.telegram.org/bots/api#setchatphoto type SetChatPhotoP struct { @@ -325,6 +463,14 @@ func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) { return req.Do(api) } +// DeleteChatPhotoWithContext is the context-aware variant of DeleteChatPhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletechatphoto +func (api *API) DeleteChatPhotoWithContext(ctx context.Context, params DeleteChatPhotoP) (bool, error) { + req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SetChatTitleP holds parameters for the setChatTitle method. // See https://core.telegram.org/bots/api#setchattitle type SetChatTitleP struct { @@ -340,6 +486,14 @@ func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) { return req.Do(api) } +// SetChatTitleWithContext is the context-aware variant of SetChatTitle. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setchattitle +func (api *API) SetChatTitleWithContext(ctx context.Context, params SetChatTitleP) (bool, error) { + req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SetChatDescriptionP holds parameters for the setChatDescription method. // See https://core.telegram.org/bots/api#setchatdescription type SetChatDescriptionP struct { @@ -355,6 +509,14 @@ func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) { return req.Do(api) } +// SetChatDescriptionWithContext is the context-aware variant of SetChatDescription. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setchatdescription +func (api *API) SetChatDescriptionWithContext(ctx context.Context, params SetChatDescriptionP) (bool, error) { + req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // PinChatMessageP holds parameters for the pinChatMessage method. // See https://core.telegram.org/bots/api#pinchatmessage type PinChatMessageP struct { @@ -372,6 +534,14 @@ func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) { return req.Do(api) } +// PinChatMessageWithContext is the context-aware variant of PinChatMessage. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#pinchatmessage +func (api *API) PinChatMessageWithContext(ctx context.Context, params PinChatMessageP) (bool, error) { + req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // UnpinChatMessageP holds parameters for the unpinChatMessage method. // See https://core.telegram.org/bots/api#unpinchatmessage type UnpinChatMessageP struct { @@ -388,6 +558,14 @@ func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) { return req.Do(api) } +// UnpinChatMessageWithContext is the context-aware variant of UnpinChatMessage. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#unpinchatmessage +func (api *API) UnpinChatMessageWithContext(ctx context.Context, params UnpinChatMessageP) (bool, error) { + req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // UnpinAllChatMessagesP holds parameters for the unpinAllChatMessages method. // See https://core.telegram.org/bots/api#unpinallchatmessages type UnpinAllChatMessagesP struct { @@ -402,6 +580,14 @@ func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error) return req.Do(api) } +// UnpinAllChatMessagesWithContext is the context-aware variant of UnpinAllChatMessages. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#unpinallchatmessages +func (api *API) UnpinAllChatMessagesWithContext(ctx context.Context, params UnpinAllChatMessagesP) (bool, error) { + req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // LeaveChatP holds parameters for the leaveChat method. // See https://core.telegram.org/bots/api#leavechat type LeaveChatP struct { @@ -416,6 +602,14 @@ func (api *API) LeaveChat(params LeaveChatP) (bool, error) { return req.Do(api) } +// LeaveChatWithContext is the context-aware variant of LeaveChat. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#leavechat +func (api *API) LeaveChatWithContext(ctx context.Context, params LeaveChatP) (bool, error) { + req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name + return req.DoWithContext(ctx, api) +} + // GetChatP holds parameters for the getChat method. // See https://core.telegram.org/bots/api#getchat type GetChatP struct { @@ -429,6 +623,14 @@ func (api *API) GetChat(params GetChatP) (ChatFullInfo, error) { return req.Do(api) } +// GetChatWithContext is the context-aware variant of GetChat. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getchat +func (api *API) GetChatWithContext(ctx context.Context, params GetChatP) (ChatFullInfo, error) { + req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name + return req.DoWithContext(ctx, api) +} + // GetChatAdministratorsP holds parameters for the getChatAdministrators method. // See https://core.telegram.org/bots/api#getchatadministrators type GetChatAdministratorsP struct { @@ -442,6 +644,14 @@ func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMemb return req.Do(api) } +// GetChatAdministratorsWithContext is the context-aware variant of GetChatAdministrators. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getchatadministrators +func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params GetChatAdministratorsP) ([]ChatMember, error) { + req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // GetChatMembersCountP holds parameters for the getChatMemberCount method. // See https://core.telegram.org/bots/api#getchatmembercount type GetChatMembersCountP struct { @@ -455,6 +665,14 @@ func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) { return req.Do(api) } +// GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getchatmembercount +func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMembersCountP) (int, error) { + req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // GetChatMemberP holds parameters for the getChatMember method. // See https://core.telegram.org/bots/api#getchatmember type GetChatMemberP struct { @@ -469,6 +687,14 @@ func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) { return req.Do(api) } +// GetChatMemberWithContext is the context-aware variant of GetChatMember. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getchatmember +func (api *API) GetChatMemberWithContext(ctx context.Context, params GetChatMemberP) (ChatMember, error) { + req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SetChatStickerSetP holds parameters for the setChatStickerSet method. // See https://core.telegram.org/bots/api#setchatstickerset type SetChatStickerSetP struct { @@ -484,6 +710,14 @@ func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) { return req.Do(api) } +// SetChatStickerSetWithContext is the context-aware variant of SetChatStickerSet. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setchatstickerset +func (api *API) SetChatStickerSetWithContext(ctx context.Context, params SetChatStickerSetP) (bool, error) { + req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // DeleteChatStickerSetP holds parameters for the deleteChatStickerSet method. // See https://core.telegram.org/bots/api#deletechatstickerset type DeleteChatStickerSetP struct { @@ -498,6 +732,14 @@ func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) return req.Do(api) } +// DeleteChatStickerSetWithContext is the context-aware variant of DeleteChatStickerSet. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletechatstickerset +func (api *API) DeleteChatStickerSetWithContext(ctx context.Context, params DeleteChatStickerSetP) (bool, error) { + req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // GetUserChatBoostsP holds parameters for the getUserChatBoosts method. // See https://core.telegram.org/bots/api#getuserchatboosts type GetUserChatBoostsP struct { @@ -512,6 +754,14 @@ func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, er return req.Do(api) } +// GetUserChatBoostsWithContext is the context-aware variant of GetUserChatBoosts. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getuserchatboosts +func (api *API) GetUserChatBoostsWithContext(ctx context.Context, params GetUserChatBoostsP) (UserChatBoosts, error) { + req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // GetChatGiftsP holds parameters for the getChatGifts method. // See https://core.telegram.org/bots/api#getchatgifts type GetChatGiftsP struct { @@ -534,3 +784,11 @@ func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) { req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID) return req.Do(api) } + +// GetChatGiftsWithContext is the context-aware variant of GetChatGifts. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getchatgifts +func (api *API) GetChatGiftsWithContext(ctx context.Context, params GetChatGiftsP) (OwnedGifts, error) { + req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/forum_methods.go b/tgapi/forum_methods.go index e7fa6d9..bbd8c44 100644 --- a/tgapi/forum_methods.go +++ b/tgapi/forum_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // BaseForumTopicP contains common fields for forum topic operations that require a chat ID and a message thread ID. type BaseForumTopicP struct { ChatID int64 `json:"chat_id"` @@ -13,6 +15,14 @@ func (api *API) GetForumTopicIconStickers() ([]Sticker, error) { return req.Do(api) } +// GetForumTopicIconStickersWithContext is the context-aware variant of GetForumTopicIconStickers. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getforumtopiciconstickers +func (api *API) GetForumTopicIconStickersWithContext(ctx context.Context) ([]Sticker, error) { + req := NewRequest[[]Sticker]("getForumTopicIconStickers", NoParams) + return req.DoWithContext(ctx, api) +} + // CreateForumTopicP holds parameters for the createForumTopic method. // See https://core.telegram.org/bots/api#createforumtopic type CreateForumTopicP struct { @@ -30,6 +40,14 @@ func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) { return req.Do(api) } +// CreateForumTopicWithContext is the context-aware variant of CreateForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#createforumtopic +func (api *API) CreateForumTopicWithContext(ctx context.Context, params CreateForumTopicP) (ForumTopic, error) { + req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // EditForumTopicP holds parameters for the editForumTopic method. // See https://core.telegram.org/bots/api#editforumtopic type EditForumTopicP struct { @@ -46,6 +64,14 @@ func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) { return req.Do(api) } +// EditForumTopicWithContext is the context-aware variant of EditForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editforumtopic +func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // CloseForumTopic closes an open forum topic. // Returns True on success. // See https://core.telegram.org/bots/api#closeforumtopic @@ -54,6 +80,14 @@ func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) { return req.Do(api) } +// CloseForumTopicWithContext is the context-aware variant of CloseForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#closeforumtopic +func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // ReopenForumTopic reopens a closed forum topic. // Returns True on success. // See https://core.telegram.org/bots/api#reopenforumtopic @@ -62,6 +96,14 @@ func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) { return req.Do(api) } +// ReopenForumTopicWithContext is the context-aware variant of ReopenForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#reopenforumtopic +func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // DeleteForumTopic deletes a forum topic. // Returns True on success. // See https://core.telegram.org/bots/api#deleteforumtopic @@ -70,6 +112,14 @@ func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) { return req.Do(api) } +// DeleteForumTopicWithContext is the context-aware variant of DeleteForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deleteforumtopic +func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic. // Returns True on success. // See https://core.telegram.org/bots/api#unpinallforumtopicmessages @@ -78,6 +128,14 @@ func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error) return req.Do(api) } +// UnpinAllForumTopicMessagesWithContext is the context-aware variant of UnpinAllForumTopicMessages. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#unpinallforumtopicmessages +func (api *API) UnpinAllForumTopicMessagesWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // BaseGeneralForumTopicP contains common fields for general forum topic operations that require a chat ID. type BaseGeneralForumTopicP struct { ChatID int64 `json:"chat_id"` @@ -98,6 +156,14 @@ func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, erro return req.Do(api) } +// EditGeneralForumTopicWithContext is the context-aware variant of EditGeneralForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editgeneralforumtopic +func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params EditGeneralForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // CloseGeneralForumTopic closes the 'General' topic in a forum supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#closegeneralforumtopic @@ -106,6 +172,14 @@ func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, err return req.Do(api) } +// CloseGeneralForumTopicWithContext is the context-aware variant of CloseGeneralForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#closegeneralforumtopic +func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#reopengeneralforumtopic @@ -114,6 +188,14 @@ func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er return req.Do(api) } +// ReopenGeneralForumTopicWithContext is the context-aware variant of ReopenGeneralForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#reopengeneralforumtopic +func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // HideGeneralForumTopic hides the 'General' topic in a forum supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#hidegeneralforumtopic @@ -122,6 +204,14 @@ func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, erro return req.Do(api) } +// HideGeneralForumTopicWithContext is the context-aware variant of HideGeneralForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#hidegeneralforumtopic +func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#unhidegeneralforumtopic @@ -130,6 +220,14 @@ func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er return req.Do(api) } +// UnhideGeneralForumTopicWithContext is the context-aware variant of UnhideGeneralForumTopic. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#unhidegeneralforumtopic +func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic. // Returns True on success. // See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages @@ -137,3 +235,11 @@ func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP) req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID) return req.Do(api) } + +// UnpinAllGeneralForumTopicMessagesWithContext is the context-aware variant of UnpinAllGeneralForumTopicMessages. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages +func (api *API) UnpinAllGeneralForumTopicMessagesWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { + req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/games_methods.go b/tgapi/games_methods.go index 4441edb..0aa060f 100644 --- a/tgapi/games_methods.go +++ b/tgapi/games_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // SendGameP holds parameters for the sendGame method. // See https://core.telegram.org/bots/api#sendgame type SendGameP struct { @@ -24,6 +26,14 @@ func (api *API) SendGame(params SendGameP) (Message, error) { return req.Do(api) } +// SendGameWithContext is the context-aware variant of SendGame. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendgame +func (api *API) SendGameWithContext(ctx context.Context, params SendGameP) (Message, error) { + req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SetGameScoreP holds parameters for the setGameScore method. // See https://core.telegram.org/bots/api#setgamescore type SetGameScoreP struct { @@ -52,6 +62,21 @@ func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) { return res, false, err } +// SetGameScoreWithContext is the context-aware variant of SetGameScore. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setgamescore +func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScoreP) (Message, bool, error) { + var zero Message + if params.InlineMessageID != "" { + req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return zero, res, err + } + req := NewRequestWithChatID[Message]("setGameScore", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return res, false, err +} + // GetGameHighScoresP holds parameters for the getGameHighScores method. // See https://core.telegram.org/bots/api#getgamehighscores type GetGameHighScoresP struct { @@ -67,3 +92,11 @@ func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, e req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID) return req.Do(api) } + +// GetGameHighScoresWithContext is the context-aware variant of GetGameHighScores. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getgamehighscores +func (api *API) GetGameHighScoresWithContext(ctx context.Context, params GetGameHighScoresP) ([]GameHighScore, error) { + req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/inline_methods.go b/tgapi/inline_methods.go index 1bcc48a..8b8232e 100644 --- a/tgapi/inline_methods.go +++ b/tgapi/inline_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // AnswerInlineQueryP holds parameters for the answerInlineQuery method. // See https://core.telegram.org/bots/api#answerinlinequery type AnswerInlineQueryP struct { @@ -19,6 +21,14 @@ func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) { return req.Do(api) } +// AnswerInlineQueryWithContext is the context-aware variant of AnswerInlineQuery. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#answerinlinequery +func (api *API) AnswerInlineQueryWithContext(ctx context.Context, params AnswerInlineQueryP) (bool, error) { + req := NewRequest[bool]("answerInlineQuery", params) + return req.DoWithContext(ctx, api) +} + // AnswerWebAppQueryP holds parameters for the answerWebAppQuery method. // See https://core.telegram.org/bots/api#answerwebappquery type AnswerWebAppQueryP struct { @@ -33,6 +43,14 @@ func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage, return req.Do(api) } +// AnswerWebAppQueryWithContext is the context-aware variant of AnswerWebAppQuery. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#answerwebappquery +func (api *API) AnswerWebAppQueryWithContext(ctx context.Context, params AnswerWebAppQueryP) (SentWebAppMessage, error) { + req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params) + return req.DoWithContext(ctx, api) +} + // SavePreparedInlineMessageP holds parameters for the savePreparedInlineMessage method. // See https://core.telegram.org/bots/api#savepreparedinlinemessage type SavePreparedInlineMessageP struct { @@ -50,3 +68,11 @@ func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (Pr req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params) return req.Do(api) } + +// SavePreparedInlineMessageWithContext is the context-aware variant of SavePreparedInlineMessage. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#savepreparedinlinemessage +func (api *API) SavePreparedInlineMessageWithContext(ctx context.Context, params SavePreparedInlineMessageP) (PreparedInlineMessage, error) { + req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index dc87f8b..69ced5c 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // SendMessageP holds parameters for the sendMessage method. // See https://core.telegram.org/bots/api#sendmessage type SendMessageP struct { @@ -29,6 +31,14 @@ func (api *API) SendMessage(params SendMessageP) (Message, error) { return req.Do(api) } +// SendMessageWithContext is the context-aware variant of SendMessage. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendmessage +func (api *API) SendMessageWithContext(ctx context.Context, params SendMessageP) (Message, error) { + req := NewRequestWithChatID[Message, SendMessageP]("sendMessage", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // ForwardMessageP holds parameters for the forwardMessage method. // See https://core.telegram.org/bots/api#forwardmessage type ForwardMessageP struct { @@ -53,6 +63,14 @@ func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) { return req.Do(api) } +// ForwardMessageWithContext is the context-aware variant of ForwardMessage. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#forwardmessage +func (api *API) ForwardMessageWithContext(ctx context.Context, params ForwardMessageP) (Message, error) { + req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // ForwardMessagesP holds parameters for the forwardMessages method. // See https://core.telegram.org/bots/api#forwardmessages type ForwardMessagesP struct { @@ -74,6 +92,14 @@ func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) { return req.Do(api) } +// ForwardMessagesWithContext is the context-aware variant of ForwardMessages. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#forwardmessages +func (api *API) ForwardMessagesWithContext(ctx context.Context, params ForwardMessagesP) ([]MessageID, error) { + req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // CopyMessageP holds parameters for the copyMessage method. // See https://core.telegram.org/bots/api#copymessage type CopyMessageP struct { @@ -110,6 +136,17 @@ func (api *API) CopyMessage(params CopyMessageP) (int, error) { return msgID.MessageID, nil } +// CopyMessageWithContext is the context-aware variant of CopyMessage. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#copymessage +func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessageP) (int, error) { + msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).DoWithContext(ctx, api) + if err != nil { + return 0, err + } + return msgID.MessageID, nil +} + // CopyMessagesP holds parameters for the copyMessages method. // See https://core.telegram.org/bots/api#copymessages type CopyMessagesP struct { @@ -132,6 +169,14 @@ func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) { return req.Do(api) } +// CopyMessagesWithContext is the context-aware variant of CopyMessages. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#copymessages +func (api *API) CopyMessagesWithContext(ctx context.Context, params CopyMessagesP) ([]MessageID, error) { + req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendLocationP holds parameters for the sendLocation method. // See https://core.telegram.org/bots/api#sendlocation type SendLocationP struct { @@ -164,6 +209,14 @@ func (api *API) SendLocation(params SendLocationP) (Message, error) { return req.Do(api) } +// SendLocationWithContext is the context-aware variant of SendLocation. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendlocation +func (api *API) SendLocationWithContext(ctx context.Context, params SendLocationP) (Message, error) { + req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendVenueP holds parameters for the sendVenue method. // See https://core.telegram.org/bots/api#sendvenue type SendVenueP struct { @@ -198,6 +251,14 @@ func (api *API) SendVenue(params SendVenueP) (Message, error) { return req.Do(api) } +// SendVenueWithContext is the context-aware variant of SendVenue. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendvenue +func (api *API) SendVenueWithContext(ctx context.Context, params SendVenueP) (Message, error) { + req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendContactP holds parameters for the sendContact method. // See https://core.telegram.org/bots/api#sendcontact type SendContactP struct { @@ -228,6 +289,14 @@ func (api *API) SendContact(params SendContactP) (Message, error) { return req.Do(api) } +// SendContactWithContext is the context-aware variant of SendContact. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendcontact +func (api *API) SendContactWithContext(ctx context.Context, params SendContactP) (Message, error) { + req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendPollP holds parameters for the sendPoll method. // See https://core.telegram.org/bots/api#sendpoll type SendPollP struct { @@ -266,6 +335,14 @@ func (api *API) SendPoll(params SendPollP) (Message, error) { return req.Do(api) } +// SendPollWithContext is the context-aware variant of SendPoll. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendpoll +func (api *API) SendPollWithContext(ctx context.Context, params SendPollP) (Message, error) { + req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendChecklistP holds parameters for the sendChecklist method. // See https://core.telegram.org/bots/api#sendchecklist type SendChecklistP struct { @@ -288,6 +365,14 @@ func (api *API) SendChecklist(params SendChecklistP) (Message, error) { return req.Do(api) } +// SendChecklistWithContext is the context-aware variant of SendChecklist. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendchecklist +func (api *API) SendChecklistWithContext(ctx context.Context, params SendChecklistP) (Message, error) { + req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendDiceP holds parameters for the sendDice method. // See https://core.telegram.org/bots/api#senddice type SendDiceP struct { @@ -315,6 +400,14 @@ func (api *API) SendDice(params SendDiceP) (Message, error) { return req.Do(api) } +// SendDiceWithContext is the context-aware variant of SendDice. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#senddice +func (api *API) SendDiceWithContext(ctx context.Context, params SendDiceP) (Message, error) { + req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendMessageDraftP holds parameters for the sendMessageDraft method. // See https://core.telegram.org/bots/api#sendmessagedraft type SendMessageDraftP struct { @@ -334,6 +427,14 @@ func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) { return req.Do(api) } +// SendMessageDraftWithContext is the context-aware variant of SendMessageDraft. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendmessagedraft +func (api *API) SendMessageDraftWithContext(ctx context.Context, params SendMessageDraftP) (bool, error) { + req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SendChatActionP holds parameters for the sendChatAction method. // See https://core.telegram.org/bots/api#sendchataction type SendChatActionP struct { @@ -351,6 +452,14 @@ func (api *API) SendChatAction(params SendChatActionP) (bool, error) { return req.Do(api) } +// SendChatActionWithContext is the context-aware variant of SendChatAction. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendchataction +func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatActionP) (bool, error) { + req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // SetMessageReactionP holds parameters for the setMessageReaction method. // See https://core.telegram.org/bots/api#setmessagereaction type SetMessageReactionP struct { @@ -368,16 +477,26 @@ func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) { return req.Do(api) } +// SetMessageReactionWithContext is the context-aware variant of SetMessageReaction. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setmessagereaction +func (api *API) SetMessageReactionWithContext(ctx context.Context, params SetMessageReactionP) (bool, error) { + req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // EditMessageTextP holds parameters for the editMessageText method. // See https://core.telegram.org/bots/api#editmessagetext type EditMessageTextP struct { - BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id,omitempty"` - MessageID int `json:"message_id,omitempty"` - InlineMessageID string `json:"inline_message_id,omitempty"` - Text string `json:"text"` - ParseMode ParseMode `json:"parse_mode,omitempty"` - ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` + Text string `json:"text"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + Entities []MessageEntity `json:"entities,omitempty"` + LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` + ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } // EditMessageText edits text messages. @@ -396,16 +515,33 @@ func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error) return res, false, err } +// EditMessageTextWithContext is the context-aware variant of EditMessageText. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editmessagetext +func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessageTextP) (Message, bool, error) { + var zero Message + if params.InlineMessageID != "" { + req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return zero, res, err + } + req := NewRequestWithChatID[Message]("editMessageText", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return res, false, err +} + // EditMessageCaptionP holds parameters for the editMessageCaption method. // See https://core.telegram.org/bots/api#editmessagecaption type EditMessageCaptionP struct { - BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id,omitempty"` - MessageID int `json:"message_id,omitempty"` - InlineMessageID string `json:"inline_message_id,omitempty"` - Caption string `json:"caption"` - ParseMode ParseMode `json:"parse_mode,omitempty"` - ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` + Caption string `json:"caption"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` + ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } // EditMessageCaption edits captions of messages. @@ -424,6 +560,21 @@ func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, e return res, false, err } +// EditMessageCaptionWithContext is the context-aware variant of EditMessageCaption. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editmessagecaption +func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMessageCaptionP) (Message, bool, error) { + var zero Message + if params.InlineMessageID != "" { + req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return zero, res, err + } + req := NewRequestWithChatID[Message]("editMessageCaption", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return res, false, err +} + // EditMessageMediaP holds parameters for the editMessageMedia method. // See https://core.telegram.org/bots/api#editmessagemedia type EditMessageMediaP struct { @@ -451,6 +602,21 @@ func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error return res, false, err } +// EditMessageMediaWithContext is the context-aware variant of EditMessageMedia. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editmessagemedia +func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMessageMediaP) (Message, bool, error) { + var zero Message + if params.InlineMessageID != "" { + req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return zero, res, err + } + req := NewRequestWithChatID[Message]("editMessageMedia", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return res, false, err +} + // EditMessageLiveLocationP holds parameters for the editMessageLiveLocation method. // See https://core.telegram.org/bots/api#editmessagelivelocation type EditMessageLiveLocationP struct { @@ -484,6 +650,21 @@ func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Messag return res, false, err } +// EditMessageLiveLocationWithContext is the context-aware variant of EditMessageLiveLocation. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editmessagelivelocation +func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params EditMessageLiveLocationP) (Message, bool, error) { + var zero Message + if params.InlineMessageID != "" { + req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return zero, res, err + } + req := NewRequestWithChatID[Message]("editMessageLiveLocation", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return res, false, err +} + // StopMessageLiveLocationP holds parameters for the stopMessageLiveLocation method. // See https://core.telegram.org/bots/api#stopmessagelivelocation type StopMessageLiveLocationP struct { @@ -510,6 +691,21 @@ func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Messag return res, false, err } +// StopMessageLiveLocationWithContext is the context-aware variant of StopMessageLiveLocation. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#stopmessagelivelocation +func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params StopMessageLiveLocationP) (Message, bool, error) { + var zero Message + if params.InlineMessageID != "" { + req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return zero, res, err + } + req := NewRequestWithChatID[Message]("stopMessageLiveLocation", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return res, false, err +} + // EditMessageChecklistP holds parameters for the editMessageChecklist method. type EditMessageChecklistP struct { BusinessConnectionID string `json:"business_connection_id"` @@ -526,6 +722,14 @@ func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, err return req.Do(api) } +// EditMessageChecklistWithContext is the context-aware variant of EditMessageChecklist. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editmessagechecklist +func (api *API) EditMessageChecklistWithContext(ctx context.Context, params EditMessageChecklistP) (Message, error) { + req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // EditMessageReplyMarkupP holds parameters for the editMessageReplyMarkup method. // See https://core.telegram.org/bots/api#editmessagereplymarkup type EditMessageReplyMarkupP struct { @@ -552,13 +756,28 @@ func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message, return res, false, err } +// EditMessageReplyMarkupWithContext is the context-aware variant of EditMessageReplyMarkup. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#editmessagereplymarkup +func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params EditMessageReplyMarkupP) (Message, bool, error) { + var zero Message + if params.InlineMessageID != "" { + req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return zero, res, err + } + req := NewRequestWithChatID[Message]("editMessageReplyMarkup", params, params.ChatID) + res, err := req.DoWithContext(ctx, api) + return res, false, err +} + // StopPollP holds parameters for the stopPoll method. // See https://core.telegram.org/bots/api#stoppoll type StopPollP struct { - BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageID int `json:"message_id"` - InlineMessageID string `json:"inline_message_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageID int `json:"message_id"` + ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } // StopPoll stops a poll that was sent by the bot. @@ -569,6 +788,14 @@ func (api *API) StopPoll(params StopPollP) (Poll, error) { return req.Do(api) } +// StopPollWithContext is the context-aware variant of StopPoll. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#stoppoll +func (api *API) StopPollWithContext(ctx context.Context, params StopPollP) (Poll, error) { + req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // ApproveSuggestedPostP holds parameters for the approveSuggestedPost method. // See https://core.telegram.org/bots/api#approvesuggestedpost type ApproveSuggestedPostP struct { @@ -585,6 +812,14 @@ func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error) return req.Do(api) } +// ApproveSuggestedPostWithContext is the context-aware variant of ApproveSuggestedPost. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#approvesuggestedpost +func (api *API) ApproveSuggestedPostWithContext(ctx context.Context, params ApproveSuggestedPostP) (bool, error) { + req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // DeclineSuggestedPostP holds parameters for the declineSuggestedPost method. // See https://core.telegram.org/bots/api#declinesuggestedpost type DeclineSuggestedPostP struct { @@ -601,6 +836,14 @@ func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error) return req.Do(api) } +// DeclineSuggestedPostWithContext is the context-aware variant of DeclineSuggestedPost. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#declinesuggestedpost +func (api *API) DeclineSuggestedPostWithContext(ctx context.Context, params DeclineSuggestedPostP) (bool, error) { + req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // DeleteMessageP holds parameters for the deleteMessage method. // See https://core.telegram.org/bots/api#deletemessage type DeleteMessageP struct { @@ -616,6 +859,14 @@ func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) { return req.Do(api) } +// DeleteMessageWithContext is the context-aware variant of DeleteMessage. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletemessage +func (api *API) DeleteMessageWithContext(ctx context.Context, params DeleteMessageP) (bool, error) { + req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // DeleteMessagesP holds parameters for the deleteMessages method. // See https://core.telegram.org/bots/api#deletemessages type DeleteMessagesP struct { @@ -631,6 +882,14 @@ func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) { return req.Do(api) } +// DeleteMessagesWithContext is the context-aware variant of DeleteMessages. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletemessages +func (api *API) DeleteMessagesWithContext(ctx context.Context, params DeleteMessagesP) (bool, error) { + req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // AnswerCallbackQueryP holds parameters for the answerCallbackQuery method. // See https://core.telegram.org/bots/api#answercallbackquery type AnswerCallbackQueryP struct { @@ -648,3 +907,11 @@ func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) { req := NewRequest[bool]("answerCallbackQuery", params) return req.Do(api) } + +// AnswerCallbackQueryWithContext is the context-aware variant of AnswerCallbackQuery. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#answercallbackquery +func (api *API) AnswerCallbackQueryWithContext(ctx context.Context, params AnswerCallbackQueryP) (bool, error) { + req := NewRequest[bool]("answerCallbackQuery", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/methods.go b/tgapi/methods.go index 7375e9b..e4a2f9c 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -25,6 +25,14 @@ func (api *API) GetMe() (User, error) { return req.Do(api) } +// GetMeWithContext is the context-aware variant of GetMe. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getme +func (api *API) GetMeWithContext(ctx context.Context) (User, error) { + req := NewRequest[User, EmptyParams]("getMe", NoParams) + return req.DoWithContext(ctx, api) +} + // LogOut logs the bot out from the cloud Bot API server. // Returns true on success. // See https://core.telegram.org/bots/api#logout @@ -33,6 +41,14 @@ func (api *API) LogOut() (bool, error) { return req.Do(api) } +// LogOutWithContext is the context-aware variant of LogOut. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#logout +func (api *API) LogOutWithContext(ctx context.Context) (bool, error) { + req := NewRequest[bool, EmptyParams]("logOut", NoParams) + return req.DoWithContext(ctx, api) +} + // Close closes the bot instance on the local server. // Returns true on success. // See https://core.telegram.org/bots/api#close @@ -41,6 +57,14 @@ func (api *API) Close() (bool, error) { return req.Do(api) } +// CloseWithContext is the context-aware variant of Close. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#close +func (api *API) CloseWithContext(ctx context.Context) (bool, error) { + req := NewRequest[bool, EmptyParams]("close", NoParams) + return req.DoWithContext(ctx, api) +} + // GetUpdates receives incoming updates using long polling. // See https://core.telegram.org/bots/api#getupdates func (api *API) GetUpdates(params UpdateParams) ([]Update, error) { @@ -48,16 +72,19 @@ func (api *API) GetUpdates(params UpdateParams) ([]Update, error) { return req.Do(api) } +// GetUpdatesWithContext is the context-aware variant of GetUpdates. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getupdates func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) ([]Update, error) { req := NewRequest[[]Update]("getUpdates", params) return req.DoWithContext(ctx, api) } // SetWebhookP holds parameters for the setWebhook method. +// To upload a self-signed certificate, use Uploader.SetWebhook. // See https://core.telegram.org/bots/api#setwebhook type SetWebhookP struct { URL string `json:"url"` - Certificate string `json:"certificate,omitempty"` IPAddress string `json:"ip_address,omitempty"` MaxConnections int `json:"max_connections,omitempty"` AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` @@ -66,6 +93,7 @@ type SetWebhookP struct { } // SetWebhook sets a webhook URL for incoming updates. +// For certificate upload, use Uploader.SetWebhook. // Returns true on success. // See https://core.telegram.org/bots/api#setwebhook func (api *API) SetWebhook(params SetWebhookP) (bool, error) { @@ -73,6 +101,15 @@ func (api *API) SetWebhook(params SetWebhookP) (bool, error) { return req.Do(api) } +// SetWebhookWithContext is the context-aware variant of SetWebhook. +// It executes the same request but uses ctx for cancellation and deadlines. +// For certificate upload, use Uploader.SetWebhook. +// See https://core.telegram.org/bots/api#setwebhook +func (api *API) SetWebhookWithContext(ctx context.Context, params SetWebhookP) (bool, error) { + req := NewRequest[bool]("setWebhook", params) + return req.DoWithContext(ctx, api) +} + // DeleteWebhookP holds parameters for the deleteWebhook method. // See https://core.telegram.org/bots/api#deletewebhook type DeleteWebhookP struct { @@ -87,6 +124,14 @@ func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) { return req.Do(api) } +// DeleteWebhookWithContext is the context-aware variant of DeleteWebhook. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletewebhook +func (api *API) DeleteWebhookWithContext(ctx context.Context, params DeleteWebhookP) (bool, error) { + req := NewRequest[bool]("deleteWebhook", params) + return req.DoWithContext(ctx, api) +} + // GetWebhookInfo returns the current webhook status. // See https://core.telegram.org/bots/api#getwebhookinfo func (api *API) GetWebhookInfo() (WebhookInfo, error) { @@ -94,6 +139,14 @@ func (api *API) GetWebhookInfo() (WebhookInfo, error) { return req.Do(api) } +// GetWebhookInfoWithContext is the context-aware variant of GetWebhookInfo. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getwebhookinfo +func (api *API) GetWebhookInfoWithContext(ctx context.Context) (WebhookInfo, error) { + req := NewRequest[WebhookInfo]("getWebhookInfo", NoParams) + return req.DoWithContext(ctx, api) +} + // GetFileP holds parameters for the getFile method. // See https://core.telegram.org/bots/api#getfile type GetFileP struct { @@ -107,17 +160,36 @@ func (api *API) GetFile(params GetFileP) (File, error) { return req.Do(api) } +// GetFileWithContext is the context-aware variant of GetFile. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getfile +func (api *API) GetFileWithContext(ctx context.Context, params GetFileP) (File, error) { + req := NewRequest[File]("getFile", params) + return req.DoWithContext(ctx, api) +} + // GetFileByLink downloads a file from Telegram's file server using the provided file link. // The link is usually obtained from File.FilePath. // See https://core.telegram.org/bots/api#file func (api *API) GetFileByLink(link string) ([]byte, error) { + return api.getFileByLink(context.Background(), link) +} + +// GetFileByLinkWithContext is the context-aware variant of GetFileByLink. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#file +func (api *API) GetFileByLinkWithContext(ctx context.Context, link string) ([]byte, error) { + return api.getFileByLink(ctx, link) +} + +func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error) { methodPrefix := "" if api.useTestServer { methodPrefix = "/test" } u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link) - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } diff --git a/tgapi/passport_methods.go b/tgapi/passport_methods.go index d6e5ae8..4af8fd5 100644 --- a/tgapi/passport_methods.go +++ b/tgapi/passport_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // SetPassportDataErrorsP holds parameters for the setPassportDataErrors method. // See https://core.telegram.org/bots/api#setpassportdataerrors type SetPassportDataErrorsP struct { @@ -14,3 +16,11 @@ func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, erro req := NewRequest[bool]("setPassportDataErrors", params) return req.Do(api) } + +// SetPassportDataErrorsWithContext is the context-aware variant of SetPassportDataErrors. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setpassportdataerrors +func (api *API) SetPassportDataErrorsWithContext(ctx context.Context, params SetPassportDataErrorsP) (bool, error) { + req := NewRequest[bool]("setPassportDataErrors", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/payments_methods.go b/tgapi/payments_methods.go index ef87590..3cc9966 100644 --- a/tgapi/payments_methods.go +++ b/tgapi/payments_methods.go @@ -1,12 +1,13 @@ package tgapi +import "context" + // SendInvoiceP holds parameters for the sendInvoice method. // See https://core.telegram.org/bots/api#sendinvoice type SendInvoiceP struct { - BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Title string `json:"title"` Description string `json:"description"` @@ -47,6 +48,14 @@ func (api *API) SendInvoice(params SendInvoiceP) (Message, error) { return req.Do(api) } +// SendInvoiceWithContext is the context-aware variant of SendInvoice. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendinvoice +func (api *API) SendInvoiceWithContext(ctx context.Context, params SendInvoiceP) (Message, error) { + req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // CreateInvoiceLinkP holds parameters for the createInvoiceLink method. // See https://core.telegram.org/bots/api#createinvoicelink type CreateInvoiceLinkP struct { @@ -83,6 +92,14 @@ func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) { return req.Do(api) } +// CreateInvoiceLinkWithContext is the context-aware variant of CreateInvoiceLink. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#createinvoicelink +func (api *API) CreateInvoiceLinkWithContext(ctx context.Context, params CreateInvoiceLinkP) (string, error) { + req := NewRequest[string]("createInvoiceLink", params) + return req.DoWithContext(ctx, api) +} + // AnswerShippingQueryP holds parameters for the answerShippingQuery method. // See https://core.telegram.org/bots/api#answershippingquery type AnswerShippingQueryP struct { @@ -100,6 +117,14 @@ func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) { return req.Do(api) } +// AnswerShippingQueryWithContext is the context-aware variant of AnswerShippingQuery. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#answershippingquery +func (api *API) AnswerShippingQueryWithContext(ctx context.Context, params AnswerShippingQueryP) (bool, error) { + req := NewRequest[bool]("answerShippingQuery", params) + return req.DoWithContext(ctx, api) +} + // AnswerPreCheckoutQueryP holds parameters for the answerPreCheckoutQuery method. // See https://core.telegram.org/bots/api#answerprecheckoutquery type AnswerPreCheckoutQueryP struct { @@ -115,3 +140,11 @@ func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, er req := NewRequest[bool]("answerPreCheckoutQuery", params) return req.Do(api) } + +// AnswerPreCheckoutQueryWithContext is the context-aware variant of AnswerPreCheckoutQuery. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#answerprecheckoutquery +func (api *API) AnswerPreCheckoutQueryWithContext(ctx context.Context, params AnswerPreCheckoutQueryP) (bool, error) { + req := NewRequest[bool]("answerPreCheckoutQuery", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/stars_methods.go b/tgapi/stars_methods.go index 7e3e0ab..cec2447 100644 --- a/tgapi/stars_methods.go +++ b/tgapi/stars_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // GetStarTransactionsP holds parameters for the getStarTransactions method. // See https://core.telegram.org/bots/api#getstartransactions type GetStarTransactionsP struct { @@ -14,6 +16,14 @@ func (api *API) GetMyStarBalance() (StarAmount, error) { return req.Do(api) } +// GetMyStarBalanceWithContext is the context-aware variant of GetMyStarBalance. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getmystarbalance +func (api *API) GetMyStarBalanceWithContext(ctx context.Context) (StarAmount, error) { + req := NewRequest[StarAmount]("getMyStarBalance", NoParams) + return req.DoWithContext(ctx, api) +} + // GetStarTransactions returns Telegram Star transactions for the bot. // See https://core.telegram.org/bots/api#getstartransactions func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) { @@ -21,6 +31,14 @@ func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactio return req.Do(api) } +// GetStarTransactionsWithContext is the context-aware variant of GetStarTransactions. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getstartransactions +func (api *API) GetStarTransactionsWithContext(ctx context.Context, params GetStarTransactionsP) (StarTransactions, error) { + req := NewRequest[StarTransactions]("getStarTransactions", params) + return req.DoWithContext(ctx, api) +} + // RefundStarPaymentP holds parameters for the refundStarPayment method. // See https://core.telegram.org/bots/api#refundstarpayment type RefundStarPaymentP struct { @@ -36,6 +54,14 @@ func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) { return req.Do(api) } +// RefundStarPaymentWithContext is the context-aware variant of RefundStarPayment. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#refundstarpayment +func (api *API) RefundStarPaymentWithContext(ctx context.Context, params RefundStarPaymentP) (bool, error) { + req := NewRequest[bool]("refundStarPayment", params) + return req.DoWithContext(ctx, api) +} + // EditUserStarSubscriptionP holds parameters for the editUserStarSubscription method. // See https://core.telegram.org/bots/api#edituserstarsubscription type EditUserStarSubscriptionP struct { @@ -51,3 +77,11 @@ func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool req := NewRequest[bool]("editUserStarSubscription", params) return req.Do(api) } + +// EditUserStarSubscriptionWithContext is the context-aware variant of EditUserStarSubscription. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#edituserstarsubscription +func (api *API) EditUserStarSubscriptionWithContext(ctx context.Context, params EditUserStarSubscriptionP) (bool, error) { + req := NewRequest[bool]("editUserStarSubscription", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/stickers_methods.go b/tgapi/stickers_methods.go index 77cad3b..6bffcf9 100644 --- a/tgapi/stickers_methods.go +++ b/tgapi/stickers_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // SendStickerP holds parameters for the sendSticker method. // See https://core.telegram.org/bots/api#sendsticker type SendStickerP struct { @@ -14,6 +16,10 @@ type SendStickerP struct { ProtectContent bool `json:"protect_content,omitempty"` AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"` + + SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` + ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` + ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } // SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker. @@ -23,6 +29,14 @@ func (api *API) SendSticker(params SendStickerP) (Message, error) { return req.Do(api) } +// SendStickerWithContext is the context-aware variant of SendSticker. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendsticker +func (api *API) SendStickerWithContext(ctx context.Context, params SendStickerP) (Message, error) { + req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID) + return req.DoWithContext(ctx, api) +} + // GetStickerSetP holds parameters for the getStickerSet method. // See https://core.telegram.org/bots/api#getstickerset type GetStickerSetP struct { @@ -36,6 +50,14 @@ func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) { return req.Do(api) } +// GetStickerSetWithContext is the context-aware variant of GetStickerSet. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getstickerset +func (api *API) GetStickerSetWithContext(ctx context.Context, params GetStickerSetP) (StickerSet, error) { + req := NewRequest[StickerSet]("getStickerSet", params) + return req.DoWithContext(ctx, api) +} + // GetCustomEmojiStickersP holds parameters for the getCustomEmojiStickers method. // See https://core.telegram.org/bots/api#getcustomemojistickers type GetCustomEmojiStickersP struct { @@ -49,6 +71,14 @@ func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticke return req.Do(api) } +// GetCustomEmojiStickersWithContext is the context-aware variant of GetCustomEmojiStickers. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getcustomemojistickers +func (api *API) GetCustomEmojiStickersWithContext(ctx context.Context, params GetCustomEmojiStickersP) ([]Sticker, error) { + req := NewRequest[[]Sticker]("getCustomEmojiStickers", params) + return req.DoWithContext(ctx, api) +} + // UploadStickerFileP holds parameters for the uploadStickerFile method. // See https://core.telegram.org/bots/api#uploadstickerfile type UploadStickerFileP struct { @@ -68,6 +98,18 @@ func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFil return req.Do(uploader) } +// UploadStickerFileWithContext is the context-aware variant of UploadStickerFile. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#uploadstickerfile +func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadStickerFileP, sticker UploaderFile) (File, error) { + uploader := NewUploader(api) + defer func() { + _ = uploader.Close() + }() + req := NewUploaderRequest[File]("uploadStickerFile", params, sticker.SetType(UploaderStickerType)) + return req.DoWithContext(ctx, uploader) +} + // CreateNewStickerSetP holds parameters for the createNewStickerSet method. // See https://core.telegram.org/bots/api#createnewstickerset type CreateNewStickerSetP struct { @@ -88,6 +130,14 @@ func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) { return req.Do(api) } +// CreateNewStickerSetWithContext is the context-aware variant of CreateNewStickerSet. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#createnewstickerset +func (api *API) CreateNewStickerSetWithContext(ctx context.Context, params CreateNewStickerSetP) (bool, error) { + req := NewRequest[bool]("createNewStickerSet", params) + return req.DoWithContext(ctx, api) +} + // AddStickerToSetP holds parameters for the addStickerToSet method. // See https://core.telegram.org/bots/api#addstickertoset type AddStickerToSetP struct { @@ -104,6 +154,14 @@ func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) { return req.Do(api) } +// AddStickerToSetWithContext is the context-aware variant of AddStickerToSet. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#addstickertoset +func (api *API) AddStickerToSetWithContext(ctx context.Context, params AddStickerToSetP) (bool, error) { + req := NewRequest[bool]("addStickerToSet", params) + return req.DoWithContext(ctx, api) +} + // SetStickerPositionInSetP holds parameters for the setStickerPositionInSet method. // See https://core.telegram.org/bots/api#setstickerpositioninset type SetStickerPositionInSetP struct { @@ -119,6 +177,14 @@ func (api *API) SetStickerPositionInSet(params SetStickerPositionInSetP) (bool, return req.Do(api) } +// SetStickerPositionInSetWithContext is the context-aware variant of SetStickerPositionInSet. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setstickerpositioninset +func (api *API) SetStickerPositionInSetWithContext(ctx context.Context, params SetStickerPositionInSetP) (bool, error) { + req := NewRequest[bool]("setStickerPositionInSet", params) + return req.DoWithContext(ctx, api) +} + // DeleteStickerFromSetP holds parameters for the deleteStickerFromSet method. // See https://core.telegram.org/bots/api#deletestickerfromset type DeleteStickerFromSetP struct { @@ -133,6 +199,14 @@ func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error) return req.Do(api) } +// DeleteStickerFromSetWithContext is the context-aware variant of DeleteStickerFromSet. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletestickerfromset +func (api *API) DeleteStickerFromSetWithContext(ctx context.Context, params DeleteStickerFromSetP) (bool, error) { + req := NewRequest[bool]("deleteStickerFromSet", params) + return req.DoWithContext(ctx, api) +} + // ReplaceStickerInSetP holds parameters for the replaceStickerInSet method. // See https://core.telegram.org/bots/api#replacestickerinset type ReplaceStickerInSetP struct { @@ -150,6 +224,14 @@ func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) { return req.Do(api) } +// ReplaceStickerInSetWithContext is the context-aware variant of ReplaceStickerInSet. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#replacestickerinset +func (api *API) ReplaceStickerInSetWithContext(ctx context.Context, params ReplaceStickerInSetP) (bool, error) { + req := NewRequest[bool]("replaceStickerInSet", params) + return req.DoWithContext(ctx, api) +} + // SetStickerEmojiListP holds parameters for the setStickerEmojiList method. // See https://core.telegram.org/bots/api#setstickeremojilist type SetStickerEmojiListP struct { @@ -165,6 +247,14 @@ func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) { return req.Do(api) } +// SetStickerEmojiListWithContext is the context-aware variant of SetStickerEmojiList. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setstickeremojilist +func (api *API) SetStickerEmojiListWithContext(ctx context.Context, params SetStickerEmojiListP) (bool, error) { + req := NewRequest[bool]("setStickerEmojiList", params) + return req.DoWithContext(ctx, api) +} + // SetStickerKeywordsP holds parameters for the setStickerKeywords method. // See https://core.telegram.org/bots/api#setstickerkeywords type SetStickerKeywordsP struct { @@ -180,6 +270,14 @@ func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) { return req.Do(api) } +// SetStickerKeywordsWithContext is the context-aware variant of SetStickerKeywords. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setstickerkeywords +func (api *API) SetStickerKeywordsWithContext(ctx context.Context, params SetStickerKeywordsP) (bool, error) { + req := NewRequest[bool]("setStickerKeywords", params) + return req.DoWithContext(ctx, api) +} + // SetStickerMaskPositionP holds parameters for the setStickerMaskPosition method. // See https://core.telegram.org/bots/api#setstickermaskposition type SetStickerMaskPositionP struct { @@ -195,6 +293,14 @@ func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, er return req.Do(api) } +// SetStickerMaskPositionWithContext is the context-aware variant of SetStickerMaskPosition. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setstickermaskposition +func (api *API) SetStickerMaskPositionWithContext(ctx context.Context, params SetStickerMaskPositionP) (bool, error) { + req := NewRequest[bool]("setStickerMaskPosition", params) + return req.DoWithContext(ctx, api) +} + // SetStickerSetTitleP holds parameters for the setStickerSetTitle method. // See https://core.telegram.org/bots/api#setstickersettitle type SetStickerSetTitleP struct { @@ -210,6 +316,14 @@ func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) { return req.Do(api) } +// SetStickerSetTitleWithContext is the context-aware variant of SetStickerSetTitle. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setstickersettitle +func (api *API) SetStickerSetTitleWithContext(ctx context.Context, params SetStickerSetTitleP) (bool, error) { + req := NewRequest[bool]("setStickerSetTitle", params) + return req.DoWithContext(ctx, api) +} + // SetStickerSetThumbnailP holds parameters for the setStickerSetThumbnail method. // See https://core.telegram.org/bots/api#setstickersetthumbnail type SetStickerSetThumbnailP struct { @@ -227,6 +341,14 @@ func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, er return req.Do(api) } +// SetStickerSetThumbnailWithContext is the context-aware variant of SetStickerSetThumbnail. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setstickersetthumbnail +func (api *API) SetStickerSetThumbnailWithContext(ctx context.Context, params SetStickerSetThumbnailP) (bool, error) { + req := NewRequest[bool]("setStickerSetThumbnail", params) + return req.DoWithContext(ctx, api) +} + // SetCustomEmojiStickerSetThumbnailP holds parameters for the setCustomEmojiStickerSetThumbnail method. // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail type SetCustomEmojiStickerSetThumbnailP struct { @@ -242,6 +364,14 @@ func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSe return req.Do(api) } +// SetCustomEmojiStickerSetThumbnailWithContext is the context-aware variant of SetCustomEmojiStickerSetThumbnail. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail +func (api *API) SetCustomEmojiStickerSetThumbnailWithContext(ctx context.Context, params SetCustomEmojiStickerSetThumbnailP) (bool, error) { + req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params) + return req.DoWithContext(ctx, api) +} + // DeleteStickerSetP holds parameters for the deleteStickerSet method. // See https://core.telegram.org/bots/api#deletestickerset type DeleteStickerSetP struct { @@ -255,3 +385,11 @@ func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) { req := NewRequest[bool]("deleteStickerSet", params) return req.Do(api) } + +// DeleteStickerSetWithContext is the context-aware variant of DeleteStickerSet. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletestickerset +func (api *API) DeleteStickerSetWithContext(ctx context.Context, params DeleteStickerSetP) (bool, error) { + req := NewRequest[bool]("deleteStickerSet", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index a950030..732b894 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -30,6 +30,8 @@ const ( UploaderThumbnailType UploaderFileType = "thumbnail" // UploaderStickerType is the multipart field name for sticker uploads. UploaderStickerType UploaderFileType = "sticker" + // UploaderCertificateType is the multipart field name for webhook certificate uploads. + UploaderCertificateType UploaderFileType = "certificate" ) // UploaderFileType represents the Telegram form field name for a file upload. diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index 0ab9547..ba78abb 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // UploadPhotoP holds parameters for uploading a photo using the Uploader. // See https://core.telegram.org/bots/api#sendphoto type UploadPhotoP struct { @@ -32,6 +34,16 @@ func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, e return req.Do(u) } +// SendPhotoWithContext is the context-aware variant of SendPhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// SendPhotoWithContext is the context-aware variant of SendPhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendphoto +func (u *Uploader) SendPhotoWithContext(ctx context.Context, params UploadPhotoP, file UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file) + return req.DoWithContext(ctx, u) +} + // UploadAudioP holds parameters for uploading an audio file using the Uploader. // See https://core.telegram.org/bots/api#sendaudio type UploadAudioP struct { @@ -66,6 +78,16 @@ func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Messag return req.Do(u) } +// SendAudioWithContext is the context-aware variant of SendAudio. +// It executes the same request but uses ctx for cancellation and deadlines. +// SendAudioWithContext is the context-aware variant of SendAudio. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendaudio +func (u *Uploader) SendAudioWithContext(ctx context.Context, params UploadAudioP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...) + return req.DoWithContext(ctx, u) +} + // UploadDocumentP holds parameters for uploading a document using the Uploader. // See https://core.telegram.org/bots/api#senddocument type UploadDocumentP struct { @@ -97,6 +119,16 @@ func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) ( return req.Do(u) } +// SendDocumentWithContext is the context-aware variant of SendDocument. +// It executes the same request but uses ctx for cancellation and deadlines. +// SendDocumentWithContext is the context-aware variant of SendDocument. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#senddocument +func (u *Uploader) SendDocumentWithContext(ctx context.Context, params UploadDocumentP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...) + return req.DoWithContext(ctx, u) +} + // UploadVideoP holds parameters for uploading a video using the Uploader. // See https://core.telegram.org/bots/api#sendvideo type UploadVideoP struct { @@ -135,6 +167,16 @@ func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Messag return req.Do(u) } +// SendVideoWithContext is the context-aware variant of SendVideo. +// It executes the same request but uses ctx for cancellation and deadlines. +// SendVideoWithContext is the context-aware variant of SendVideo. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendvideo +func (u *Uploader) SendVideoWithContext(ctx context.Context, params UploadVideoP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...) + return req.DoWithContext(ctx, u) +} + // UploadAnimationP holds parameters for uploading an animation using the Uploader. // See https://core.telegram.org/bots/api#sendanimation type UploadAnimationP struct { @@ -171,6 +213,16 @@ func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile) return req.Do(u) } +// SendAnimationWithContext is the context-aware variant of SendAnimation. +// It executes the same request but uses ctx for cancellation and deadlines. +// SendAnimationWithContext is the context-aware variant of SendAnimation. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendanimation +func (u *Uploader) SendAnimationWithContext(ctx context.Context, params UploadAnimationP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...) + return req.DoWithContext(ctx, u) +} + // UploadVoiceP holds parameters for uploading a voice note using the Uploader. // See https://core.telegram.org/bots/api#sendvoice type UploadVoiceP struct { @@ -202,6 +254,16 @@ func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Messag return req.Do(u) } +// SendVoiceWithContext is the context-aware variant of SendVoice. +// It executes the same request but uses ctx for cancellation and deadlines. +// SendVoiceWithContext is the context-aware variant of SendVoice. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendvoice +func (u *Uploader) SendVoiceWithContext(ctx context.Context, params UploadVoiceP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...) + return req.DoWithContext(ctx, u) +} + // UploadVideoNoteP holds parameters for uploading a video note (rounded video) using the Uploader. // See https://core.telegram.org/bots/api#sendvideonote type UploadVideoNoteP struct { @@ -231,6 +293,16 @@ func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile) return req.Do(u) } +// SendVideoNoteWithContext is the context-aware variant of SendVideoNote. +// It executes the same request but uses ctx for cancellation and deadlines. +// SendVideoNoteWithContext is the context-aware variant of SendVideoNote. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendvideonote +func (u *Uploader) SendVideoNoteWithContext(ctx context.Context, params UploadVideoNoteP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...) + return req.DoWithContext(ctx, u) +} + // UploadChatPhotoP holds parameters for uploading a chat photo using the Uploader. // See https://core.telegram.org/bots/api#setchatphoto type UploadChatPhotoP struct { @@ -244,3 +316,41 @@ func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bo req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo) return req.Do(u) } + +// SetChatPhotoWithContext is the context-aware variant of SetChatPhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// SetChatPhotoWithContext is the context-aware variant of SetChatPhoto. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setchatphoto +func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadChatPhotoP, photo UploaderFile) (bool, error) { + req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo) + return req.DoWithContext(ctx, u) +} + +// UploadSetWebhookP holds multipart parameters for the setWebhook method. +// Use this type when uploading a self-signed certificate file. +// See https://core.telegram.org/bots/api#setwebhook +type UploadSetWebhookP struct { + URL string `json:"url"` + IPAddress string `json:"ip_address,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` + DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` + SecretToken string `json:"secret_token,omitempty"` +} + +// SetWebhook uploads a certificate and sets a webhook URL. +// certificate maps to the multipart field \"certificate\". +// See https://core.telegram.org/bots/api#setwebhook +func (u *Uploader) SetWebhook(params UploadSetWebhookP, certificate UploaderFile) (bool, error) { + req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType)) + return req.Do(u) +} + +// SetWebhookWithContext is the context-aware variant of SetWebhook. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setwebhook +func (u *Uploader) SetWebhookWithContext(ctx context.Context, params UploadSetWebhookP, certificate UploaderFile) (bool, error) { + req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType)) + return req.DoWithContext(ctx, u) +} diff --git a/tgapi/users_methods.go b/tgapi/users_methods.go index ff5ea01..08c883b 100644 --- a/tgapi/users_methods.go +++ b/tgapi/users_methods.go @@ -1,5 +1,7 @@ package tgapi +import "context" + // GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method. // See https://core.telegram.org/bots/api#getuserprofilephotos type GetUserProfilePhotosP struct { @@ -15,6 +17,14 @@ func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfileP return req.Do(api) } +// GetUserProfilePhotosWithContext is the context-aware variant of GetUserProfilePhotos. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getuserprofilephotos +func (api *API) GetUserProfilePhotosWithContext(ctx context.Context, params GetUserProfilePhotosP) (UserProfilePhotos, error) { + req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params) + return req.DoWithContext(ctx, api) +} + // GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method. // See https://core.telegram.org/bots/api#getuserprofileaudios type GetUserProfileAudiosP struct { @@ -30,6 +40,14 @@ func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileA return req.Do(api) } +// GetUserProfileAudiosWithContext is the context-aware variant of GetUserProfileAudios. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getuserprofileaudios +func (api *API) GetUserProfileAudiosWithContext(ctx context.Context, params GetUserProfileAudiosP) (UserProfileAudios, error) { + req := NewRequest[UserProfileAudios]("getUserProfileAudios", params) + return req.DoWithContext(ctx, api) +} + // SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method. // See https://core.telegram.org/bots/api#setuseremojistatus type SetUserEmojiStatusP struct { @@ -46,6 +64,14 @@ func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) { return req.Do(api) } +// SetUserEmojiStatusWithContext is the context-aware variant of SetUserEmojiStatus. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setuseremojistatus +func (api *API) SetUserEmojiStatusWithContext(ctx context.Context, params SetUserEmojiStatusP) (bool, error) { + req := NewRequest[bool]("setUserEmojiStatus", params) + return req.DoWithContext(ctx, api) +} + // GetUserGiftsP holds parameters for the GetUserGifts method. // See https://core.telegram.org/bots/api#getusergifts type GetUserGiftsP struct { @@ -66,3 +92,11 @@ func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getUserGifts", params) return req.Do(api) } + +// GetUserGiftsWithContext is the context-aware variant of GetUserGifts. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getusergifts +func (api *API) GetUserGiftsWithContext(ctx context.Context, params GetUserGiftsP) (OwnedGifts, error) { + req := NewRequest[OwnedGifts]("getUserGifts", params) + return req.DoWithContext(ctx, api) +} diff --git a/utils/version.go b/utils/version.go index fec1438..f335c22 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-rc.3" + VersionString = "1.0.0-rc.4" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 3 + VersionBeta = 4 ) -- 2.54.0 From 2e14d8b5dfaa634f823086ee76c0d5a820be9708 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 23 Mar 2026 12:59:18 +0300 Subject: [PATCH 47/99] small close fix; logo in readme --- README.md | 2 ++ README_RU.md | 2 ++ assets/logo.jpg | Bin 0 -> 304504 bytes bot.go | 21 ++++++++++++++------- utils/version.go | 4 ++-- 5 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 assets/logo.jpg diff --git a/README.md b/README.md index 580c99b..8d594fd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Laniakea +![Laniakea](assets/logo.jpg) + [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) ![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) diff --git a/README_RU.md b/README_RU.md index 02b10cb..65a3c4a 100644 --- a/README_RU.md +++ b/README_RU.md @@ -1,5 +1,7 @@ # Laniakea +![Laniakea](assets/logo.jpg) + [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) ![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) diff --git a/assets/logo.jpg b/assets/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..58815eb419bd3c3a5051eab40908cd0a6b47b427 GIT binary patch literal 304504 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

_ zBwJ0~#Q2VVz^OyJxNB28is(}Gqr-S%`ILvuO@kkB@`FhbBSo^$_Z{aP_}mm6xtZLV z@%ZVNHHPr>3bD_3;g!#i{g{=FPuvxYY1{(SJ5MLnsn}^HY68iG#Ju9jCIrF1aTYxQ z#0wVuKvXB9{0nARM`?($>KJ$Un6pE9r6MZ>h3*#gT4o4v77L5+S=N=Ah&pBjS8H;? ztd?lu1F_E>%f@M!IaLzP<5YTs0Ncd~M!fQkC7KR6BAgr$wxTf63fn!xY^5mV_QzCj zFecN8Gp=~N{Kd1QIh*@Hznc$cywo?pRaY-B!?qXeh^E5o9l>!lBBlq-#_M{lA}9k| zVrP-*9A7mMMI4o2X7NRC8~R@j5!ycuieQG!uzHFCuRH!yz(z}dh{>{zTe$ZEU+hVJ zQ?8S%`-zI-cUPHvSyb`&C{%&xaPK$q7h;+?sZrgfb17*XyJysOK~&2Vg|@a+)ye|c zP$5+mk3Ld1dxtk$6D%T|59CcZX;fU@QNgl4tFSHE5Y4}ds=ORQrZXAcl~~C#lI2rp z7_i;5xnAcLk}V$2V#wfkGEkev_`#ZCi)zZp{4G+p@4|q_pTON$)wK|$$>Cdc9};n}RZsCcaq%%3;vX&XHVD1v#s_!DQs|i~wyyI6&bw|2 zR8Y$~)KEKVfxJp;;Q5V03T5aZ(h(E)Tvmxurx#B7nWIwo-b37@F?My z*t|*)ILOKI3uRN;?okSWEVQ>%TtN=Ox zp*Ah`Y%{PL6>-f;w85YYW&pzO^@sqRKx4lvooiPvn0p+1u4W(;U9CUDEqdbOeZyhp z1;0>Vh#>_aSfKUAu+-;(#gNhkxMe7D6+Mv5pqHA{nai-|#_kQ=1~z?nFmV0PVfO(? zEgVOY^tM*g8J>?GiF?KhU@rcV z&ZeV)0Y=V#WlX}e(Nd-{h>HZ|Idj*C{{WC$9xi*Iak0AaZYNlPT~JP9p;Y$@aDTae zkGWHzafw=jj^)aGI`Fx?9}@;;X4>NTL;y`&m0zlqhwwXs@&?pjq9+K^SygPtuHLh# z4poN(5oJ(!4YVe!nRkHVFJ6Hiao*CETZJr23oSl zWavF7P4?s;ny>ctB`!eE3SS&y0K3v(Kr_uCw4X z?0PHaQtB%y7kjyZySl}90GM4=yV+AAiC$09G8_rU`IQ7F zloG!43z)9NiKK9U%3lnQmJqIt4F_?q4p}xO-OqOrlx!^KVqRCDiE<4uX+9#i7S_(A zlm}tJfrTrl?3voZwt1BVs`iN%;fVT3>;*7?2O1ESW0q%2HY5k*AD|;BW;qGRJ<8st($S3<9n`6| zyiIBw*N$y>_Zv9T--m4?L8QE$OUyEu`~LtD!BZ!xa9U~J=QBJm+6Lo>qO@J>9sp{O zvNHN`&Bbyyu-vtR8b0FgVbshGY7MxbxsygK1RBmS*$kz%@2N|cRaVq5dk$tAYT%H# zOgFsAP0(Ana1~#4zNSO5#WkQ^=Wln^23*Tfxlq)`RdHiKGP}C#0W!57OiF?u_Z60n zu|0yE*%LTrWEqqs1D5m5r3r?8uvoX8JVeV1=IhkZAzpfjS_{N2XB@#nSkuxFEe<G(Wpwn)*1PR*SdN5frn)b%!Cwc$S@GN}mS4O~B7+sFR*8qo2-8t8aW&u8O|yK1_17(ieXv7B(@(k(65eN-(p#n)^|~ZJw-QHh&_(#oZj@Zw4S> zO8A45E}?B4wbBf%ej`V-iFZg@fzgB0e9k-aKMq1v+Z+); zAWNa>r6d$qV2KFhYyxDdO)*b-6(5KceHFyA)q*9b60N#D%Ye;DXSg<}!E&W+KxpnfS^;Ls>NT;{1HViQTUNm5{*x%XfwEHAb8p%> z<0FOt05Ho+SnR&NLjip}zR7k)_lpijt1hFHbbjg|A!%qQJ&NR?O{ke{v;co`NGrGe zkQ7iTFrx6$rVRGg&#oyeo9LBA!c)JZ_ z!9`w9rSyd*)9O2Oe*8Pws>yIMa`@stD{pYSSKNOyHJfJ|m78>Nbp63fhL}r_y3u9;Ha^ zs#|3dLRy-=Oplgoq6~1#Xv~1J^>ac3?1gxuiLQ%zUB+^bWk8@wPYaKoy)yybvZLjc zC`HJ+ZsuYN}{YMhro{-uAh0@I0DDceET)>v+d5-@8)C)Ww z<&-t}U%2d`buN{&^$b_DhUE>$s{@#Nk78zi^Kf_Z%9QofIk6e0X4O-Ty z2p!d_E!zHx9=?Ut*>24(P1qo4alvuSqJ&9HTs_N6Ljx>cNaimr&H^!u>H~=^FUPlOE76vK&5C#$mf#@i!d4=Mvj1p9QV0EKO%T$DP^2%XNG* znYLK_n9;@_9YpgAE&Qi#>Y^c8eXyNf)32DNcnz#b11+4&C}OuTa+B9HCj);HmuxQB zrt7Hvp@}-KW?;`x^ntfN%#zI_c^j!i*kWXxR~egxXfhd@%W~T)<#~Q1l%)CPoc{n4 zoWQHV`kRf471}N=HAPAt%gZVfylMTXHnE5?%F6yHYk)dp6G_Ru%7V%?RWOY}!Zyn? z)^F4EH|qC+D7Pg?Uu3n_Z7?3;O}uelpk6`TwZhG#@h=s}ai~Jo4o{i0o;GIYf-1hh z^AQfr2N&*SdTwLQOQm~6*DOL7$6PfnFmVQKAJoPQ_YLlekokDo8mwozle)|4E}Tom zuDZ-+H8TGI;$u7Y=YL)=PZ#6w#ldQcStkT7=dT#Q4zc-{k8>*$%6t&29|`ve*BbXK z+A``9$@|1oCTln=@eo^4W(w^?i{zIqpg_vY+_^!)tRIPylJ>IWd{%wNhd4}wMOD@q z6FTayeZb1C@jn2C%?h{DPzV;WO#QKFAXB%tR><`A{{Y!W^a@qWbupwjw&9}S9ManO zfnwb;GQ1nhI;3=I16|?W`G;nyEw&~{I?i_sY!5_8AK-o=l~`cX-l`FgxtVg5{{ZU{ zUMspY4&;AOVYSM1H}JZsys=5Wmb9-DkCGa5DyF*?OwZeVqf|u1u>8t{1K3-+e0VMN zAOU1oPt3KfxZy0^qEsqVBfE;a)X>ZyOj*kxS@jG+pZUzPv%XVBe~i{DscuMcWOF=q=$j->!zR5sCpVCuE%2pYIvMVQlU}kW>bMS1YIzfwkqD z>KRZPyg#UbfN@OaJuitw-r5{o!Bm5W3%;dY#<*%z6v4a@Uhm41swlR$+*;FdmqOWd zQ5#>YZ<+8U7JuAnYv(kNH7YinMNjiEfU@PQj{sYx#2NudVeUEXZr{l)w5{GM3kKcP zaQy)b$HZP!vHdq2*~$@fL>~q_lp$onh$?YGRVQBO+^DXv68nW! z<5(e>?MRnV0fO)DAQt!?Ot!%;RdldR#J-b|ujqZjs#Mf(QnhUmivm&Y?ln+;5;?5B zw=MRHQ=94;(ckJa84X&DgnR;GR=*ZCQE(2TmReHp;V@EHS;=i|xzf0(SX6_$N(dRb7-{n>!abGusX(*krI3!G6%=)XGxUthcQUwai;9OrqeP{xXAD`` zr#C23-Xn7lBNc6@@QuZOVhiWRORcM*HQ!eJ!A=S-ffm47Vn8SD%Tk0lajtxr~ zIsv_V>NlxWZ6^lPuGN|zl*;YE;$e{j-^t>^egTn+R?GEiOi)gaZ<%! zF?ikZ?1=|K#mWtBVE&Sy=tqj1MHzBjIOCGj5VX35s#DZT$U{xOV}Y|i^F7ND-4^^2 zM!z9ZZ_VvpKxkJ%6}|+cSZceLa7BB?Gg{=%RyE&oHdQ`Yk7YQND9N`lhOpwfK>q*{ z5At|aTB9xBs3Pu@1R^ek5ZbbK#6pGD?5VlpixkFD2PHc|DPh3FTP$*+Oq#9tf@Z|4 zo7!4v3mVxe+w@g2WuCdlZH+EpGLo}9;$DCfj2`nSS*3?Nm9P9{2~dg*DQnpgB1)Gm zJGD-TAz{rxeFoS4#gAvy`i_jgrfa?GR!AISD)wJjF5_+x=2$FoX>|QcxtI}5g~+ZskFI3^YohlXc&w%kRUCfc^eVwBBGY8zJ8JUqB}ZH^)xCF_p8|I5m-%4O6Mizt2&g0!+V%ll@_xL zdfe1iGPKMcH)x5*#`C#`9m_q&qL){ephl`GzlHrdjmb&hxaz~6S%R;!J2?(xV@2Uy zML`;B#>np;M>a;Qth_(h@Z-b$D)!m%2~L`HoDR(eV*J<;RUX zm%XzOoBEWftjgdC-wAD?) zNX_NfO$4g%6`FiED%EGJ6g~=pW7*PFsYhm_ZwYXP2l)s-t$oQbKOaBb5@$-v#KkYU z@}MQa&B7Kk`%Ie66j4*uN*ofRe`v0Vf=Hd!M%*8QY#)a86Q-WL-}5bT`iq!xe89eD zKivD7t8eGUMfWS*;%mvECe_R&K_;(Befa7jx_gVV`LxZ1^mi#(ex)3oy5d;T)?vG) z)>ajea!*;fMN<;0oHq>$GnVY+n@Zj!bnYc>yk>8?C=O-cmdTHae)&#NTcXFrzb_+6 zywXe!B(~pBzo@0deOA3gTfeDvfkxJDHKV}vh$%7nOSH-ETjm>D{l~~<$Y<_1S#|B} z)XW_8>Sy@%e`wOX95NDB%v0_*+CifI&KZ*>MnL+Z62kSEuamn=O#`)4FJ0#Y{X_~# zrY-%Ew6-w1{{Xf%*Omxa3 zzF9#nvzpw+q0Am?11DHLTi+C z?r>M%@Z)|@i}7E>F9jFvF^)*?!nr3stj{=Jqo2_gxH_*GQCAe)4#|gFsA8i1&vNi% znY(R+u~;`odiQZp$5lJIM)INoVR2YR#V*$6E8mR`-!o#5z{G``xCP-aex^<`n3n~r zL9?yzx71Z`!KI0+uW)6%*;3%dXxJj9 z0gf4GJ8QQ;bqPQiDT|`-xE|fyC@0~C4}L2bL#66C3P(x)WeqS-P&GE`-4f#AC}nES zzY`l{z#Dv9g$FPEaVUy{^byN~Ufo?ZK+~r{+WzIh7pg9HAV8QQE2I zlK|y>5BCBL0R|PmL#Vq@;0Mfd!e~AF@c#fB#~ARz&Us@Lz&HiDxCNFSh9||rWM)am zJ;!^*S?H!6x6FSH7tU4f4;2UI{AW_3-R5YAIfI)^`IpuM8)8XEqp7`pWo|eoTpSsz zn;MAp8`+oK&})UYD`C$E6%ZJX2H?r=A0q7fdWJbo)vttCv)IMUnTD?778h_9I8a>| z2&}sqa(i>82GWOYNPKSLNBR+(&m*d1w|Z>>XV_zX4V#z-dOys>G~KlyMy=H{mPSIj zzNNv9BY60mlhJH?f~j&BIz}{%wgkpSH#D||f)TVWX=(}qfSh`O&8-V$_X~GCJd+vc zr-}anp+lHCO0vq0P~aA5$OS;-;wj6U8&lvf=W&iU{pTEV|4m3-!1 zfpDBa-st3=ly2MKjtiDGG)L)G;D`%%#2x$R!b7ZRB_7OX`<3OdtB+!>wV8^>*_})- zvFbjSUxZQi>v6-IWfNp3TdA?;Ac92Th`C@D*;xkXwXe~5S3J129{YRZ1 zr2;jDnfLjX*Se$Q#Vot`6z{u@42Jq)%%E8bmFR#z2q_W39^q(j+oBCi2mUHrk$aS^ zkf1saY?rDWbpzRYn1!RMl!ewrO2BNa8lP^W3)BzwE#UVKXMc^%nvAS=7F_w6GsW>K zZDDx6VYZJ*F4@Q8b1q_z;-)ZHJ5AL#+_tkW{9MQ=HN&WYTX)8p%LCCgmG(H5?%SS( zEs!qN5CK9J!0p++7GzZ~(qAqF#662zdzm4AqRF9M`k0}j(`?1j0p-j#D54lFygyM;!+D%58#FKg zXuhA?6_7_v({=hx$Qzm(O^i&cJ|jFH<%A{WbMxw0U~NVsNh3kbxpA<6#0tXxp53MlrXkjuewB zLMcgK6IE3W>@7S;aaf2Txmk9GJ|HyNM%cErVzJMP@m1@`G2nGDAUPg-m3jVJ3C^O~ zSpFPEQl)+P0D*It#uzDsdx`;}Oekb5R)5qs302~??i*+78jBb>J|*TQ@JF|pTwUbE zW$cYMYAt)Rh}BxVn03jXrw!H8&vVzZHraIM42;>bWTFm34%p7+xiIR{u~N*Fh@o4% zcts$icX2C_idGwZ9-;+j>a{OpB^LE6+sJuyEJl{Xz7ZwB;Pyv`bWwaqmMf`7j+wWZ zWuVwJ%@DMJIa;tsTqpgBf?2l_;x~Td!4}9{P&Pm>)8b^qaW7(3S8~#lZH-DrhSn;X zrlGPE23--SehA7%I+P(^5(|J1rQfUsAo2QW+3D2!eFoWF^C*svQhzEL2^HHY0>-OztfHPkrp?od*p zTuU6r+0f*cjOE^KiDk9waERO~3+6B~ySYtsgz*&=D%7G-+POYr0T%XxiX7o_$-5EK zz0I?tyg*}(?{N|;&}05`$#Rf&Ze`}|{ue5(yj@mTTH7}6V@>^nzF`z=qnUA{+FeQ^ zp?kQngC?6}jxB!QnukSB<|>%RHU9vKh;n;IF~A+ zb=0sca?LOr047|*uJ`i<*^Fl3U&nEoDt&PeHR3VE`+!w&!WQQ0uB8j3E(i>Y*;?NF zir0Bd$4+smb_Q+;@lmNuf{11jO{S|<%v?@Sa3_Gn)B|P4xXZ6|oJQzYMTQH9rNH}- z%kviZcLf>i~K(?sFLbV zXSOw><&qhLzvVvSo)$M0^W#y%@@ly8Y_W6qJx5xTKO2y6AT=h63A6QM8X?%XZeE-uSXCD%V~H@DeAfsoRGX|fdn@3PB@L4 z_zxwy7g~o3vsSbRxB-U;XEP@)3%;h=beg}Ja8+#XSaaZy872P!9lY`4vF<9| zq!+(YCrpXcxx{MrFL1J3`zjwWg*5Do&i5A@;i;fPX(sPsux$H=B|Cd#Sw|J8;vzcG zD<=9NwUn&Je=>{SIj9R*R{IcS2;8g#PGcCBXM+C#xpX5}x|!vwvz$d?=u9yz$ZOoi z3UsY!<|(;B9Loej5qG{L7W4Q)E#I#LM^DP)+cPe>o$&+$Pr-80N@0m!QHy~K*mxrG zdXsO&rO{4KS!vNgtA5d_peYh4vD<5lC2gFNt_6w1apqKY2EqWi!7uzGE@6zk5I(fR z|*rr^x4W(b*EnCidW z3eY#)#m|z`yNa!({{YWXVxqkfwHIol7>$Ynr?zEv8|X_J8F2#p2~t9WF?M2B6FEDJ zlOe6t!oJ1GLp+Uc;0rLUHWl9kJ0Nhxy7q%vQDiaxvF$>;r?k9ZX2@Qw(RyD}mP3U< z3+h(ot9tlf6iQMAhJX_B9fpnD5w?%0GH*AkfWU*d_c3ab$fhPO$}{daoCVfCrVh|w39x9p(cI6@;T+ApFs4iDX}77KkqDKd zvyAF;Xd=T&!JZmni{RmfwYxijXt^CMJGjhX;0_$j&PlTRg?_vM*yQo!6?dMdpcVYV z{KajhThys&7PM}+e!LKt29MeTp}4lZ6^*XOV;A4s@hNFfU)eC%>qTTL=Qw$tN*mdR z9RPA}Q^)RydxyXMq$&i?D;sb2> zmM~4%?n)$eb#p2d>_Hh|dzHbebsHUv#I>Y;82hrpcM97v3y9LL6r*lsYfw49SQyo| z>m!U_rXT2LE^HTxP$l9r{)#$;M6F1aKQV(m z=SVCn&T5D&Qskxpa>ZH(Ro@Pq)OT2d`|ber5oP;>W(Azv^D;|LLns7GU6etyWonpw zM6gBo8>ceS+cXzU8E& zNSTN51mIYEh}E|(GC70&L=d}IY-SmYt`Nn`{{Y12GPdw}xwmq?kR2h5zbvR)%gjLv z_D&gznpl}1xux;oE-!}?H0IsKENSio92;?|V0uX7k=mK61ZD&<%}`hXzj}|@(yJRw-jnn*k%4?sRI81<|E$~<;*is-+V)y=dV$S z+8=NS>f)aC)V1Rc^9(o6qRuP`euksDerqktFO^!ZBHGG|!eJuW$u_ZNvxZzIf}nE( zDBd8{w*6SVN~W5}f`jfI#@Nbtc;x;&d|+!O?h>C+;u{lAnPS_um2F~~2;v+yaBJ4= zwtA@9`HLR_4XxAfsMEpFe&d!$B&GVBWQ$9P{J`F3F^$Ar>S4mZVY^)ij^GQgnVl#c z1CkC2r`)peIp5Q$r#YhrbaMwU#OEzWEp6Q8QPGkv$whRFsb<4G%&y`Z1kNtyfJd@g zxg284fDvMXURhr#RKyWePLYMh$4tFFLn|rFqiM?v&r z5}s+VXtO{OJ|EmLyZo!>0bv)X1Je?f<_Zd7L2kH5m@|5r7hzuzxm{@HFas;Zs{(dg z)Neer#C^+iS%JjBaeIK?y~nVB69(6=xiJ zWjPGi^E9WN&#DwdjPo#Ufm>qOOKS@dU79TD#&x?$@8WYq9 z1tltd!Zi_X5Cf6f#JUOcDqO-I<4s)RCH<`f0z19X*Ww>n9a`X5MAzB zdYmzDwxx#V$nX|k3wt_?dVIs__HxSD*R~eQtfoFV{yFgZFzqNiW3r^AVT_PbxmA{l zTT+g$=QE9e5LGhd>%zm0dqBJ%D>q>%t@k&?8?8c8R0jk=!NVx0IsW{()=Prg;ygY? zy2~Wl+}+iE%(-T+?TbW(TzY7| zt_C__Ik!E{_BCpVuvVIEnM-KIs~weQe04GoR%a~lFrW#TQ+@{3^DC*Qei7;eZ8Tfh_rCm~HM}=Isg8MOHgPgIx=M6M84^ z3au>qWg+K^)G@)#N-E1)vI-DZY36cF>R~o5(}`0VwN%t4F$R|g>T|M{1AgO zyf^h5r7Ys$H$|nPreuX*MG)UimM&!UGA~eV3suT%amsU#!!78%DDA~tkS&H~V?ij^U;g4PzH8#u2{{RGDFYmy^4yOfz^TawW zP`$?$NlY=;yZ{D{-Y0LOAWio29aKeDp}_tn7c`C?soM*z-4h`|<=X=TYWQ64(8+p+ z*yAgRyiO{cjl2X{IbjOncbL3x979uhG&dE1C^cirRScU7vfd_NG*lqL+*Di&RJp^*9d$N(0*Y4 zbIel!%1FTxHK-^MDO~;|XKI@naP0$y(qI+uxX+~Q?Q52#iyVzZ1urB-IZMxzQ)_dY}&suJ8U7dOsh^(z9R z%G0};IS!a+InA%kboVZB!nQ{ClNBhgZdCsO5UzRZ8KQ{SZSk2+U1D%B!5M3V0ajL8 z{{S+qtXYO2iIJDYWSFX@7?(%PXJI*EnV~~453t`+R-H176kYE4iN@-&o?>dqqVF-X z>o%*2eH=^za?!E}iPd}{8CtWrZW7*LJqD=f+{0YQ74VsK8M1m|ZntP4u=p_$6wESS z6l*gH{DfIM$1F1?AIU8Ct3=ryRmZ8sLbMU0$54jMXjS8h)S)Q3Mp62XlDzaQw_!1PR!_`Kc7lqi<7$wC!l>wI*jQsH|F79c5+40XW zaD4k^5lUN;#-aFtDCql0OF-+ny|#?;5KRwVOas12bi0hJL-FyHyepr+FB7 zaF~(!XC22~EAChT61yZCoX%KGjZ`z*Ztec1=!2pN!Ga5xI+ji`4x;5|Z^g@uuH&hF zIGl0u0I(_Lm;iPP@$($-ykLM#t=lW(#{P(!dxki>@C7-~Qx*PYB{0fCZoPLqx$vhQ zz{e}3yZh>7K5-J7<1D2;Tr6zDW;Ay1VPQt?3k!GPGHm#kAySND?UbUgS-2ngoZinL z5M&BAoJ3H{%M#79%o}VjF%%<%#l+4v9c-4zQ%`PKOc2i>65R~XY%ckaM9iU!>UxOI z*@D?@dnak8u_|y;)Vop9%p6>~r~JmvxLadWlliG--r8!S+Im&7lIG^*-fDA7_=|jl zRNQ=kaJM@#XIEK^YTP+B@d>7uzR{D!ORuA;dS&UBDTXM9VVYF1()nX)chhS6i7L;` zYc6nO>R8y^UjETlSjp0W-#uaY#wky*iUTj_QmiPYTTNoI+3HZzZ#Tg&TE_9i2IDu| zi-`92kVYEjC}dog0hsHNET1U-yPutN{{ZudjKx`Eg7#AE{{X3mhVNO0GN&~xT?eVN zwxLJb`klg-BPY0N3)Cwod(SXKRbIN7u~s=90QP*3iMp6A zerNgdSoQouG?e%`^Yi0Ov3D6+R#9z0jESOC$>}?cbYO$^Q46ZcP&SazT~E{#;%+HT z^qAh$+4VC?S?Xgu)Z#AkHg0}XfyXff5wgEfC>LF7Ff5^2>T*zrDpVe&lLNC1HElu6 zQijQ~8?|qCul&YuYD>i|`|+`!b1Y3N#$H+5Dj*IUwLkkS-jB@5wkp*G%LV3aABctv z^vtBA)xWt*6VPahL1zN+OKvS5{ue=@b1BjJfC@XP_A};gjeN!^C1Hh@$z6p-PNh0^ z+_LkF)*w@bFK`Vr!YjyN7#3Zk+{;$YUDt)e{{RxS;Sm-(C=1<@@mPk3LPOP(_;7_e zJ*NCP+%0z16JfKXxPgEf&yW{ny=~Oz-0Q(r7vR+bs-R;m10+*_(pm`R4->n860G2u zr}>THp!n+GSygd4uTto*dzV|zAntBB4l}_n^dD0r5i>$ODFsrD-Ogtdm|H`Z2(wI7 zP3G3ckgvQ{yds;!P%;t@vjV^HKrHgEE*oZTvls?-S&OK9B?y)d+WbHh``H!Mh6=<0 z!tBG;Ixg7cub!AK&k0}`m#Fl9<_Yr*v$Du!x;!wrjrTAb4BPvN6tdaW!eF(QM^>Vy zRc>NgOS@&4vheCS5A7Tqu5v#S5CL4u4Ia$gi$!WtYxH$63I<3Y^vpR7n}QYz>l{AiwB+MBmI4Ki34x2*C^ovy#;m`Y%<5ZJ$6g9;b=NRe!xYpmw3sh0 z=Q8qN>OaH3Ch|vkm)zs{ofrdu4R{S5D%-hga^&2xRCEnbbCu=+Z+U@alha6K>Gl#>5`lCArS`Dct*A;!t!X zA9Dhb76-ytdtesN;!mJyWe8I@8NlSi+x<*zDze-0Ih%ugFR5zFh^@V~xs#NtL@yGBEWr{Nu_@5KsGPm5N6bdTh6&=(8zqns= z=Scu4ilE{#U@PE`AZT|n%MBM^pBY@afhwq>WnS)GY!-{OoNiK6L^L{!W+2sKC2k|j z3_m`gHC#f>rxkGqnz=J7%Hn635WNJoc$UsgdxrJmurn*LRofS*)UbA&R}$p(?@)_{ zIF05EMcY1{Q6WxjBFCui$ak7WMIQKz+8n<`rlR;Au~T6zWUo*xiHgGInO5BQ8>RCH zhW;Z&0b8;!j=>e56&k07Wcr?_0+DVbelDTD!ns}`Utqlzj^BF~48$la>xfn>amUO(WiYLg9t7rKLx@+FxgfO|3q-?W2n$ta-g$<*6;bd*hl?UsdWk6Cy)v~5>R|OY?yeV2>CN*p z1;yXo+N?`ae1&s0O}X54DtDN0QP{-Ik#gU1t>o(9Lu*pKX!ABh0I1^(s^&ICw>$~) zH)WY#@LO=Q7oK3nrlH!UYT-*sc%4aqdwm`|1zZ#HLo2UCjyt)MM&3J`#!tIhd8MlTW#z zu@3VUn#|2=5c@BQx@&K8vCfcY&vuD!&$s^T!!H4sWZG_8l;;d6rY~{t3pw{2E`(-c z^X!r}yS)g8I&*skF?$RRLI)R!s3I_^@-Im}s`? zh(JQiuejWuZafC7cWw1kI_Uy{y(GJ6hgrn>bTBGi%PiuyUHXlK;nH*VqqvE>r5Y|+;_(nIlOo)?P`sJ4B&ru_Ecgpw{g3Kx zfDFpj^%mC4y++BCF1Et2F38fCo}o~I3V`ewlnm+ItwP$}=Y7L|q6XTyTprT`aFzt` zwMT7gRK0qf&f;byKtj{M;@2O;h}J4>00FzQE#31S3>;s+rBBiLl(%LsI@HUM^953s zdYM;f#j?KVCMeoeN7PhS%Ilclf*Vsn&Brs>H-BV76AlYC@g3rK9_RHhIPhyHGyFjhiDn6HQ^d5G zh(IXv&959irJzKFiJZ)h&iFc9Lw89xq#&J(ppuHn6GvK*R%RiSFlUsCXTa)SQ=%oepx>h3rT zz6o{;ja(02;=qEm?jtFuFy40`?E=%Du9B1h%(5M=;p!9?9KFGzjiMi<3G}~AHr>pv zOZS&^CIA=GW%A-V8u^r%&MmJ^ zSl%Gg$klr)TXz=DOPhhqOSwcE+ZW8?G8s8auTt?XmBO$5JT3}2rXJ?SxSJX?Qt-=+ z3%lU)c>WK+Ai6vvzF}0|-w`ag4``4uk>Xy9XqWhld_xkxP_pUXB?_uOi!&UYn2ip! zMy+TRYAvS@$#Ko-8qaVyCp9Hv(Un#9Tm(fSK{H#YCiRHyhCVHUoil{EWIxJ zW=tF3{7z=w)_3l5Tp=7~UXp~i6uWT+k>805SKzbmOAD=3$TnB)F|PCs!~T)u!zuyk zE2wzMxnYe}P)1~o?pa>r`-OaALwQM-stk#c516x=h^Qa;mTM z1+b^$Ckd|R8Fh1bn-s5sfsXcIWd@@R{VFP4YSPNeT$19M6s=TZfpuD+a7$g^62e<7 z#x@q)j`L0>!Z`)4^$fM{wHl_C@laKY7XE|_Xkd;@8ll~^5IAutThJ`9q^lwQ&1{z~ zw=uw_t7KeYWcGTBY$?=s8CGIbw}lEnGKGpSSEz|FMygd>_GUiR)5;HA`(M!xZCISk zf*BKX{*a7d+;J(a`;lv9vhD3{^hw_YrlR8ZJ1?9xW z5qJ?$iq$u;8{Djl+koN-s|BY5K%65lSN{NTX<44-`q;DU8JiUrcg#ga6r-2H)ZWLC zm0rEXmwNVwPA;Rig1)6D-J=5&E0BZgEZ=Zg*5bi`UghIc3>Pk?uGvsCODY4<4s>GP z`iW4L!1BimQJn4-$k&X_6Jh+Hz>7O=*K?0t#;;i7eM{CLqJL>0IjcI2=NF;WYtm}>UJ!9x+zSH7w{ww3dzn=yQxkBXA^y^22$?2*YNaMBs#-Nl zTphv;Ibt8K8+teOJ`BhCl!Y$s=|#mT}ys`D_#)a)CfDIebj+@c#f5M57}Bvp`J0sp+Xp#z;Fi_bU(<2KBfR z9dyLR8`g+qUYs(l{{ZTScsQ&Pp?{?3iA!4nsB+xK>7(qQkdz&mjjip9H!7Po=ZS%< z-6RZ@xJuQtq9W5hL6zrGXM}e7m@D^i+?Ko!Ad6lyT^j!L(3XVCsTvV#A82YeYt1PM z?HvFTyMOgc)NnCQW01=E-NJU(!%@&ZrV1{fv{MV%LtiD>%$gbIS_l@c#JXKMQ%pw+ zvUeQC&9j&-_tZzJIz${b@hd|yw)V;IT^Le-#z6$X*w8ZKz(scJ%nvOU3TZtuLY zrQVl_S7&jyp38H5YGzREoP$v{o5vFR{mm{Win;R;U{j2o=ZR|JLwe8VChJE&3|u2g zW<6f!o0dmik$knsycwASofuazx1hOEsl-+i*m`qyH01Kut^Oy9wa@AyVu9c;*u8n= zV?3s%ORgmW=r>(d+(j;pM}DSqW4uI;(FbD+R}i_Va9o>um^m&e{G9rfrqy#m9g>!t zJJu!{sN0mtWyQj!EM9N*EARYD)gL*XDP6+Mu3^x?^s?Co;N*>LoMU~ABO?2a;%E3=yaVyXC|y+iWtquNve# zg53)4IENg+snL@H=5K{?a{}EVTLik$!MIAZV;SORu!~ua&_-)m)YkRG(JAWpqSSe_ zineGyLt>UnJtY<_V_*3`Bb4r4nyb|3GNT8A&a*j{y8cP2((E`=1Ub@6<9``_vhM(`2dl*Ml0ZD^LdJUFNEA zKAuYrmyV{T-LCg@eO_e}fq1pl1_Up=!RXV;Xqk4!TEi1~gsYV+$Xh{is;0PZRH|VqmzQ04+F--+hn+B1p`C4t zJou<)NcdUIc6*L2QM=o~V)^PNIBpR)MMND)j}`^T%u|&7n81r0E=?MWkZ&tv=>bsEExfjz%D|}L zKM9l-hnzCZs&FNBS&&_OnFaEc%~iz{Coik0o0cPja8{rPUz9){6sb$5EL2@yd`f!^ z9?I@g6^sFR1JLR^)@}KPomXgO^o9Z z*-RCeKXHdbI3K4>u1r+6s`;CBF#iBh3@~Qz!eb|qTJe@~^ArN<*SIA(&v6hbH28PB z@T-v|9cS92MQleZg z)#C=PS*^}-&0S_-w6BT!JHrUO32 zt*o`vdX!bsj0C3KIB+tF4GV4wrtPEkE1VTv@c>bg?9?kUb|Ua+{6LMdGw%S-x z0l?sj9(lIrZs(?R_?HFM)Kr&-9t`4ZBlikchAZ(JY_#WbqdVoj&+YHQT+FCIqOsts z&*~;FnfR8~OPsXj@Rk(lxBmbGTsJCKC7y`!m`*j~{S6@R7~(8EK6D#1GUnT#ySV1t zonr)CxT$^JRbRP|CBKPIc%hq0#f((DkNu*0jw5pPg}^E+0?f*en_k$|Pb$MKTZSuZ zR?aL~@f#J}}HsI zLMjznz0GcA!0=4uCTDz>>VX{z&MyVg&lBW(h0akyqJnJCjng4~?t=8~&Jo*%36`9+RBr>FMAK z6qyeSf@Z8cs7k?6EF7as_AXb583gTN8oX+4xqlg{@OY~a4s93}JE^q!5)%nE+bXHc z@hd(86jFF5R}Z~Jl{G%Vu3~B*XfdqiZ(PBC zF-+OaYTTt8gx06WD-yNzBFwd7;#*tpGK1{mdKs^CXk~1r5p2zPAx)ge1~73Px;4pT zP3gGl1p>Y%!NX;D0h6}H)7mAUk-2ZPcf{cdcrAbf-R|`*w?F$(u_y~LrMXS-)DVk* zq!$1#`D3vh%)!>JEj}d^R&C=EIXLR$EZbpB8;ZYU22*9Z^$^PwbYzAVy|TqCV2w#? z;^nK5uHu7DUpawJ%=((}=P<~FB&lgulBMFP`S7NmYfYcP{{VogD=$+r#`d=z0iSR! z$_H~}82f_}6vGo3%kC5B$Vfl~gn_)DbE$wzxUYMuR^mP!VDS|H0JI!S+(lxqEV#Fv z9-`hbb(P_OIe;+Y3)d4MzZq@SV-?(Bg#srx%_0O({!8CJ;s+1RSm?ckDl4r$&qRLM zUWcR*xn*y!4PANgGo6;z#wrvpy6~%6boe|UBAC0v+XFas@p+6Gwiws%!2nvyrTyfg z0qvF`v>!1lOX>5+3kPfu>a;g13yRB?P_FFc@C;~NH;!1uzlW3#65#Vn1G@hJc*owJ z#tc^Mr#=lG3!chuJ}WVE@G1x?{{X{?P;^8nYyeo<<^7|sd=6(|6Q6Nd}OD}&=6ssBb1~8?Un&(4{%0@e4M%q zSjaN?(duUFgxN!Zs8iu_S*hFnvzWtkEW{!_=pd@QtYuiAasu#zijD-TEgq2MEU-*) z7ZFAvs+F?a>SeW1bu4#Bx}7f(ehQq+8hRpLqq3D15pL|3Xhz5)z+N1LOI*dA1}pyJ zHA2gQZbxc}stPiUS!o)Z>%}zkyvkMZS&3JEpd0NzC5Iu9!X;%|=MT7B+GMPz^{IgT z*{Fa&@ta|!W2Z4yoW3!i*N<0-IsX7~s*;~Czo@-qAMjODh$WDjkl}W%d2dp}2^3pHk*5@Mgli z1;wGS{9h0WgF!Z6=-U!V#f_}&FU?c+)nf*nYtx{niZJ0C^*Ck z0Ib5Lwx3Wqo()a6mmcOj@K;x^#DYruk>EzAoszG3hOdxy_h^zyG8;#*T)z^MT{OBOE} z`Hh12tdQ@8OId$MGKQmbdkMOIqNNpc2g26HcLali=(2qH@54D{#Ot3^>MgeIpFhW+ zQj4~Ha?Up~KVBMsk*FY8cD`fz@MZN3p;sFQ6?Wd`yf8sPZ&N}f)4_Ut;sCNMLgbHyIfk4&AYQWF6Kbf~N#vT}n)!8i|8Yyg5ct|8{ zcM7I6-*WK?#mK3%$q9=&K1%t+NGw&7vPZ!H8%hb8)Ae4w6gk~odX_Tc@GszlMG#X# zxafL5Co_3`?f3xAT7F=lu&APRgA(^TcxDX$0AGgFzH)w9a`14skPcDuAl3-nVB@&i z{2dbD%4fIm0fK@bL5NbGaz*N#uezLD%)%bOlmG{022ij6WWi1?wu zq9tLjJ+m>OHn(gW!p>r+CX!h;A+ce1C9=;9&gE!8K1O?nq;{yp{Zlfy%H!r#!dJPG zN7uPhJiKHj%NEexRCqH3Rb#U$WV7yJT6zBf1h}E{LRXdW%E1fft0O=drBTuc1j5)* zS4UB#Ry)jPYnM{fX<_j`0z0l#cl9$G=zv!9@h*&6d+;rR)k~tOwt(ELVT$?O#~v^B zcpHvc)sOpyaK}-lQj5Xk?sMl9MVtW@UJaF*S&k>%IAq6Fm{q_r9EStd-S~4p<$lps zS&Pcx*la5U6A@u+20ksBst{KDjDPHgExGf5^#jOQwXP@5S6sC$w-woA z8|9RAC{u=1EgEN^h&;Yn2z5S0A-FH(#*U8*u`NR@tHTm-Ywj*8be2!Yh9t>2XnaO; z3AdsjR1_W$)uY?oUjmL<_<+WNM}}m^2gJN24Q6%GZ@&U81=Y_-s6s@wyI6%kgJUeR z=1enJj4G@a{LRZUeeg^Ief0S7CDm|zdW#9qE)5?coZ-4e9B`-@R(vdk;}6|V@nQka6sae#w_kH2&ddeet_)4?##3mk1E_QE;i(^#%IEw6ibm$xV#Cj1LMN_NU`usfZ_OfWDMYZ$L{=7aKH^77UPHi z0DlY@%koAqUOI4LL%^La-9s*=?=YCZM7H>-5iAEK+3_D>S|=I|{P-f44SXNwK(bBg>^h65JGN?6h*^UP%j9=9vm z#bd>7XUB)s#4;qYD!n-lK+t$EKo$vaGn^ zhP8eS)<&yAOoEI6rtMD0^_xj|xv|AlsNWxpauhh6MaAW2o zt5)9xyo})ZCCd`zi-fN8lkQx^pKlBy%K)BJg$jqbKz;N>jdNl)b${zfM2y0ifS(Lo8`?aIoH^S>%?ee7TlhWf#q2I>3jF z*E8544@VJ}B&7tDM+*foS9;Whl%N(}ClopQV z7lG96gBY?3IF(5Py3AWR8?infJcZTq!~8E5#I3O!ycOy{K3D7_$w4Jj%w4ezg}A*+ zxrNwka_RAK9n1@bUnWQk6M;&OtEp3&kN)_XpzuT4 zh_=7+KN93X2W|0C;MI7RrPtzO&Am&mbuM?9xC$i$5iac()HE;MOk!7bEUP-|VlsD) zOXsWKa;Ggu7eY83%5J52H0@d;pT_=tWsmS<{0WQVJQXu{n|N9Z{f?zpr|3LqjNnbY zI6GQ}tx@9TFl8SV0m~^Kj};ETW8>FB1pv|F-@wzrru6Z0`8s&KGL7xlS7~+?W!GLW zykPK4S3U~T<^HA1CFS@_mh^JKro0utapw_dv%`mD@!=f{VTIAk4BdF>&Svk&1Ap)) zOV|eoem+zs+U<{u#Bi7IsI~=}15)8;`3sriio|9J@2QcDAPc7{_hLGf!1+WBD~!SI zrn8hTSR<6=gIfNfyKT%{8ui?M)Nrza#kGzqEzm%uD$Z(RwVvlbr3xThxG>>lu46N?=-fsY74#lj0;%`mOT!NH#vtSlEn->$(SASa440SnFO#^?>`8L2s#r4(KIN)hhD;UI;mp4&C89vQ`^8kYg>vE`1?=25jaE z5Ho0Yk;TD$$N+%iv5spn*Gt+w>fTT-M{un`J7L^nuwn1x;R@4w8dE;%z8(L60w5Ig?>t;`S#DB>k-7vl%mS}FMXT9@Tb zt|b%E;H0*Dk;HzyRb6D4E;4q%fLP+$b0tw$t1(kCxHBs~3^1`|xYR>cJkQ>8<6eBc z%Qb=fmlhg`CDk$>uWiN~YM?ZsM7is7rrZgz^%E-#moYDIr^1V5ZgZSx)8UzpQwRrTvkU;y%xrBx32-iLex>CeqQiOU(3_ku zxouFkvea&im6%yd3M=S@Q|a8u!|8H~fNv<>Iihngk^@Q2(>ol)n;MSR0H?BEn6&$w zFg2aa_!=A+=b;BV$KwX<}>Sf^a-2XVbYftZTjFuF@g9U`vRna(`9&VQ4B9|3Oa6?xQ9&CR9^?q5@%8;MwBS3_{h*x#(M zOF&ti`Q`-Bw9dHm)+JpgHmz9O3_HXf#HYvR8hlV@!SL!_W(m9SOx-+7nje^)Y)nM? zE(sN-xt)s@=GoH4kJ9?hA;!eP(cU!ZM@KD)d5153>fv z-RC$@h^RJ$_byzxKHzopah*SCLsj%QFGryzM4u`T`G->F$Hn@QTHF0bqPRZ%3bUx< zk4B+`+S@*AKD;RhDM#E{nCHi8A_Xk)t0Z=dRSkzy!O-X85m8!5!ebBZmX`+9_7v7wmxMcN44Q;L*bTn!>G1eKXT>E$O%;9J~Kv4Ju=`ydkM>x;Knld7tFo4 z;d0=Zk6gf?ljWBk9G3t-dFd~~x4@$|_&SOQL96i6E}@Yc+Q*O$ns+gL%B##m#2D=_ zPR2dKnkZ^w%st9LwX+964eZC7;9NPTNen!mvsCvL1?J!rP`8C%<@4iWq`kEsG2oP0 z&?`}r)ZT!xRv8tg*!I{6IkwEa~-nGk<`Xlo7DS= z(8+VxrelG_czm+PdEbj@`0%ZH`+!=m!MDy8)D_?~!Gz2BC7(ub<|*u95EN&p zs31|b(H#6D0K2`+jfl3p;o4G=Qt2lfep7Zhx)AJO!GTxG?rXz3x3rdoUZ7Q%$tjCo zu@PH}E(63EN$ zFmtPltYI~a?GC{B5ldC=i!3Rk*}I_1x$zgatKW(MIvM`}-Jku~5;VT$ZN{ebL8wWP z(#wj3oJ3!Z?`e$Q*}}rCRbErR^hm;N#C89Rgz(c;Eo*=lBVg2h^o)!GhS; z6yI`y?sL>im%a(x<$^A`E5!u6P`pcr3+%t)yYJ5t{lnbH_`KArWoBUktr?6|)e$6C zh7yi1s8iCT=6&IDjji_=9_E%ymL8F#ZhC55)eU@3M;l^OhDw!|yS@6=~s+@P?%K$EmurFT&Wdlj(vC=VN8 zdsC)8EORl&Z*GpIx#FnarYyUa#dnfq=W)s+Y@~}}VgPL%V1VF1KH_sNJvZ)N@EfO5 z$>d+ZiTuLLTSQ7iE*HeM@Hsw?h%$50)Tt8m)>r5CxCQB{mh+6oNIgTu_A z7{{r{9s=Q@foReCIh@0PPf-TPK3k{-g1ySLvg*M90CL{SQMZ2*_=mY-oplrf~q5(l(3C3nimuRfTdoKjBAypUJZ7=e%f3(^#x?tjHs$*{rVA4U@9(j#e z{L}riMe;Fb@6@#=>4YMD{?TbO8o__z&xxh@v|vPS0oDsWaFn6%D*W^>dSjsksg z6_VF+sugq|bf`TVg)GdNZlYq&v*ln^(Cfz#%TXE>Y1FNabLw6z_z{!9>G=@mZDdl>&8FL|UZqfvlT~+dEVUNZ-S-&~$~Pnn;YxI>SbYY%)uO#YdGyhVaJ&M_%^*A+7GJP`ia=?5_dYOF#CFNm#v znSdkI3a>DXmv=2H;?~|dM(1WeAt-XO8vfVnUhrl)c|I--`MGlC_`eGRm}BPP#3mH< zGMN7W@FkE(reE?m8E!ujGR39VL?@fE<(J%K7;ow!3e39ko_2{rexw& zJ(oF|I_ap#uJtR$P&Zr!)YYr9Xb);~y10yRdxJrj-w@v~JTo-XzOEpY6IN`f?Qh(^ z7}ZjeMTr&ay~6;OIoqUL0=a4}Rt$dNrTdj)q1pHT0jeNbi4FtFt6hp1%j1tGW{0#%&Z(khhC+p%>lHp>Tm#E|2iAoz7-nu6F_BCUn^b` zW3}KZ0^HI^7nqZ3VqJ6~o3_^(k0i5p z3}`t+2dI<(0B_FC%yMov-SZF3A{Bg>5EyM$cE@<}mp{zOvClHJV3m!_W=L&-T~gNb z_?1qs5xIbvxR+kyMi!Zv0={ltvx!6eFdT(sFg{Yh_4uUKp}~KDF~%cX#xaOB;aQc$ zPltpB2;0e8jc6turH1~XP@Tckqe7pQ6^g5^pi_e^pg+)!+>t`Y1~SA0Sn$(CNJ(ILx}88?p!_3h_5*KB7(JHu6l;?`-!`}`XYc;3|lrtkEv~L z8#$B#BzF>X*)NRJ+K3ms;%s)zE1FqHsm|drLb)%QZ5o9Xj&i}RPyN3P5k}a>qUDH} zp@CClP6OstOycEH<1DcJS;yRH*LB8i1SU>-ff1aFUV3wvc?xM28;;whHLiE;$&ZXjyz{+VIYqd{S9 zMAXN14GhaP%KS=OEdHT=i&bVhJRymYdN?<7mN+#AION|j%Kq`ssmXGd4j5-!HMj~! zhC-XFFu9`+Ahlm}4x3xgP*IpO2))MpM^+ThBY(K26;1Qp3bzu<%4hJ4H4H7y8oNrm zhdGVa)aE{;O{XHC;N#C5{upD%8(rzt&C5IsAu_-${2H6)j5YTbg+4-E6pZuCIJiuY zI+O#cUgaTX3wmM$r>@)nlN0whzs$*e!a4&?Q}9_$z+;#Lh5`cD*MbCv)tmW4{E7(+ zHnsN_-=7xZUW-~v6hJ}y9}_25K+s1shcp8G(!#nI4Xk`Rn46or$wkf%`j(c-j?&eD z;JX*fHxj|u1m=A4EVpkJsM%T<)V%z}@Z4KPsh9{^RDa?V zv^pI?vjatLY+Ih=riR8MS*qR3;98idDy@T)k5j3P=3bq_Hl+b@Tb6QAz7C?qN{zr> zK#vrj8JGdlGP_@2!Twdxm=GO-#Wynvx1s>-EtjY;irW!(;6N2u<_EGFgq0G*kj610 zblfy5Tnfu8q7ixvDk>Sdmx|uvkQy(kN;$-Ii&4{>za-_X)L~{YnN?aJ&?+WU5 zWoiC=f6%FLiCOm)YB?=}at=G3#A^79v0qFwDbt2J<9qH@>-Q1$)y&xIGWC2zZQ<%7 z#+8|amcPs^%UmFpa?T*UNig}?ZW+C?>TNMm8%7VYKI0RWNr$OwZ@#0yq3%B*yffnj zR+>|}it)WOXFoByaFr7rL87}^ViwpD*7v)EQt>Q^F2)yPX_$Lwsca(HrMiGR9z;(c zR&!BvEzmpn5i+pw;N9~QPf8~gMRgL9n!NL)^B^ z1rhQ%-}f_7e{eURa|lEsW2Aq6F*~@aS*B#Ka}-z7SScmmipFj6k=0O`VON-8)tyF_ zr5CRp;ze@lguLsT@I7|nwPA)^fn}QYDbW)wJw&J|bg;tJauxZQi2&3K9Yk`$Xj7Vm zL9WgrioqdEiWMDEG+5vL_G5Ac2lenF)+-Y3v z+$&@uiU8!!*^#$hKpR=yu{88|6$3ER>M$yV{{U+fI|EDoM#*T=1{^SjcM2)Q(ULWQ zw#ZcnF5<~Wl5?2MI)b{Fm3t~$nVH7sWfw)`schT!h}^Y!#4VgS;t?-3+!n4gq9t&h z&4?eR{6k0rH+-_8$}S>)poZX!*IE87Y>2K8OaZ0i#w$_s>))Jz=2r8mRkmA&ORY*K zH3K|8V^#JS91cOsoSGQtm_Bd)M9}L)wtVKMxF6*!c_+g^`6fszVcZ;+4RFohsFlx) zULgoHuiRheI8@)cV??0kP%U?NK{FUAe~e0Lh2-P)D-!~@j=P#X+ZU@kio#*{)NXj} zVc(;vYMeVFvWl!CR~xzYKlYXubdIB?Tj7p{)lO_x0*fZf<|TqdP+uXmD*h*_LH_&j zd=<#E8K{&sg0&m{)cn3BT>|3l)^#}CyvN99^D@bho~7O+(mJz^w-*4bDMW0sa<@cb zjf-KqVGeyuu0YtV#0vn#H<@11ErppE<`u}Y&fjRJR6`how;)zezZxJttRgBoXA=g` zk}12^BHh*p68FCWXnb`tERhvZK;zQnuWSybt)BO`Yxpy$+q_t;pzcq zx`zQ5ArHhjO8P{p3%qIo&^v)x+rA>(KuR?zseOhZ>?xKAl9WBDH~32;5u4^AuId|4 zCqV-RhfHT_WV}mBTKSI?1eUPn_c*0^?Hhq`*5I6-@zf*CS}il;C5On|1>H%mlN1-( z;#i<_<-`k7rmrs8EHlEnh%8x3?o`;)WcJ63uI-|uSXATvrCwKTdP=bucW)DP&rQE^ z?55FJeZETOqS>Y$Ur^?A5E;)<2%u$51cO>xcnUEM+~AT1Uyh{(Dzr*+=-A>t%WE;5 zB4C-Gma){bmg`EcCj8h3cxJ@7s;3uq74|#CrHKm|r2zSr=NPQ~#l=i`qvmj$&E{{c z!>g%t<{6c`3j34;4rW#qUu>|xI-&)%wVlkCp>IINq0{CK8ZW;NF>_>U&DI|iIA$(# z97>p3d&@QnuX44h-o^DByJ#XWtU{rr)Tkeje{-gIQ3cr0adlFKp_OpPAcZWKI?Tm- zzDx?5JGg+?`byf*^)%;wK;jFLzmDKjk?tvhj#BsJOsTEd$_w!IDmie1&4xLFn2b`f zoZYdP3b8fMB4sIfDDaKr$GuCWLM0;d!BcSC)b_Au=F~%c7ZwF#xtDgieqnVAWLo>w z3I?z4YRqN>o```LW-&KnR^b^#n-Bj0Cliy@rwcrh>6o&oJ`6g!#AbrTw?d=iMGyy6oV zh!Ss{=!h%Ja|WBZ9M+ck#Zak0>YAuUnlDrF3`3(i=4D`da7wi&tH*a6Fg`hyt~WXr zF5aPt6C8x0>gK=$Y=ucbHNXEm!-U3O_9F!=ZWi3^|4jIB@u!jG|N# zV;9G`%cD~)7Ex>Y5|}J2!oEF07RTWWWu`qbyd}2ke6Bv>xHT~P$LIMLEYrXnjxkO1 zY&PanXc;fUok2n=iqY8|4qrCqB zDq?9Nl)$shEy`l=72?d+2}kXhLoHa`taak{b$Z*a!tsu`MJ?Fx|OpxQLA zJP5X5d`xL>j5OWG#R83-%DmsyXu;U{=VaQ!L%3nz%*S!^R^+6zwRhT5$@pfDH_TU| zfL8ComV&%_xx)^a{$sp16xpd#FX-mHg`Y#C;fU0&HnM&1|Hv7*uT;#p2* zPVNSbJzQ&vZcVWF7I_`Y@<&3xpi&!cZ`7_|acBUeDzokdWUlo;6N3JId`w|0hAeml z^%yuk!xxKsl!ONeYoBrd0HJdelAiFvafhg`MC}{g&bo!Y(Oknjj~EXTJ{;}bQv&92 zqQ{8I(rvYgwUqqGnA9uBL7PNqcw!i_#f5cL4hdnR|_jrU``9<|dZuiNN%GOU|R{n42qx3joNNAkxUI zF&^P{aWJ$=4cI=?Ypl;iUZEfvUa z<*UJis+5I|D`-K8MXw_I;&)>4)ZALva+!y3@|U+5TWmrC7Ih6|o%}*sdTEw6rQvyn z#*DRT)MP{j9}qNEFbh}xP9gYsgSh4GFjR=}${gRxDQdynY`WNI0(hYrsrYE_%}v1+LQlW>AK@oP*wA zUZe|-aWEH7AYBl&VW=TqoR-hj7@Usf;6YuHrp)w|{B}e;in;@sk|7WXQEsAOBx3&n z%9v7<=5rO*jqwn+h%2odV5CF2v_z~eJQOCj#oWqN)%QpnoiD@yowXUjmefyRw zpCzs)WB5z1w=1WnEtD6>Q3FQdg6M=EM_bpIm zv@t#d47ClFhM9e{IhBj7T%wla^XG^+%eYt*wQc^or3650Bp zmYC0Q7fl>B0c1bb)U&}oL;;`riU;N(h-~jtm~6Zqz?=_oPr*#ijZ<&T3n|3X zYWbZE;hElC0qnOv$=kaSA)hMT+@#3B}<$l_&~~Se~}%qYPn`@tJF%G zJGo9V8og9ayd;dUarY2T1-XI+F{G%6oSHj`!c@7$SBluZ%R4Jsj7_-X9a6?FOLrP0_fQzha9khM4g*NC zft6gyGrj?qJB3KODT?`vHtGKW%4i@M!HAYLh2fOYkm0x{v(c4v9b%TjOY+290|w%g zo&3ZhOU=cMpO_KH<{^z^yO(N?aaoiTFy$bSe3HfrmfQVHTy3zTy}6mS3Y2F4Vi_Ao z*JFH{Qdr_)9)PHaPGeeijvrAPEC_Izwb(^&3Y7*L9Q%siKBF*d&08ejC9ga~a_@5| z2i_xiD#O&TW&v2Jlpy?dE;=$+gxsk@g*9Z|Xr)wt;in~g@wRctgiJq)jQ+oa--Os& z?TV#V_QkgG_=*ErFMc?mzZl}as$(q~&^m+Ingz!|{Z3?G2*ykp`RZ}o>HtL8EK5W8 z=N}{KP@=c1fbUD7(KE?Kzo^}_J71`9)WKNXWh&HE%GIt3Oh@I4i`FZr%-yp5@fxOb zEZnze9Q6@E`PZe+Ak2Gb|Dyn7m~S?6NUj|Rh(!(YpQ-z>Iuj{MNc*nT|IUMo0( zD7_cb70jVGRaT2MI@x=iRY4;)e6QkJvxHrFzPwg(6_a-hl>>8=BJmLXsI{4#Rr0~u zSK1C6ojHy=yXcGr8sY#jH*Vp09&IA&tP-t&z9j%qLIRuTGY2&5FQq0SVf4RW+NrJ*e zm3HnI5mmNfHB&5&p5mT{>)cnbrdZ>Jr^UsRs}uDSD>`Z|Nn-;Es=Fp*SCC;Aazan$ zc!Jh|mo+ui`(4HisGI7)(sG7%?jTrgY_M0V@3Sm$=*#rmNxG%75~L}bq;oB|-Q)O2 zd6+C#vyCw9IWl)FT0=Gwz@;B3Dw?-i*ThZh%t8MEx(HJWQ;B?W9)}R70_6+Dd}Tn2 zrb_-)<~}NM=Z`gr5O31_Q&+?bV&0)yIrvJMWsP1rVW$o0l&a%6rWOWQZX2DJ z2A<^?kX{*O>8@DOYa_1U2-H@>wGdzsvA}zc$hzo{jh&#nv(ovQ2)CLYYO# z;-<883zuxssM;G#M=TCgf-7kytlYZgDj|%lKy2W?}TKMGH>%90YoejSRq#HN-L;aXK{8WK+BD7A(u+yv20AgfjAU zOV||r#03Ha1CAyt;qDWlXfA&fBNpH!q;NTat95@eprbg{0vDml)S#}@q7Uzwjg2R3 zEC===Y~H0lQzmmRaUZm>e8P=FVfAp#Wu?(>V)!Eyf{jDA@UAA$t{Ry*M0cq3mdrs8 zCdJ_;9US)=PH4k=i&_GTeap8eqs#;kKnNUjWT)0=C^fTn5fWV(58S~1fk);lVk-?m zT!0a0+*D^8qMR3fMRR)QrJk16%b6mNEG-fR2DdZ(IJ?hL(A{of>%VfyX4pE4!{i#9 zqTqh4t4r}yT6g3v@mSpViN}=thBFbi292fL>#1erQ>%%q9I(b2xzxLLyrOPjE0ZxP zZGFUJFnC75yI~#;I=-P>e^7%KTo0(1XIYf);5+FoqTu2rq-Qr%Al_eBIgFfJHZ-*2 zvGWB`rm<%+;0khc0E09f@9pkKMz zonWoUbud*ohFMqP#IO*)MmT+L5Hw2Hw1NIjsf1VggBn%G9)3xA1tw)=joSy*`MaBC ztWH17G(oK`{32GS((& z8oXV5@ohatZH0P@*-y-~b43`gW-T`aG{?15{L{Exm#iW73<7bRNi>annOGOZ0NTd8 z?J)}eB3M}YYGuH<46EiZne#1Z5Gxb?Ba=Fov+T|_FKr!@D&Ix9n7q772VS|KJW$li z1Yuh~V?xV_mkrvu?`@UJ1?6!xXD_B_bCVwC(|%e(GftF1tyIZH6PG-eX2 z)s@nrv{>K%TY<~~pg>>0?Cvz}zCI!@_#oLasY-LLL*^pN8OL()S_2Qv!WAzQ)2%!A zErt7wZPL#POsJoTYC7ZU<>p*Aj+8q0E-jMr05mu5ExDi9i~a`bY9BL*fky0#yjw*% zzXelw>*iX*HF%8dQ*8+Ph$v%Eu|5yn5L*}#!)y_umJz>kql=VK{vIV34rm5vLa5En zr=%bn#k9x7ne&@wS06I!Y#Q1TPUKm^EY(n!y2#m#U&Q-CrLzJJhmy#%tvD1#^nOr< zG`QW&A#5{uX9)lT=fr^C4Djl|U9BvgB;^2U1GbZyg!k*zl3y3B|-sL@h zN$HrBvfmRpV|fnhYnufB09_RPlU^Pa@9<0h{WTe*wf&JaELr`?k(2v`>A6^O755fU z8V54r)oyBBxkxUBIJs~7p%N3mHu*B01h)yiW(6n+U$hd=e>;uZxu7agz*il^K6fiY zW9)+;+J$Vi%*06AlM!dk0HwHt!k09P&5+Lbh?;pqHv;*Viaxk{gzUkQ=dNbjnUzuH zn)=)~pxCL#we>8q*wL8JvCL*C01G0|epTxq0grC=Fg(hzP*#$j#|xO-6*UZuXo zH3lHtSsxPhva~aEqTbi;7VNI|GkE_1R1fLcJPd%lw!8(qs2b7kHG(uPm5Dilon%gXtY`c72n-jmP zI1L=~$JATgP&Nae;KMhLCC9b}+(70!y)*MKxb2;P3Mi-N*MwRY%d8ho-}B3fkhL;a zyjNM6A5uWZS;6gyg;7(>F6J7jrwn|{pklQ59YMUcEEa9by+z6DSo0Vxv8(OsG70wM z+{X$r!I~ITi-5|kZeAm-1v*Y+ZtAvnf4E25G3pVDz@wm5Z=HX*+Kar2;wFL?Ao}gPwf$# zToqE%WHlD(A1pF(3}BY+uTTpS^F?HQrbA#+%(zmIGbYcOT@7~BqNhNH4IAf(OBcY9 zJ8f2+BBO0U0@1@TCfCVOe#dVyy9LH{S?*Hljs{vx@Hma?(>3u2DfhDd5Ytwk(YNeC zm*!YMD-gsuYEb0VEgvb)C%0$j4VhbixiPHhg3VCR+5BH@x+;DN@Fqszs7l4e@n)HL zs`fM%{Hg_Io7ADH+4_RPSOYVk;y8~t!1pg6=MZX2d6pMe@e*A)H}?o=4{O^wWACU{ z=+dg%nIux$Evb0X^AQ%;m??TRCS`!mDY=yS0rf2Qztm=>MLVj@Dkgt0jK6ciEI}yM zUSYv*1X`lgOgH$JZZB$I0gLgr@2V&MBWDSZ98Z1tKRylTP&S%8a}WKP`d6uJg=dpK zIq}Bj6|y3!5%(*?{=A!(W6CgU86$_OrJq^B5YVQ%ihUzccG?SRw*;{&uA7t#Ilrok zsQix};25-}<7BO%%T3g1?nAeDO-ZUI~beTU!D-FC>`TbEea_{4a))%w;Irti-di`I*?xWdn(Z;M=4^0&(kM zs0~(TT65gt)ZMayKzW$3`iW)*ir36ss$TUDNLv+L8*N;{MPMdlReenTQKn{4{mk1S zpXFCI;5wniOR+P}&MStIo8gH@RkPs*rGRyJ+V83UGZI$d!#0p+t=u(XeA$4mVz6*U zPGdw>NhM2ejukjW#Txf63>V%OLzYNjpwaGRxlnSj_D5^lVHxRIWyjW_qNMau6AD(; z&_)m7P(=^H9uQSt;wDjngeec0`UC~jz*=R8gjkd-dG1}6q#kIct|XGIBlrVAnPOsjtjPN1xJT{N^_7o zy2mi$%m8|X;v5J0pCO+Tv}X7iq8LY=R8FIoA|-aqpyuvcHmuxWgi#A7KUlKb4yG?R z^)1VSu{-kibgc0^T{8WjxL5B`DmRoRiobWCIfQpp70t}<-{n&)8+?!ZgaDU$H>1Eyl=(C6)l47S zNRjFHlOW{K?ezvs8WoF{BvGTAo0s^y{mtlT9g)jA+gOW`Dge@Zp3-96KA>0)@lean zEQjJ%N(;Qokl_&NA&9guZZQHK)YJN7>iGK*N){Yk#>Io1jweYi%QF+ z3>%?0j~tN8{1Y)#`!IK1wNV#lB`ZW_Mub$|cOHvlpP7uopQPl&)b)m-^Zobtu)W{ZU< zHz-TH{Y7O*mF9K@#t@fulouat83xeWx{mbinZYiP+HA;P`x$(fTEKI7nUJDRjRo`5`a1)^z)8Uc}4a?R%q zA-K)!1@02G%5*xb5iOk)C4;OrGCyX`Ych;4nYka}!|Fe{$)PJet1U%_pZ*^)oazNQ zIPNbC6>u9n<`_Ak_1vZNmLSP`{-DhbhMwZ4_b(;w=N7(1Jr(rGtaequHDi`ft{G^==(wKqCSUR)hYrn3h#Xnuy{p<}aImt2DA?rrae z^v0d5xsYKPFxy>XVH64*ueoBY!!z8B_*}NzAD-qoc9+OzP(#trbGVAD8jhANSTscL zy+!~coMi&R4L!xmtIUo&P4#8Jjfczo@p*SK&PR}(D2J7%Jf^9^pE zst#oa7jLO|0c#s~G8I-gIXA7aFPT*eX5&)II*)>vip9Xa>G^~8O+s=#r7=?H%*umv zinuG@OOi4O6cDUJ2e0EgsvLc$5K@sG+~Mg>8W}2Yae0J8O~xbHO242Ky1(IBNdbkIuldb z1v2MGs}U`(Op)Eqsdr|Gw@kV8f)>!s4^K=;fEQ`_OQhCRU;UT@F4*P8#HW#)f^OJ2 zU@n)qSbO2Pd{8YoulFpfUq2B!EwIvjnh)j;IkXFJQu6u*cyMm&y;NrtlWBd$+;h}0 z*b9}$<>VVmw`9n{(Q_4kq7-b2npY9k54p}`_4PQNyvph}B|y6S2=x^N7a?;WeH9qmWi`xS#9K8;-XrDczyrj)5L^sY`u(W6g^(xK~4b z5uU_4%-MGJ7KjvA<163&UM8l-j$^D2tHcL*EC~pW6@wPDoPtp{*HG4nC?-*3;GC5P zOO63IX;wOOSGb_~E52$ydE%m$GAt{&O?PDrie)C)TKz_v^8CSS1+X#1q`?~adxi1N z?xM7<_+lAAQhEOX>~6Ieu^`fzwY0ht!FgBItyB$qN=m(m0IXj(7-VYh7hO`S5LY;B z`briC)jE`8UMSzI%~gM|+R0<>}?I$-Ma0_Jc0LckUDL{+_;m{Mq0 zGNiQRY+yc7W}QmKZ1o;*gr*W_54Lr+_C|)S_rxC8p)Bk4+X8HMSXPjwYG?|a;)|8D z@f(ugB(^$BCCzAQgF*L}tecE5x6CrSYApF(JB|ho+($(*?pv7OIxjJgbC6dsBUFg! z(4}?MUGS6?gJ{oUQG@NGU=vhRe)BBv5GddM#;Z-QeL^T!_ZYL4Pr8@FCCZf3w)HZN zVTKn{lfe{Jkc={6%m%9Trl%I9a1lW2S*C13${}|Awe1lF5O-`u8e$_<(JW93S6(il zZM*R-{4*(oV|B7uGodSj-cvv5$&?TL>L<6bm^6bAbfal%V9dcmN0aU`j@t0=)U^r0 zf-WZG&G8)H3y0Yj&gZ^F%D3aV)y^C-`<|s2lcwX`Im^Ku{7eG*sLR8IO})!@O3Ljo zJEC!asDY-dCCeRWEGr|!fN?FB(H9&Cc>P9hJp}ulhE5_1HevNOTFzEr$*a1CMPjRU z3{VQ~aB&A3jmNOZ?qH`bQa!3OB5kTiD~hVzuTXM*K~$g5x$ z&10Tr_%c^2X_u`li<>p|skY#)C}Idb%duH@u?7VA{LSaZgkgK=U9X`vImek++T6bEgr9L#5oq z9PY-Y^CneSnCn!z)EPV3IT@Oo6jfaiqsFVNC3cbK>eHCBoiJ&YmFa&T+ zv@|z~Tv4ywu1pp9f@0Z=Ml`acb7ui{@f8Wgeqc7ctqy8YR}IbfG^p<7x+7&p)yCkt zbM9_ER#zi8g84=)^|MtDRSRP&m)j82HMx~&VWyzd_LQw~-?_u=hG0bxaV__YfmS5RX~+mLub9z_jRpLZoI&_SncH`TLi?q$ zUM0wg+{RwLFH-BcPzNU%jUJgUCc>u}O-FsD!~T5|tS2B=T1N?n zGXSmT6<8Y&bKl@wzfo`>;v;#O6$Z>BsfKj`jth>@wa!DSg3)KWfIJtt(-y!A+)Ds) z3&L4=Wx&S7T4eES#B~5`ILvsD(kEW>vDk<?6 znZ&r&O7}kh0EaPrP{IyzXe)pWjf}?s0CuJoRC1;4FT6R226v-Mtskz)L<^xHbYBq(G z&X^@TpsI+XFkNP*W71u9y9itz z%4<#S<}9VHs()}$pjBKp!$H{hD#%b2)m)>{UvqdN?}++9sL>VJizGqMNOb_i0WD!& zYM{W&8e%=L&?xByDWhy;u>4#FvlWoj3m70$dgXz(t^$t?$x7npxECAnMQuAG5dz^( zDaME)L|%|r30$)S2)l6zuE!&BI+fYX23Rr+sI3CGdor)I>zIV3?3Wt-*wP>vnt8xq zC4Z#GnAq-IG?$q7xsv9lD%}S&QVkoyO^^@47-D4FYPd=+khNxN9kQ)W%(H5qsikmB zs6E2&Co~*P<-URj)Mvo8$=yp3aim>Wh{u!l?Dk2)3RA@+ss8; zzbtZ4WvT1^G*^OM9_lc)}_(m0Yn9}0Q zCAIjDuMxZ`;;*P1;#nGeiXjs!LfNQud6rC-uf0LBO(^}oC3MCbtJwI7`8kklh9T0w z1$`wickX{e9a$Qc4SG#w7eu@>`-o6AqVp?yAArK$@4}%K6a zJGYOBhQK?4eN3C1O?i4asbB%M^)8BLh*0`SV{(&fDEO71IUu`(sZX*kxrmpj=U6?l ze0uR;z^l#IsBoSWb0bL`k}A=&8fIC~xSD~cWE-U9jvR@Fn%=K79m3jJ?j=~FYF)Q? zxvQUK5~oq>^D`tA$o!ygMN553k!&uhhs@IJ<9UFEo^x@-L6jJT&=m1ey%&m?3pAO@ z?1BnsWs)0%is1!fw${EOs-4$@XtUVII7!>NaS4F31X56(9#Ww8YE&GrJ|mot9nsZG zxB7}}xoUi zwU-kaWH5Cw+xVEIBNc|TsN}UMH5J)l>J?xJRyl-aYetylAvj_Mm5 zH85T+H7=#SjmlP%$Tbg3^h3@}3S&{AmrrFE!FR60i%+QNjBY~IZBddKtNdbm7aDB#OOcYQM z&FG@%%6!Ws5(kh= zaJ={ginkFjAT4l_VU8+^hG5s+UEC(p_tc|nfq@atcA2duz;P}&Y^ppns=P`t?h_Tb zIQKE7Tf7JDJ6P%q7R8(VlH#n|`cy@NacihJ%DBgdOFbahXPgsRsBmFDlFFee$ zSBA?c8=Q+ezF@lUE2a|EO5eWJ!4=TEjbr1@VsUqC`j?OOF4BYQX?yBCl7is9MSrwq z&%@*E_!XP+QCd09W$2aK*wvPpvv`hy!pz2t)j5Xqh(Wg7E(NHbvm$A&S_{vce!At8!dn+A42Vl0{j#(E$#MfYs_4;9u?mv=2~A0>$)- zmK>Bq>Qa@ZXsyZ0H@pt$>+_l~*v>%*9divt(a!6)JrP?+ z<$yD->wj>!2ZW)YqvcVc@|?ywf{f{#f?x=;+VCAi93U%1V{94MQCJsfG|QVe4ZG-t z+fWGkQ-cj<7UQXfs{Uq`8ol>X*@0~l88;pQxGV*Hy-bSASn3hgWr@go5Ueo7U?`?I zJ1tw4=`#a=^)}Y^RRyVb;t(q;(YJJan6@zHnVGH)vEUZfaRkWAg_dXiLJ-@ANJV1b zC(NJ}O}^qZGitkrE%z4mU);QkD)RFYZG)IaZ8XrZx@9el#vp^lTs$USJbI2?pQ(p+ zbuNXRBj|i!$L=e3(W`*RM)Mph?qa?9bZhDl=fYRq#(&JEs>2A=1*ujq<_%!t@x;%C zUlz&BNPDxLp#CXz!Vl z$;ld7zUARa_m~;+q~C@)R45t$01zj6Oq4_j+BubAA9pUBJTino?--4aBi)+CMeYt| z8t|+eh^`}E>4U^=6BXcz+DHB(4%4};@SuoP#x zaQKuJU|+;I8je~%nC)QbgwO`kmoi$ehaMbU(>s>*J78Q5EzbTW72;M{1uG_TE|YP$ zH>^ZH`G(8j9v3ZuGxC76$kWxvmF$Dr_b7YCWS1`lrAIIW zR5x`~88h)OlGNs=4jAS+D)Ps5DK5r^Zz=D?7u+g7MVLOmc7O_1(y`33?b!y6PEA}gykg=AP@F?Jx|t{hHbI}cWeV3b1vz_{ ziN_Q1Dk4pWsuIP+F>WGdpWn;6XGLO+!B*AEkOq}m z_U0h+>%#v4QAgB5QVB~2Rf*e%A(u7n)G`io-!O|cjSiTD{V<4^S6_rvx@q5Wv}=ix zPu9G~gR`6-q7@_?$f}0Y<7iGhjI`|C7XaIZLrqoHa~KRpEu^qyH_s8REH4M{CGzXs zFvTfHe&UujSVzqXlNS!+qU&ZApgC`Y;TU$HYFl7(ZsLzG4l6=Zb)+S+qrZuRWj>pL z3k-IaD4L6}8SK}7rNHsZ;Dk%M11-hWFw5>;P`8_mp9n@i-5g72(=EsT zIE`fsm8oXml4NcSd`y`Ek5D&P5zSdB$kf09!j+jy(AY6TyK3ORT6aX`X)B|C<5;KM zKoYjYWLcL{XbOw9s*MP^l;?;y97rrdxPjWVUkS$i(7&I^GrV6|fAB{!RiSk*<$$(V z%ua#)%HE&MePN!aO(nV~!7)1gcuMku?fHp;qkMG@*}28|VV>YqyN2h$@Q4;I4oC(6 z00W%81s3Z`W?lSvag09Y9l#2P1I6MnmT+qI8L6^h&C4Rm@CS9MDDW#7SSn;HaqeE& zZVKJFJC-tV=z<$BV8V(#h+$B&-K;9}IgDAT1l)MiH?rx1Uk8clYlkkqz$-R&?qiz= zyO&(%9A3ReR}RDc!wfmRLwfQp;LYx(wV>D#VJ*&$T%$(sC18NDx!ChEHv5#kQQXjt z!NyZXRaRy+fof5kZ3_C;orhCFbG`_a0KBhnUZoPP&1N-vD^<+0`K#hJ2Vps`rI)$X zt5gLXb1u7TV^XhfX4!pP`-=&)5o)@M@Jo%k@s8m*OT&qs@iIOVzmthYb6b~+qvy(V z+}&yL?D5zkRA zu+I|}kJ~pHub3Lso5p4%RAqVjotAzPGk$>_uDZo{Io#p<@m&ktH(R|x3Zr#eKXJLM zC)CH+aSIP1>Np*p$|$Jr<~z)G)^}6lGQFktrB+gxgZCZ<)I~UhnC>6q3%ID?B4XDk zNVepAmMYWYW~{^gi;N?PSAB&})XnbhVAyG`Vhq$n29>kM{2q;XR`K-&XaVkSYa2^| zFLXb+hL5}jS`lrlnZT| z+0>^M3>%mm(!ref0$q#+Hw%eRJ7Mx>UDt9gnN(&4@EIAMi z{{SsCc?{Gx_=)eE<_lyuxBmcfDQhoxFFvyKIC*fLF%(<&l(Pu6PHQ%mRn6x4I+ivJ z^P(o4Yx6ZsKLp=O&gGgySVOXwW&)DjHWe?Wi>vi7V8>C`x_l)vu4lvUTdUM|{X)y& zt8u(Qrde7uTS*4M@+4`SHCKTV8BX^x+rF)tu3R7w)OEZz>=#sG4k(s z5DFW$a35 zsTT&gmZp^q#R|(=m7et}q+SgKR4|k*3*s=V&BK%_!(PxuZ_*;r6+G^CeJ*7Co39pK zf>y3mJPOBi5t11LGY6?h{{Vv>(6MYhs?!Y+*@h6QxN{QNw%8~*Gu${4K4Efk+uTqT zF5NSb=lrA7A8~NHf4GtzN;rgEn_ZuAFg!;VJ@}>JYuqX^bq!swn9KpnrS%Fx;a2_l z4Qe~fJCwV8)JiI1+r(RrDb!ncm6ruZnx+0XiE%F17&H7*y2rRR0eM_JKZkHphrK}N zx}06qLdd;NZN8<3o{U@1Gm^sllwW3usizeJhN~kJYstk-O%{|>UntxZX~w^_tcdmv zm+m2IrI~+#VeIt^YMQ=@Ma>4o8JvW?7)5(9YEfp74x$`eyWw{xn8>v} zGWHhtCm4=%7DrETN1?;aVF#B$=;Dcrp0 zf5mE?#_A1Xecbl(PF0To0E1YNxy*bmaXja9DG>~964lNjN4TMEIN5QVH7j1>wLNh% z!{-pwDX&aG8?+;VZ*b1FhcCH!oI@WBu>+)OE} zl)C*V6$WjI#kgi+c!#-g%RL%kHiq@!tIXqH!ye`w_Qh4s8WKE8kmwt{uVtpY4trQ#}(&gyW0O{PdDY{CD z(}b@Su3ES%yFgyOOATRJNn-M@a=-`yDa};dPVbqcJeY_mrxro)b@3}d**(ppWmTJ& zH;TF@b*v~G>ka<^_XbAxeMj4@XO<@h-NFz9q_qbnc6AkZH5k({#J}PzRvgtr*er6I zp5xO$96<}R_=a>#WvU3;+ULa&A57Xm7M5gBo~8Qxgh1c8y=r4+siTN(T+2(k(nVad z$OAqJi^e}FY#v!@g&K2p_>R`F<}DgDXE13Jtd@P>)O|#1`Ya@VU{P|NvBJ~Oa~5^X zOH$~$RR~)~;U5elnNM*YId>{7qKb#iLbveUgg&y z{^F9k2Gbk(hi9|3WPi+RFy)W!l-pGQ03>jxOGp}*5GGwM*mV+ETpeAv0YqVn@vK} z6zi5yzoWQ%f;fvNcNU}8CBzP^XfV(mU0h$>;m&6t;$Nxv|H1;&0XDz&;~|V~C{> z&$(z5cZVu$Ws#u^oWBz|E<5TJFny)~U#{lH11wY)mxv$B54F#N2nqmr<(o_kFS&(E zt+yJ+sLT?FvjElK5HD49;Fo{^qLnfRMq(LuFrCH&@We6-Mdujpjo`M+56h^V%W?Ft zvfuzektXPMEY+!axPMcXf(Z+G>NUH16AnSU&Bc-?dzpD zZfhUrTMwk%x?D$h{YNW-UsGvQBCceKr7P+-S~)+r)UN@q9*r5Zb6^)X`!$Rk?3yU)h&6luhofzG906=&PCguVZN%@TvX? z-4$lXC^Z{^MI?(U<{;V8cP!b@H@UZa7=6an5Zo1 z^)Ux06hNJ? z{5cncjV60-?ggvbjKgx45DOv%wk$l+cZYLEx zoVdhwW)0E7781^On7CDF-}so#{-xi`7#bMj7RkkvOo~)X0wo0VhUJ2r74_kDl$z}vk%WfdgkE~?u~e$TFa_Y5i^iY|u4bG7Wy{>eXo@S~Q>l6( zz4I#7b?t^{`v`W0)tZm$4zGcDRAnk!f z{jH1^Lf4U-J|b=bYaU28Dpyw!fFHH+iCVDCtgT!|UKSY;huW2Y-CqxRT7XdN?qJX96ii|m8yhL;;vA)QH>6bA5!@Eg2qMB2jvBJ zD$Efs1M?a!n0}dm6%&Fp>V=Dzw-__^EwrQ0+`4=yU$@*7KyhWgTpohvBLH&m+@_X) zWY|#?QFobv4Xu;8hLzM5iK)-_jQz+H0Xvj%F7KI4d~pWLcTkqTp{cEG?ivA};0=Mp z!5zmPF!wTZ`iUAL7nnaEa+Fc{@k6^idobl&_=Qztdzx1rOEHRu^QaB5`;QQ&#lM6_ zZl8&D^DkHmlmgRsX;7C8I5N*^a;i|!dt7!H_?KM2)5EN3in_D&3p9!oE^U@=3Hd~? zA32p&wG6YbQoS|AO3<{MEd_;WsbD!{ zA9B@2#<_jKFX}iwjv}xYp5<|}Uw;zUofiB@Y8zIR>6F|GrQ@jYM?|iza16604YJCO z4nSY1a*J9%xrWrb-)fjvt5I;*+jc?k(bS6)QiqE(M#jY#tqXguEC&qDq@&z5Gwo zUZD?4Ir>a>#_6VhpwfL@l`jhiEEfrXgk8R3g?B5(wD65KZ@fmeRZw>`4rtGJ z_XYi7hipX|7#w)j;rNpr;E(?RW{7%v^(}EYxvu5XsG0!?3UK~mENmCqD`1s!jgsW> z6;%wrV7?LRT_a;pGKSNY0%DjOoMprfBCLMVs`jnh^i1M6Y=bDc)UvA#E!@ZvUt(Mo z3)7}8>0c8E043+<1q`>*A3a6Bu4ns-z`w=}XxP-g3fq9@(y_(I+fWYOMdDZ(Q|~hu z)Kb1wXi>96DDN@ou=Og<$6}bqrCGBOzCiItSho8J=*$x-yW69Z7JL@WB)oGGXbMSs z3zoHKi#cFH}+#T|&vQlIJ%IA&ceyv@oi za7sS`T7<=0%Ve;HV~23A!796B)<5nnQ(@CE2GF~%zi|ps7Flbn z<|{!{SW_`+84Q>$VvaHX?3AopTP7Z^28@qOUyromU?OE`dj+&v8X-Ft$X`wwbo4)L}&&iv-32!m|@L z-xCzj8FvIgH3lF+_Nb~M%;ZCPIF}czy-dJuUA#>Xp_m&Zmy%?q8_MFC{S)eCv4+_lWl*{F)T#ldwl6srqm(~>WR)qY42GnD2Y*CN~M zJ(dk$6Da8BQ9|AC1XVc92LcMbVU2t;gsQo&VFQTa^#an~#{?Pz%GnADT5a_+ZvBoW z3u)fd7K5e90I9a9t^KSgjl;csaW}v`T z6PI+f-`oPY90*)Fydiw;pSgnXY+MG#;FT^Zw|qqL9#b%B#hDCW`imoj)!j=+IzGCE zQ8hxHrTDc>v59$FxT{v(5{?*mZIINa6!mjH8eT|c)17VKY~JrP zhI27{IOYi)IPCi-f3DBsdyi@)Jv{*k5NrXKV_f@<+AzKm+m%cVzJjq*j`KCIBj6ts zm{_xw)J$RSh=3A;p9!^C=^URli*<=D`AKZwxhbBM-a$Qpy| z0uOqX428>km0kFV!c&L2Vj+WNe8Fj3T{(q=L}PX^yN#Q; zk5?C|Wt4AoJ#$~0g;l1rLGB5c+j6MNwznA6t=LVNpgE3SoDF-5=r9lRPDqQA#$}~g zQNqRcpQ_Eo$zj=D%H%2<#$QpCI1z&YEk>pdwG|G!v_-paiA@59VXugNBsY9w6|lIK zt>gz0SgDHAjaGBa8DI{A{{S&l8MRsamRsIGxycr2IhEUkWs4;RPHy>$wgg^m%%l-i z#&p0HbPJis1Y}TD3zSSlGI-xGr*k5z^&asUcMA$0yOx%>qiK+>4r#VO5Vw!Hy>K!y z*}2Z;EZE;kkY78XiaKgd2mb(Oh$Me>X)Xd_+4CuwXP7;vSjFGMX}qowHmqSLYDlMY+U zHM*EkZi=(nF_A%3R^qIt0g^K-3m5JOX$t|@XLBO4i0WSSG-G}*-^?_*ABb%=z^%jg zFFN{z9u&6{v`tH7bA9+8Zg8Y=G8W^Sjc|RyIj_#6eO%RUTjgOV=;CV&%(R`MF=nNVL(mVEUC{tYuz(OF23!WulF) zVPM?982FYT139^(fm}`CO6sl(;vfMt_Ju6?QMqE1E(35aEpwtQMY)!J{fS$`?4e#ql)p3%GNcSw&p84PYkni%b!GJE=?# z!l6YnFq}Yy%Go$7 zu*7>*}gA&IB>6Gr}4^saC5|9FPkHKK4OB*n3V5N~BYB>Q{ zj-{D88h7p!kyIiyG2D6$OY<IoV$SBYRl zX2`C8g) zBz;s`A~`b=Qmzz=EH7Aq$?CnwETx|HEWjyC+6qjbx_58}D_FOgwK^sJrOE*pL3|Z! zk%jnr;#xL0pNO^BD{f-<0MviDcds_Ol$(i$>N@D?FLK)7V7E*(H-k5cQgrSrBKScF zgHLe~MQY=y4#Q?vBDs%K?3BiAp<_%fm?-*mkts01#SFr{++*%heF#(oO|=-oIPC0~ zhAnhx^)aI%O`ODk+Fc4|+%J2Zb~UJhh8kuq>h&(I^$sVpLX7B%dxs?EDA!iY^%Ap? zRx4BzCXxEyowMwPbz@f{XB*QBY)a3=bMkwT|rd}{XdT$Ubq3SQiN}+qG z`MmKO(fWf8FtAwEa6cks&v5`O8TB}f;GBXBcnfI6dK4cLQUOGX{g1|8w8>7q_k*2Yxaa>9ji?yCh1(0mUVdq#0 zUjG0*Kp}^)m_u%ROT?v6Z-_AmI({LNuAyEU0YC)Dw&Sn?**__m!d6*v0jvtG7Xpj9 zR@4A+9CNR8R$)#o4Cz-s1icC7!+7WMhxZ%ukhFh zk$MONes6ad#<~1Lh79-AXMSt14(2>k@ZZ$E*>9dF5X|EI=;Gqg0md~GTswg{d{al^ zfg2fawLNd{WY*k1Tt{)0YZTWfQpbT^0}y9oE9$9RH0MQ*(7*=j@e69U~EEvKjevh$;vVK9$5l@Ks1SMw?xR@208)-@f3s(aiZTOe@>S8ue(d=n9b zKf+=loNv^pT*T}p1-}TbR8^yw+{;?^GZAly=qG57@oJf5UFWxnMk$v80ir8|ByH}# zGusDeQNX{r((_zD;7iipX!A6Noe^sGBeVR^WOjzL}A z0Kh5GmD9714n<52O0mx}@sR6Fm<=t>Qkz71MxK53^_Hq z#M)A^Qw3rozR5mtMNXL_}WWr#EeJ*K58W}R*>DlqgN#oM?DXjeSixqO59X{rN z=vF~C9-sFq?Jb5q-|AZVO2+MBjHA(2T8KhF>wHTQaFln9jPD2uecql=~%x zQ6c@VreUPE1o)37Pu(QkD#dCWHdJax4r?{;J##ZFvk=$Aj{#$vsN)NP@f<(FiL6p- zY9@~0gV5~gg?eL^3Dbr{nbs2{jS|;|+~%pHXW}-6Iz#2sZB4|D$gZM|-LSE~V4me% zmuwuSqV=i54bwbJyK*_WT?d+s%VPrODDc?~gUkZ2csltI@t+XcZ8pwk1Vc z9jfpcX5|TB-#V7KHG=;D?qd|UF+7{E#qM4kA!?iB zzZWkR7Tc=62yO>9Zl?o@T*56mEr;Qh6srx!yNfG0&xF9)nEE0Zca}9VY&Upr7xecn z9Q%v_=I+^C%Ez-VE5@1_ddw^@UjG1%&tJpGIh@BVYO#JC<^C>{vigZd53*e4;wH4( z>-vp?-a0wC@}C zuvu$ke-ksFEVgHBfsz_1tb2-)*=_c9E)B9xz*q}WXrhI3`l+6W z3+ib$lBw+Pxvs&eIIFn7qo~sZP{^claU=_ugDR`3Lf6%G0%#lWh{K7q^)$6!-l0@h zt+KH)77vzUikgcvj*|RWD+^jtI5z?UxJFy3KJr(xY1MnEn$Rh+Ug4KevMltvj5N^8 zy=vfNbD^(w#2SigxQp0vZee&?hfy_Et}e2J@|>9d$erV~9xk&!Rc|rD>%QT%Xv>URTV>V=!Y?e=^{= zZcJ?MC6cX21i{Bs?gmXjaDDX`A%EOJL#_?{$JozKZe^mi#8Ub$&r!f1aLViubvDi_ zJOqB$$8D{efo`*UnP-p*GtuTUjIzY-aRhC+3rMRN#iH*N&CFhZQr%lH2{Q$)5aKxI z<>uL0gIp1|xeY+7y>`Kob~>b~OP ziIy%7yknB%yvuU01>I3|>Q*PR3lVE?*&06>sHnSTiJdiu>F#s)GkA)n_D2ntx2l5W zp^%~Z_Yt9LvV-M=YU?U8FsiHdGgbQjA{<^hnt*3DIH1pdGq2&q6}UI+_^ZvlvaSPJ4 zP*0Y33iop_^tv3MGeZyMEt7uYGPZuD%viFP{{S+tY|2r+;vmpaKqv-b%JE!4G~Nct zl+f7>gSl)y#A>WIiiMK^G{ZN4;V7AXi!IwMz#(}|=DN(t-BFA|&LJswFPCJYR0~BM zTtJA>**pl zs}l$5glX;)^ER9> zW#IJ`DyvDqnM4>_D2GG5K1B?Fvm@czL?T8(-#Kz;mALd-aS;TE;@dX&g zX10T7W6cdwuXC7Vh|n@QJ;4oGzVTB8rnRbs)HY$>AnE-$#mJ*tbv7FrE;dkq!dBag zad8=qIGCuM6^U^GcIKhVBUjn0SQQ%&gW@N0(F8Uq+w%%Dhtn#LGg#cqS%qsDQEt7$ z+{A5Zbc(Vd@Uq%w?qS4kz1(Db4VxKALPt)RN`-V{DxABQ)?b+JpWGc1($HQAl~}W= z+YOD%G&T!ashxt3Y8AEnscMR3SFck8*y837hVck2S4W{A=`qxEj9!m$_$8Mtb-TE( z3gLD{3mkj|L5l^coO|LWmxSWAQw$YXzZY&b)Nsz%o7Cgnuo|_DD-6{>GX%bRoiAO= zdkx}xgjTr-o;aL7AY*?JpiX~K7EsJ8fvbBumxYJSwF45|PeV5~R`4zDK19Y&p|(K- z<HSL0aP155G8ipO;<1(e~DfSBX&*a%u2DF)GcGU)-hP$a`-=Tr#T^PW#*-o zOn`PorMfG}B0I!po1W8m516*i)kguR;xVhnNt%y`#b`6vb8*ogBfZOm@xEb+g{3sh zbD9S%pf+z+GNi4hn~w@FvNq*tg`XzsQx$j27l~I?yG);RSm7$|>Q*5y8}m07 z8sx8j2~0|jHBr*s;fMQ%M~>dq|(YD!~$MrMw+;JmK3S6jHgTE+ya_%Mx?XB-^>@Pf8u4zm7*ug zLH)4`YyB_8DPJPS(;Gg{>4u;oO4v&_=0D6tbn_ZI7Kwz%e zP^RA!^rbJznJV8=9uCN?5yff*rMXlEXj;trvkK)AZ=}ZApaXdQZIQ5{d1U8yCYEE?v@Y&{gBqQ7ze)-GSy+%DUN> zJ!=HNxxmSgS5V;U^N4pH&72XQeY7+7IF{932!d&gh#mpO$_&Xe`l*lH&`%jADixjG zM5mMHK1;M3(AyD-gJ3ZgFvWE?-1r--xlwEVA8}Q0yO(%jSILy}DSQ^#rtKg(}$Ye5_Xb(3jHaqvOJO--G5=i)K6~o9oMtpNPAl1@TY{ z%TtdHE(-PMh}L5ZDga`&4#I;;Dq&)%HU9vv7~z8A7H)74qRwM$q{WOlEYSg=+Xg}p z$u0n@ntpqinNha3xnb6#6c{VGocV*LV(wvg)#eBTgG|tS;sIr3wB7od1kHwTzNLU* zV$P+k)^@=~&3~DL6`7C-rT8HYXNNh)u5>xXpo z?B1h*L9yC$jJOD~w@av6$idGrFx4TJQ+M#(p&Se-ZrQYUORaYWWqw#*FsAK|i)S6rxU-U_66iR?4isaa zWgrfU+Vw0>9TNxRGXVga?G7RHHwKz`HuURbKKp^T4O2taRB6dYf;vo+m6WR zA}yao5iLIMDmm0P%Qu%a{-Ne03B(`Eu@Gt6CA~(Yxx^VJgrWpYXC*MTsYP(D%CW6( zX=-kf#1*J#)@27davFvXt>U5tpwj5XDoU7Ji-8giVR*T8CC}PhAgRMyl|`-H9WssK z*X}9}8188V85>Rb+l90OhHLg?b?!4|0^Yj%71l?j}y*Fv34Z1cRK% zerj%L%KgBqNN4_IFF-v(IWNhPD45l?-ZkOF@de4;8d>!#ImA8PdYC?9+zjR|(O5M& z!gzWW>RzJ7mX0P=fBP+R(7E!0+9A!>d!D83Sn4iFtl;V@$Cb+wIG9<^55b7z$V&9T z2ozU&h;88r8sN9WRruGNzo~fe)#F}sfyv3_;0D()2lCebmHZq&Q&IXs1Wv;_jP}f& zi~Yy_&FWmRsx!^O7`UsbLq*M%D&`t9Qu>v{<%2Bo`GsAUtfS&ZnJn7NCG$|-ZN`!Y zY2BHaloC80LFB~6wz+MNg!8FE`Y@FimOCUZZH?D)=83E6>R&72SS2c>+ciI6G}{M1 z8JLrl62~1gc`lUb?&WD7>#1@X+nOa8EUa(kHc?YKU{@7xrBQZfJa;aYGwv8IT)J3q z^$L$FrRgsY?qO)b8&iwhTA0%<<^KQ>_*dUEEdVah_b{G$jMx>vL|TrlqObnt{w1z{ zQI#f2=HaxgwLc=Q=@1K@N{yJTv6O0sGVIG@xsE(GdmSonDc94g%l$$)G6mwJZndLs zC5!&%RpMzj7S3493+G>RMNRvK49(yo8VxEripsa2#KBnhi(O8F)xiqqlGU9=DuXT? zwppu;vcZNAUIM`{1~R}ai-)yAx^cNhu-RFizJj3o?qD3NPk$3kWR%LWLBU2imEcy( zK#9q?3F>nSaPy^g8!hi137i+oppE*er*SEKVpE#QS%M*{!!9k=kjP;rm{maR@Gef` z4QJUUu@ikK=KMN=yUABB?`&kBP;D{=!T9U=1^yuB;w`QG#&Wj@hft?1pkma(VDh(7 z4WWNT6xD&rDq}UJbv_j_UgyMT^%_pEmIshL6}W%vn&R;Su_^+%du6)h;K4EwG}}17 zJB_1Hz*g>77SE30^dC_QGoDD}7jd^IG1Eh9Kc)g>mZjQv$tdn{Sf-e{g-0~hzXU3V z7mwlfjR$gIA4vpI!uoQ<`TH_Zu2^W55eFO?L6~`~@y)MYmiHo0ds2eY?qNpXxf>nXwrIFU_7>cx7R&81AilMVDRpv62 zVU#E<6liePr6aZ&C1x}cyZMD@Dy|`bwsZ1=3PFfiUo?WaQ7=3VHE}5PM#k zlxLf`SXj$#tak}jwf#qJx$3b{Kn8@T+_CVLecZ3aH@M<-T4L22!EFjR$vRtyH&m+XgI-hdz=jt)f z#Nrst8_1z}g=H01?s`y_cPo7p{mU#D;u&LdiYlCuw7Oc2%6=WoFIX>d8zEq4)b28= zZ=%|a%VSI~rL|X%zk^t(_$4_!pCMC!!CQj9KZ@g<{&;N7^?pq~#sYxY-Zaer0A4PHq6(80@iE-)T&oXk#w%P?`-wz6 zIh=ckDQlM2>xp`B;^#4Vm!MS*^D?v+IxrJ&vhvD4R@4|Szcs=+79DAE#{7d?qgIbEayLpujdYvBS ztaG*F#SI_kXPD4~fz-!a$=WOe0P-+zK)k+hFS+v^Y?$QT!~Xy;&mIi@9y5*BN%R50sb+0Kp6~MRe0QMi~E~Ejue1+s^fs2WG`^e`!)We2SVkan7{zy+^@EA zUD-lIRY0`Y#O)%5Qtjqjd`hW#QgibjgZbRX4L7)9YMwI;n%3KaWs}+y)m^K>xWbn1 zSa`ds%O?1jk8xe5Ic4D`vZlL;^v_UDpTs1=Mdpc(WIWZ2P_w4xjcKk~ZylCX2KR9d z<1Stz@WW=M4qx{F08w~#2BY4MvhFpo98Z|5{{T}NhlR-aW3ofxs8tH4(8{3g zTSJ_`NVmSC{Z1xT--T!(H4rE=Im}1}^gYKen6;(U(HYE;YPT+7@|$bG_Qi7W;PH5# z?pB9Nea8&In;5t`4N+0M4L_;$#xWef(;rmxejjnUYPhHzsa?cLzZT9t_+x%WRy6k$ zJV$Hz48>>1QvDLnG~H$ZSYw>{u*e5+u;y!XoZ@|d4mXb%a`91BL2{^?SUSGEV-EWv z$%xj!EyWM%FIn)eF6{4#mX}3!E@Uvvwpwo-p^=P}X=W#~_Y>2-cw1G=;fFc?C;%h2E>dJURC%G<^#1@A!H*nxArWp~A{9j) zL<=o@mlg_MUHnCA`%f`lMn0b!g&1y8y4tresz6H`sJN;K#rG5uo@w>&bcg|Uq|;$y zBh*YBH$Ekl6$ke(Ya+K%w7TZ|hh6gnaSXp%~ zk%;SYU9RSt7CLtTL~!;Ao4B^xZ<3_grRUi z*J6);GEQae4g)NDoikrC?r7OIzHVnY*Mnq5VEW8kP#&W)m};yY-qI0R*5iMD@;&^V`16{CJAzwDiLe8*pVox;19apQPH_=v$X z+#0%=WatYY6`Uw?D^* zdyiws5OC%;=z_Ovp<9>=Y}iM0%r!AlXOF2yKk%Ahl`7A2ygP}!+@iDxsDVZMYF=@I zd`>s@1}@@L4d+tM*_!nV`!!Ra6lb;Spesh=8|FArc+sOX`|)#+7oQh~%=Zc>F^c(c z{{X}}F^=;+zr&9l>+|R1%4;Njcz21!Tqx_$_#tzfWmm_Rmkj&Nd|rHA*YUd5K}~$b zGAwC>!53XWmDBOZj1=Zs!49#Qyd&yh+tkgKzbv(fnP50|8rM(^7w%ng1mTl;+(MeK zM7OzeCdV#i0IOOxiL4X+6v9#w*;)^1M_?__q){=3GHpew9+- z1-W~O)#L^ra0m_ei~E@kV!02@Teag3?Q7q;b!Bc+f4R2qeaz@S*n>H!{{Za4eSN+( z)wuI*PCfV<<%@m*p=7gG@Ay|NYVmdMC2ehgg5T6h!iv}M$*(u@f@N5k%=~$EueeXd z4p6PN;_NKts9Nza!(US`jD1S<{{VhpFBLDUkB%LG1If-W5e4j5oD?@%%yehM-|)sW z$C}Br9^uKn2E28cFB^{W9m78}0JUxUj^(&90{b6`+}&F@^#c;!V>M53j#U?#ga*|gNv_ob@3i4^4iQG>aA42 zj~s8vS-P#A#|IDTjbp~~{1vw6$Z5Jp&Z7mltp0b8h(cZ^=GnwWnLP=023~V<$K1A# zpsqOTR{S!{6`uUxjYV0krdh|3;m6dy;PPqk`g~%a)Db2HkN}M+_2-}9xqJo3{thm0 z4rZTzd!O0=02VwMd^m~cz`xE z)nmrz5}4BOm+Q!1hxjd#OV)KVw&rpF04Fn-srCGyuc_N-^1Oc>SGeyNTwn3IbbUYZ z3N~T&4d*AErBLn^w{sLHX;k|f#U-Kfwiin|t8|YMi%vIb|;m=aNkt^7_V*dc~ zIr7A#WaHP1nFc&Jm()Vm@`Lf*`<%+!&FX*ZmDA%a@NPBA$4$o{!o8A;_c7cMDBpcc zdIsT_EI_ovUG*8q9#ODfdW=|+vMv11aT`-M*(=7g-=2G%OZ0daleiWwnD^nFXFXSi zTJ`0u)%uQMm}PZYaaD3(7!h=tE^u)9!wSy|%J0KA#kR5F3p1D1e+z23g4M4-EdKxp zIymBQ0|%5;Q8_#?xYvuX>oXk3ahR`sOD2CcDa%ss<^6n2xc(O1>*LO^#|}>yj~i!E ztH!7G{B^PoadVD570=`Ol|?M;%@={y`S|{LrZeJ&1sy!f5Lwa3fkLWRvHt)AhsHKp z>&v8#@5fiE zZWgDHDOi?Z8Nc9Ibhz{1GUMuBui#p&uM?SYS}WNsuN?UK^I+a_UT)K|iM5gVsgDk@hZX+-0?vF~yDJeXrZhXhj}+!VGW_ImkKp;r-rz0KuN+KLZQ;3IR~kR! zGVy#(>T@5j_?#)P_)r6rBwJU%hC7FXOo_Z`cQp8PoX0m91ge;e_U z4r$jH;)=1JhrrYdS5FT7HO0ob@(9+B8}W+ppZ**3wg>CR+7E(r;Hq%&vcbh>1+;I$ zzWg!%9L1bK{ufi9cv}WB;Y0%< z%iN}}XZQ*>W8yO%uKxgE!Q&sy{wZqfTtv%~Lw*#tit*3`>~=YPelSKo|L--iosTCO}( z;I%7p5p3|Sx(|uWVAaAuJ_%>ROYOfwWW6vHrig-GF)#uK|{tbUSm*AXu zD_mRZH#&FI`H3vbhjntmmxps1UV8GkDbVo0;MaHjIQQfx$bHJgpFB=H-v{vL9xlFr zknA|)#o;LMPA>kxj~*^@&)4w~ykF0p9zMK2zk$5QUy)5&^!Th_i^YE%@yCbDhrc^< z;I4dQHcK19=<$9UYJ-(B`qP1fIix7&56pGDW4O>|vtwAFF;mrxMQ0UV zB@B6{)LrBl0oIyL*P31DnNI_i_xd@j4{U=8v&-DH=cLAtpa;&|LL|aAIo=g{bzV_M z-)HUgQFPfsMN)N#l}Y3rNoIFtO&57rx>ya9#f*~P9Wk1?y!+ilIH4L17eY~dL`2f0 zOwmK5BWW&gqpSr3FeNkB78h}67+>D(VRV&o1QJ04xmHEB)|+W>fRx@Tu0vYQ8d2ARuyq1 z(nHL4k5tm(l}*%#^d@ccr_y(&clb@Q@e+pPG7qoJ{k-09L~U1f*FF$kus`%$tjSaT zw#u#+{@ry~kxhGZQDk0O^ch_Oa@sTxNV2l}G$)M0(hS1#Dy%iD;uf_|91OC7fr3#! zPEaz`Ka3%;e}21f)d@+Qj#1_8*!qBu_OR!@7++q!O%kD<+^B zJ2iHxeU^~QLw4_}m8iNcPY8jm+XlC`mK+%?<9(ejk5-*mJ($aK>aH?zBjz%{)qUAJ z-^Sk|j1b3sr0RqoPqIl5lR%ogl$WW~eo%=WRZQo5+uk{TulZd%0R#K_d5N2h+fAM`+pc(7MATDI-}UFevw4Q3Vucpc$r?36G|U8tn>-!? zIZblZu-z~+r02mC6EuQU%mNNbwPF&PybyFWyp3ZlP`Ab5lu!u-4xf&T!%0}00c{<3~IlSzt3m5fQ>KA zZfLKJ=Cy9%=UIHtNHJRDv_al(@P==(wur+C zL!`V(ujoakYdu8&0As8ZOa@R+2AcUm)+5XlNkWf1QUEJ+@sg30{M@jrh<1E z+`es7z`Pn|EH)w0&P}u9ZDO3FYsCV|P}MDe`ZBOq^j&}?in4Y1{+l<|=S9Uy=T>pQ zSP*_Wz8;C*R1W3!Y3Vak*2~D>v>>)Y;rYV38?1YkU>5}I8hro(<5uUqp%p4C_O}sL zPa{MUZc|Nd`%_3;X5`=(rv{y8V;pIzz~vpvuE^~fVW`KS<3+gLDVK@B4wyvh?wt2F7n+P9tNG`Kq2C5Y63VMp#A>mNDYd$owkT| zB(FwkBy-`&5xPG+rP)}6RM8CG)pA+(s~rP;66gD=9g=nmks7b(*x zoF_iWlGa3bD_3y3-_qzAu(lHSMG+U%#(bcmdnaXmWu`lCSySq6s{ZO)~GxVQ6m_t;-J-2LCndu^Ok ze3vuvbxDZ0^TWw$pSyH@rNaK5`;6RWxeHi;Xw0?f5q*a7u7b42Gz(hYe$e%olb?RT zf1=@!uo&D<#_Vko{>lp2E*D>)Ii2o7TerEWHN?$5^HTlb1u#}IYMgQXvK+Ii9p-gf zarCe1yb2mUG@aOS&5&VM&ItO#Q@rKc8^BtekT2KzZK8TPj&gi+H&L=I-49tjzURA6 zE`};(Iws^teFl~!@e4>>Kg9DDOZk6K7pAc)94FgR9GQ19Qgz9rI<5^HY8~} z;Mk+RuMw5yF8=6G(iS{|%o$?kmaDe(d{IANpD#LVu{WD@w5xN+nWrpjNqXbX0mYR4ooIb=)s&1!B)?N`w+%CAE-EMwoGwzw*D{A{=|g zisv=O%5m-evG_5qM`^>_JB5e!-?dH(`pzB0gBF8vwPsiBO*SHAP!J$S5zjVDT6xVW zlUav{OGfThAGAUL0Pde$RMTV5F?UP|yA3fgs&6PCB=_Qs7)zT)UNm^Wx|QSvNm(2_ z&yW8A+B|w-n8MKD45Z#C+Ew>TWBwW|*7i|WS4DR_N4v1ckN27VQc0Ed2O6EG3iI!N z*lAh7NO1RlXD>{B-0f3?_wUNM2N2RH#GTBly$#5}m^=*=#*f~S%tGqEZfw2U4d4R+)`+vg z#sEpj8)a1(NT=3CeK9pQ>i+;?HAEMshz{V=!b9z4v|#d4pOWpoFU*{kr-nlWwvk(I zdF|u(+-mX%bQd!_M-buO_WKMG4G9B$7)%s?a2`^qrezRAn(&UX%5*aZ7T=P>AUre)0Ec& zhE*^})^$MrMztbs;j}! zjDo*?JBJ{9EFzt=aRY`5K!wM>eP#0-5LRGxG=mjjP)HHpX6pvs2&&Z}oW8{+%Tbre zH=9vZe*3P?g&LnOUglp_X!V+W2xjzRa7$Xz;=S)#DL0|t3@)^b(d8}-c&*7W1f}{ zs2mR4Yr$1!lDo!`W=4`d?Kh59l;mO?dX~H10O3I^=w4qm{@bIU=MD_0T5?z?zs*QN zNmQ~s!|!S3Hc?;qdQSq%3>1-R5pTW?&rq+r_;&Zmq33P5;D4A}#1+y|HG6G+w~2_^ z4SUX;O47X}7S*wnyGn)fi9@Ln5b^3ZTlojykGD-tqU5g`r)*wfArymECQ|OpI$#Y= zx#DKrGo>Q2Z=E$|m4UG|ompTHA6;sg%9|XH_(KbM3yOIqz_P)Rqz~_=!6%8*e?@C% zw^12pzi_3?j4m*702$Pl z!a4QC2(o1}@pKS6X;O z0P2rvLG>|!-SI#T^Rc52<}M@?T?gY)CLKYIdfq5PG?I%;g@R(MHV;$T)6Tq=JVk;< zL<@CdEq(}baZuIMD2_u7Z}>&mlgQ4LOea*#{{SiwP5IMJc277CAJFq@Vf)%`bFOm9 za1(wJ@+>CRwgrEGTY1mS+TUj4N|78Vp;A-)jA#NODdseuC-?5oB9g%%X+E3eb3aDm zZgm(3zQ7S}&)pE!fh1F9Arc5qEGP*NJ1ss_#_Lnm`M7H^y^8sPA*1&OrEcSpUNZ_B z_?@ZAHV?3=YmN@(+JiGrj*!P1=@ewdCX-}-8R@RM`=roG?h9Xv>aD-zHQ1I8A)of; zy+>CRSgYF22Hw!^wPgxGrm0%p_MuAt0WvC|PdF&_@n|due1LzlLngG#{{WqF4_hns zIOhk{P2>XXEg&|}2~aYUPWDzAdm*rqj}lYSC);Xzi;=L2%l*6SK50+VCRjeZ zhqUamojPp>)MXV~SzxB0L48H#XQlBa?F{%Eg@RgWGptj$Se6!PHf-EFFOxby$8OuH zwJN`cWDN={wCeUV{lI^1{cxoJ0MB^_LC4rxR9J1K)J<_rmpZ!WExKPztqBg;gw$xZ zKi0bdNBRt|gRwv7aaoVAOB6e1(E!FIT^S(0^xs``i(OgwDp*z7!$33iL}rd@R8dU_ zTz!+nWHc#fF9c#&Xx5F&N#>^MEVjJ`cY+8BL9Pl|F>k)#C@m|O%dX^KY0n-PsmJ;m z;YJtJ-CE-lN9nwhAdFsJMq5dO5!Y3dI5+F-ECpJJh?)+P3WC!4)G_rJ@+QE4N@8q< zAd6$ApLVPw{D5bLAtjPIqwfvAUo4(QHK=>tkmH1hcorW)WSvn)kmvQBBr;tvD#Fy> z6W;B*O22=PvOJT)i9@u}a^wKVIgyDFsQi-j2m$NIFEIoa;ak`;^G!0%R}?EA^huVG zbmqc&rc!;!m&vwaOomP@gTXXIKosDjv_K0W*+v}9$Yzk68eOTHm>Y-0xU8*buFvaP zb5LzY$z?Q=^|$ib#Q5Qs&|#Mp{bdk+6qHRDP{A%W`-k0FqzchPtAS z9w%@9BuVvvfXY0eK^W5&H8zM%Mi(I`QV9jx`Tqd6(UdY_BpP81oypxAWd&75HC+Aw z0JZlNTxE38Sw}f>3==4n%K%qd5%h%fHd;4&(m61Okc&tyhaN;w1bh`xb(d9brWwQ3 z9dnhPQG^t6c+|<&89@hKVfyjyTuu(tQY2R5@^$=wjTSc`{Z8VWdRb5pS(LsFj{{U>h*Z;%-B@qAv00RI5 z0|5a60RRI4000015fC9UAVDxuVL)+EU~qwv|Jncu0RsU6KM?-_;7k7i1wZ|ASN;!9 ze8`?N`R*Psjm+2e^W)U< zG^>O@W;D@t;T|RDf=|xlnQb(+n~EyO6RiDSHJc^!Z6!rt8f`j;>;6jQ%^PnMUj=e_ zw%)TYT7{WP;r9v0v^RyZDsX$tQ@BG<2tp?GEJ8dY;+GvEaoq|p_>7m7@@hNa)W)35 zX0VyJhwp+{DE|OUFt&;tLeF=dcsy2L3S1^RiYz~sLQ(jnmOW-@kK)wASX6a~;T}1e zc2l^Trpr7MWW>s2r$~k3ns{%}nMQPK78Je|MBX)&{{V#x!h8--hfg+*c&7%^Ds*h9 zp78~tw8x)VOWr@hp)X~x9*6Cn5r}N8tZ9gD8|yY~HeDCO;(4zf38nINcyON!ZTwpG zlt--WAz}$kMoHnK!4>*H)BGm7E$CwVD`Fcc%LPMgp*F-JsGEHtkug4jHI%T{DLzU! zJlvj6O=Y8>0&Q?^{B)FJcN}X7U%9xHkdM*aZ1f^EG@|@XtEr`0c|{Txu5=3DF|4lAL4S#Ig-| zC+V7OYB+@ch<%a4*`XT+7?u!2i{8sVIICrCAGoo4+5+=w>Aqj*MzhhJ|Qc<{Y5&9WnAF?T3*!Dp}i4d^~n>;g6UpiW%CyC)si5Unn$B8u8RZZUhksT@DL)sf{r^gM?kds>I=aof}Xz3tbe&!njEw^RJgv zEhIHO8D{}!g6&w=!8km`x zZX%v7njO#M3E~vcm~iNo!c@3yTQAX<1-%+@sbdiWv8DERaY{GyL3lh-q9MI-W9|ps zA@rW6SA(?;2k2Pu4SOceAu1&Y_l8Vpqjw1Bd|ILqu##g@j$L9EW$29;6Bj)?Jh&)| z{{WFvXsB#O)I!@B^n^q5UJHK{UTb52SvoY~#D*;~IWgV#5v=98@~g~ z#JEU!PV|aqnqosu5Sqkqhu$S=6m^S&RQeNw-i-NJa(gMS~a`#liU!Zbm&;zZ0VkgE(~5w?(!i6Jd$ zrmAA=WORfRr6}DV8$(J9xK9>|mk^AEdAPfRLsx=usg&rJhKMkfBYZ26Y@9c;EJ2}( z5e`fyK`c2wSDA+j)9_m~Q7f8Ur9{wd5)w#a6ErG7kV1*CV`*TucsGhsHYbGfb%Y`Y zrKHBE?qVkJ<|G=>h;5@bwkOGnVQVO8M8$>v8c}n> zmkzRABU29z1h%2JMkXvGjC>DBflK8Vis_Q>FAJM&=k= zI_Q^zvrU)4SM(s*>(Gl7uhCi(W12k_*SKu0@fvhYqk@@!%7iq;<4ll`Vx2-!eh(7} za}#EoPYGX+DdeZTNr*jSuVXCXu+a>AYGC{*+eVQl8-=|;BU_AuCWp}W5SS!tjZFol zAgGv$ZI$U1L)Ql46d`YCbadK9^l2+7sRl2iu8S2Rx|lX>*du`y5_-heAkf_&VDPe= zM@FZ?F>=J49=zHhr6E{`vH=*~A#lxsuRDik8nLRHA}D6wjv^@AW`Xb@bRQZ zgjZwDpvJQ{AwN!@Bi?&ANgj|)xH)u5MtgzH*AnD5K8a7!;kG?pCInh0JqA$_o!>>I z7%8ZHu*=&Me`YKq40{;3I|E}TIuJqJx?PCb?chZlh)9GZh(cm}*wUptn#3|uuLFDV zYJAfiVqEv5#4OmxtqxQWs~&`-6*Y&z(F5DBL?lEy=-O;nyM#xvKiK~9qj760Y=V@3 zgdqu(Pb4rk^v|@(F>YoN2yKMCF2b$RZ-e9#%{De6v{X?)4MTs-^v8yiVOn9mz7N$9 z=@d^8gv%M(KH!(5^E-MJXkWuOmL*8d;Uhu2p`c8}i?Qo$)e{6V5n&gy* zO1bdpgGfX@VhxN!4k6^JQJ?ZEP_Bnt_#p^Mh)Ib_hVikhj*}C?jp>kj90({(QB8U_ z=+l8Tj7g-}x6w(W+-nXLq)`+c4G7j|CMNziA#8q4jH6=<==B7qjIA6Gx%ZUU2@Nt1 zxFr^5ADkXx1h7a$jAd<=dL0@?){1K-A`?BgM3CfnV}32t5)FGo&AyEuhcdkN^WP?o zO>@|l3wmRbltI)}Oj(|8IgLDf@N2^1VhBirV9@#q+3q8qyee82%JemKT2Mj`u#;oL zWo+Cj{$iaLE)#=wo%o-JGT7kuEVTqh9FQ{w`fy12RDW~uCVA*fS`fNCvSML(30%El zqG*_cLR%!rBqktTI8}Ryup%MEO;9;O|z{RG0KQ-{45N;&d0RxcO!qC<06(ea5G??X4?h)EG z@JBRU*tSxM2_yU##S_{1#|Mq(zI?A6D4)?zlK3f)MbOs=(LD)BXl6ZvG{zB6_FEf5 zw@<-IC$Te+9p+XRQ=SOWXt7PQeUOnz+SURZ*qtE0<7;n>WzkZG+%yF=hBva|;_6{P z3voaGSl6?1#dRFriQ})~PrTZ;# znY-J;4Go1PMUuiTmGmS~sR641=s*|0MYhBm7vV{bM;MsP1Wy^P#>D10HRCmY3QVYR znj^v}ss4#?Lq|wai3E>Xd?WNY(i{SYhqFVeF<((cDWYY1VpzSyOM?ZL8ybnpM!2Us z5^vCr@d?|ZQ$Bv3jK?EVr~sb1c?M_Igo#XO_K{UN%RT^6;mjicEv zswqj5D7C0bdmWgSp$%<}d>0}_HklJcYW|I6ij$(L8XFh(B=lUyyhllhQv+ksMnShk zkhzGy@|9zIHe%UukmBcp81S4TzHn}v9K^V1PgrWA4#6ZP4WbEsi4DdgJU;}Oh=zp? z#5l^GDG+RQsL+TpL>fktf*kZWdT?73XqAczM3N**^gmHgr6`z#V;2pML8mCyBxK%( zriZ-fdT2(_oXX$va}R1^RfkZ>G$d$9gGb>6g$S_F-86*I+7LG>b1;YKYH`u558Nk8 zu~BkSMR6DXBJTM}?(7v3Pz1gO(OC!F_` zUdQ>1iwZ?Xs~)ouhDV7wK8#DvY-$iWb@9xx4os{kyh043<04u{$FT@a2~vv$iAfOE z6dS@l4x^(f%&}p=of>S=*qEasD9CAM zkeN$E#Kj!+Qjh32t%)hHp|Yab$)X&|FAbY`L(c|TnC=${Ip{;p#5m517sQp!%J_68 zd6tB5wIATeFtFBWo#3IukwSk$Wuj*~Hirg?gpjaXdJ{Gzy@~G=3Ctu-i;Jj1!8ncL99X5K*M}Dy#1#!}LX@S^s7PI6Qs~#LdK-CYg5=8K z87D`Cu7?&AV_!1ge~RWFu?>h!Sd|k(!(vpASyL4gRGt#5Y&8Vn$&QT>f^ieYDls_7 zf4&ggLxpgaIq;$7LTsK1W)vSs@#{Jsv9Dv9NO2)5Cf5kik)$;o8i%6g4?G9F8=~1KidPza7MS$PuY504fmy0RaF50RRF50RsdB0RR9201*%&F+m_v zFkx{(fss(5vC&|`@Noay00;pB0RcY{{{Z3IZs0e*N0!;Jo{Rfky)xORBMiaE-lQ#!ZhICUKc$-L_f5XC5Wxw?&1S!8thc6YeJius5spj9o!v zD0v9)jFTmccVH*ki5X=70I4jqhrTh(EV2&!kee+-ESn^^drDQtPVSDoZ%vFpKO~f$cDSRyn}ec)FD006U!Si>dPdvsOsuB<)M^%NgpGp z2U7*!OSu`5zY9-9eWV^S@;7^o$q8=B7$*-e*}uK|7AH__{_&PL^&{*Lv!Cd)^d280 zgFeR@z!QX>U}jxoG=3eA%C75jq)eKHhA zvw(ujB*T(OBHkPeS(eR_?{@Q_iFL*@KWvZbc_Z6X1``4=t8MB(yS_Hd?SkE4KF3xb z-`XCFI+I*r`nx@GN7xRFL@y;1j`mQ=j3aEHKTu2eBy47}4B2&~Xi1#q8yna1=MM0Z zSjQgE7+n_g)r`qF9thReNjcoMGwi1(o==d-Ue{Ph8mu@Ezd2GJBP{Ly13nB zF{viDWDayf^AXe$r2e*!Ebe0J#p7>q?ni2m^}Pgk8U8)rMc8G@+h*$!j0Q|+`8v{Ee_;BjJM?F zrb|B`KO!@p=1ky0p8Vx_^45EaE#xfOTLbI^jkeA@uw=%_?no@-ZGd>{M3ayf4Uww@ zq5Y~#oxE9q{WeH+eI)?>gtIs7bbD-zcxldJm667TV3L=il{if|o7aR%D<)8&V^NM2<9XCyvZ77dTyaL90A!W))ZWDl|*Abp+* znAah+BzrsA(lEzMp>nt4+(6if5VNU&m z84mSmXj}C4Aa&A8!XBmA7EH7FkHcc>OnD0+?3*qNFY{|?I-iV7iPVgA>IX|lfzoXx z8U4unJY+4|Ys54g(#-Du@Eu?ozmxh9JRmVHxXJWxmU?=Om_d7M=K>K8*#Px_L^=Lo zGW{;}`5zG@TSR43)PwXdt(*Hzb&w$TVea+W0D6OC3H>-F-{RlT$39*=fM6w^@HwI} z-p+cmdoA!wQkj*d0IlY=2i@SGX{zolE*k9n1LI zeTUJ1#kIvh@phh{)!*2gp?eyK?CG#(T4;n=Dc-4WH!K-4WQ^YWB@nuM3#nJu`QG(u z)xFwEd3ETWvSxoiS=Sim*>?%EB5!bDvms9{dbW}{@g@RkGYk<;{GJq{0O6@c#3Z6v)wc`AcE zBtFCYk~o<)EcNzM*&Fs}x62Q~p3Wln!_v>d&OX_O{gv?pWY6;bIUzP#AEfKYmaNaP ztXT-el4jhT?(FBOv&oqUwwZmkJVNlgHA3`HV1zo5`)>!bKTEV*B(lg~B71|pjdj3t z-K?Jn8y@WjLS3Cgy!J}A6Si<-4sGZmu+2}2!L0nQ#|U~ddlMoi?6=DY$?ntlF1^b? zv^fiwJSKk*{wvquEC%e9{izLya)7o+xhokP323$#e5^&Yo~OD zq`EHhH=@Dz$iTH?`3#nN<6_xo=A69coP-ayK+SL1hBlXL8AjQKJC(s8U8K)L{{WkA zm+EEnJAywp{{YV))5%Y=@D@P(Xfvy2-f~^Kp0?D?hYq9ZBVlh`7mY#re?u?UH4Egb z&**_x**j-w^U?!t9;kCUCJ^hS7?9nWv}1OX@J0}qaJ$t;4yjaPk+GM*EwF` z9Pcc(`^jXB0zUni`X0*b9*EGI0383ql6YW|s--TlJP za$(%8)^X8g+uLO7%yuKL;60~ydEogzk6a-7$jNqEPt|~s@oOn4*j)wHoPC$51Y2bO zP~^Pf*SN?fJRC2ME#Kq-so>6{2nPt$*?q6qgC@(^;B7a81F3^8Jy{9Ve(-o~K29xg z#f6mF+&zdQIM2e!Cj(|-e|y(nTZlcr7k5Z8IK@0{{lw8H^JOvEK1s8J13Q=Gbv@g4 z_@BZYWDd#P5o3bRGTUapOTVUg!VPi(b>S|#!Qe0Im@F)T08Iw6Pmc*A*_~S2S+FD< zq=A#xW4Xst&YQ_}HN0e~_rENBy}g&HU=}lDI5oHJurY=^+B=aLK{!Bn3hqi8NRCN8 zLSbaTf`gPo2y1L zZ;+#=Q{;7G$xV)%uO8qk9r*$~<1k?-QlAL@;B;GGW%`y7@htLNYwV`p?VrtelOf4_ zo;|>hJd^w2i+@(Ek6$7rVOgo&_in33`ZvgjcH#Q!#82_V_hAg#up2CLHTYAghq?G@ zx-j9*@Sm1C%Y9bH~Qw^{yj6zr9IV(KLwz%~iUC&3xS9?PMg*>U7MXwMuG z+44#+*nj(dme~`buU32y`GGn;OIQz6{$ad!I_u@B!0!PboPlOP=4acquy}|2$H;%) zaduzfU0>d^K7s|*&ZKSV8y>Ng;EbNb{%@)>a1-oV3lj^&r!5lh_~r2oOo%P-Z7okO z?11+pJ=rqLa#fo@i>&+}0QbddZde2?na;N2zL(fsJJI{dcF z?fp_&KBPzCnS6zOPx}7=-IqILc0G*0xi9+}pE5sR`>}n0#k;-8S$f{C(cLmAJ25yN z*$i3aUx{zFJ!kh0X%cq><-By3+fU`s;0qvv3j+%XgTf=zV*dbKMU&S0TXhlc8i(q} z{{Xgn#OTla!T$hZElm&7)%~u~Y5Q0M{{XqPK)=l|`2zm{y>9pGdh0j!e1m7zv#geX zWBP4_{`!bt1mZan*MI%{l7RB)PYbyE#%u5D{{YBYe?YWQkrgq{X5KSWU2i!`19pXBoE_;83IkF)LF+y*6t=rDsPcFcwJ{Z5%< zA1%LYx>?iY@YLz7un&^P+PF#(_+1-f69cSzKXc4};&%LzY(H}XcF_d_6>s}m*8Y?G zdcEoktG81+7jRw4y543l^p?O*?(I)ruKhryhKH%lAzb-?YtJu|4zGa=@_aZ0Bd=_H zIsqYcLMfcVa95gj-_TpFuQXL8*Jp! z>U4XIgX1DFVR!JlHM8|>8)5uJd1RS9u(B-uKz)J<0g^-)Uf#>sAl6r5Z19MLA0++X z*fXd^uTOA!$U%GEzTIEnWA`e^^(->y<_B5fc^f!~`53^Ra8TYh*cZzl;5`UEOC4Ym z0v~Sc{I$C#Tht)BtP`kF?WL;jLj8=JCRrLtwnk2T{IVdMEa5D(CJO>`P)Q8$8J2cE zO7~#WG-nnuG1am@Lm@A#JUYLv{TE+utdEUlyRqSHZM9}(%foK>gC=F7Q|t?7dkL7z zNsNjQ5%)ev0z@85PW!flR-I$J3CCm%3;_)+XK;4}kL~1@&z9dK8TP_|0N=!5;hWu$ zmR?<^%Xf@{4fji#d~EfBxWJyBUH<^f5_HmM%emaLAG>?E_W|Ty#^16;iOWT?wwCPV zy+^R6_qYTx?X^L%StoLUZHCC&?VA*<)Dzoz+*$;KSxchMw}dS<=$ZcjS4XmZM@*NG zSM7sp=#}+qLe9xm-GMuOC2e14^2;pijQqDF&H#fHyJ=+OF2~63eIfPj`oKH8ABnr3 zaKL(5k9SklF7{FlH&>9I7iBH7)F$2r9N;~(+hJ+)$9NZqh0E38M0*5b)SaIokWX&T z`7V01QwFmux7(xM2Y$(v?1Rey&qn#YBW2%btnPB@A4%)U8(-l#dC0o6hVy0tKh$Vg zcEB$FSvTWsP@@1pyM9acVCnM1x|X(V@E>Ovxeu17mvMfJqnB}Z9=QlI6XcoIn`8tb z(oVMR;W+Lb0hw(&y$b`whDD8pBit_a2dQ6n+GTo#ZO?D$%buR)uTn?vI>T+`raO{s z{MUHcVcgkd5;w=?`yqB;ZM~DJbv{%w&rRq$OX6YuOKw0n#`j4iu*tJv9`9X6*_FG! z$KfY%NJ;v$gdLZsowf!mwNLwdEWjAa)Fzk%zw7t$CTk(A(sPEZ+ zCC}O(<+E9Xjk@D_-{K)lKCy?e4%)DAFK@}ryB)WNn;h;jbne5^uy5O;+?488{V%x- zZp!HjYA3gPgpzQ($qr7xEz5ZJK`@^S=U=rGPV`AX)rQ}xho~C}@OT8ryVC z!qm;2hgthC2uv8-GnAt?2HYFen_>polE%!{TA-5 zjHn*V(f9Re83Mcz`#YJK@imK<{@PE-pX%RJZ(y03B$iocR&bmq`3>@4$qsFgtQUlJ z;SS1K8yG#oyJwj z&3t3tq9$7uh-6!Zuv-=@F#Xm-VOc^<5>GA;FguTzFdb>N|1dxnkdKm?8 zvaeEMH^^_Xi=ybpUWJB4M9-*00(ac|IO)KF-PYE4c?UvWv@7%I~G9w0C z)Y~0*G%L~EY0)6|N$#YAAwa!L$U}FHWc@>Zzgo{x9>OK)_b$W3EClqNJ&+l9&n@mm z!4IqQb0j`+EWIxN5*v*2S7bF*v?dp^l)ykJi*&NGs1#$gzO2#%pzF+ayqXhKUb zcr!SYBHlZ^z2)F#jpCD8KOEpv?U97==^_iXVXS{ktV-DSA-#DaI)*#xNIkb<(P2nu zP5xGoVI~$9q+r+$zodD(gmm2kMHKS=(&;)j#D%K?B%M!_1LcckU7H}gI9P||t4}F* zvA|gda3p5_(!z1G<&mj?&zx@^^6~EmM@_qIv-o79Aq^$^hw?s{Mb;=*lpb2U zhdsT6v4=!0&-X6Bt4wSq+x(+|M}FViB60gWZQZ$J;|K3{-r2XaUy$;E@`Pn#W~LPT zYxt#>Sr%Pe2E-GGa_#N*&Kp_I~{`v*Wj4m@WI<)y9@K|V-Iy2$ay}Pk*gcto>QJ|Bl(yH) z8#v2(%Rel<;ds}%hn<8ww~gbwZDWQc4A_}J9B(gdx}5pJxA~m>P=fC5@*5!7JOD(D zJ)OblV%5kDdm-$E^cEib4jV8~$K0)Ju3jNsd{= z#&f)%j#)f}IQ$FV2686bcp>1tIVF+7t1@^Tx}VIqBNIMLGaBMq%V0<^WXN|n#~S|T zI$74XGx|%_*>5|K=?|+`4DMZUu-|XDM$3m+{I)EnH{FxQFcX$r)bXE&ehrKe&N6PL zK5??_@zaAo^O4j%X33F@kjM|Rey*n8<>$ZPEIv*m)8u_4e=QpsOVx~EcVPBKu>BI= zO1)$@4Xy34$Oe-7%358%NFKm>Eqb-=d?XK$jbWI;x?ZPFPAzkQmxRJ+_{accD|&|z~J z?cv+Oo3b~kQ_C6LrbnG!pXtVZx_N5NkUTn+Wrcx#u^@Q~_7mmWz&e7E z>w(vnJhc)K!uBUbH?!5Y^2S@=Eth8|M{~eF&t85T#y7dm>w%C#K5|981c~L`?+9o5 z1Fy$pjoTxrnU0jClj?iS}Icg_jGpvy4s*&JrxMBdOz4qV61y zypi@zc-w9KeoOUvHrm_4!C?llyk`bZf2DkgbDsDtl@IRt7joMUdwVhKj{UTm&hfFl zJ;c~uCxZFQ#@^rOhHd0bym=PcJg=|F;5)QgmtQO%$~j07goD;m_bLE&YC7XW%x! zV55e!x z`fl^*;C?%7Mt%PPSbjUs{{YkS_w(PwQ}|!&`L}-q@!!LL)fWE%uHXIv|HJ?%5CH)I z0s;a80R;g80RaF2009vIAu&NwVR3{I{g(1UoLf%jpv+p z-Db#~Cd?8f-%d-1<#B6q_?cQRt^IX7xG7+9*>V*WB1xu)u#Lf;E=H&16MN+(P2we|2$lGrG zXB=Xzs=^H~ZYA_ms}@BQjjR zch~98cZ#a2;@~4e3%>neD9z4u=v@4{{V6`bIL@vI8<@KFfqM+@jmEV$#}rJ)?R)w>-0Wn=@))6 zCUaoXzxO!GyyrVqzrJ{1=Oy6dY{lp24lu#A28p^(Zxinl(_yCO_{9(+X&i?ZYroOQ zoL|sYIx*LHGzndAU!k{Z9mDiUu!PYwUsFucjpwh`29k(cM>)95t3Blp zo#f#CI(Pa3rFDAeJ>>RJc?o|Q#yMDTHxQ5oJSLx$5^&;8CjS6Ik@t*UU%c0hmyT{c z;CDFvP91&E)%e8=MLalp!n!lOzXlvRImwVIv2@O}a)z(9H?>zqi_;Dh~ zH~L(hxiovnoi`Dc-a>QEEo;sw>`Bad{W|9!av8O)>xToM(ZrrO{{Z)T-V{OrW6S6D znLk7;nrCsxlTqkBnY2H0w8OyC@ODBvwzo3>I)&BtV_lC#B z+i$#mX53XPyk9sK1WvP?HHo2n_k%N<_kqsLLc7<-Xdz#F{CyH-?qhFl7;=K2f zMg0PR-(pNrbZnc)VM9py#%mq^mzk@beuIYoaN@7Taw{JVk$E;p-Zv`H{%-$2;T$;D*^;OQUOg~HGJ>qKr0C@Sk!x?Vo zE~ay&3nyWX@?%5f&ly3-ib135^N$BV8M*5?`Omz2pP$kZ zY4M*p7xXCrY~|F%bVPaHY$2j-z;^Ux*B=;e`sX2&XIC||7Ms!$;mgeL24|crqavxT zd&8aUJ!|F8a>QxAvx?7K$^QW3oLi)DLp%Ll1rtc;%eRbq&c9LK1<~HKt4n`Q{{T&g zCyC(1QeHQF;!F{gelTYrq|=j6u5t5&r$+MZN1ku#7&K}e>j&O+@BaYD&L1-an&*w) z$9dK3^f7ML_grQ-yKw@>`^E||0=5TO3!_vyK5;JwFQX45;+oUX&*<~N(LQo$-fY{W z#oLbt@2?o~odx>I80Q)BfOZ8OpEz#>;A@{aO({b<))~)u2ZAa`?8%Bg&p9}q{;v-A zp7V<`_lA^4pN!i1a2Y(bnml9~I$-bTVnXw5}H&G&}g{-4VQ8Yua}EgdD^E!wN^6S{mD&xebMd_Eiq zf`X#2KKnlL^_z2ZTw$%yj|JaZPi8u9CGQo1>G;J0u_n9CJ`HD$^MQERjGM#@0oi5G zH2v^`LrQ_itWuS&4vp^l)*!UHdy&bWV&aEleBl742WOmUkP4yy0C89gCw_1*828WU zlm7t6jI0z;>bWoH)&M{tZx3!dbmV!OxMrsvF}n))>k< z9cL~kvz&z4*6H8r-tiY@#TY>euJf5dzll)^h@2?%}R- z#BTBGV%os;ah9gBcDm>E0s&3{tB)SsKcUXCkeNZT$4~2dfrAsgU76no6(f=V0GyL+ z$Lp*F5h+iMPtg44hCQquk}3VUyuYI!+-|t-xQLvc9XvVnmh*y}2h)bM%EmRQX#KP2 z8Nb^hUVLIPIO?9Te?&$m1^)oQr&vziWATNc#kw4W!j$~svVGssIrE&l$5Opuh-o{( z1Pav(3X(B=1%xix(L zlYHi{KX@4Go`YL8%f~oNwG8fCS#^Bkpe~14v<-LGa;y7gB0y+&zVLa%JnJb(cvnFl zuf}z7M4BOezx>OU9xuiN&N?RD`286U?|2Jm7#O&+6hnGo8BU5{jMgIG)^$#u6r*ZzCm=p zuQ&kMvtY;+S84`4{*}_Q*_xGh3hQo4=iYbvTGtu^5b@4lJ!aMh-jCBl^W*;j!Uc4@ z-+!m90L*e(e@m@#)(<;O8$$eNwEh18OO%+-mCiAFFF0?{#th?F-tnc<%5Pf6V5@73 zTJwaU7&O<{>2-$no&Nv~-x$bgy| zu`r_)Xi?X!KTE^IdBLO3O0yGPmcJ%(Z|Ee=Q9hjKIijX^m<=RD^plajWaY%_>Lf?g zj|)sl5SkyB`j{h)d;L4xlBk2D#&G$@m3RY=j6gJw{_(EJ@4SE+f@^B6(`Ki5jX?Y4 z&Rp}e^hrE9#iMcOoCIXmGvTZv&~@Kg6&o)T5@{M_F^cj@F>g#lAOY$P2KWq?0=Oq$ z6G{4Vd|;`faUL;XloBbgCwT2|E<#dLpBQ)Xgh1?Uc*4{bBjRM$>dadA>f|nouG~Jb zWwg*dehfW#lbdc7AmA~@B@A>qv99~gZG971zH`oX z>pbrQ*f~0{Z&>bA+Pq?Ox(v8&uS3(nyqI-fg4ySsTAJSd;cB7N`fJDUoj$VmNj2{# zC;$*pJYpa*j?4s)1lHbK^ZsE>PKe;s-l;zDcdl^|6w}5CJ1^<6L8{lRT9HG$#sW}i zYeVY++Wy&Dx7D~5^8_0WuZ@3sH~_&OOm^!CZWu+ZHt)|l0bSTz4|!I0;Sgchm!tlCufQPS za(Zw#t>lf~J90Gn&LZ5a9NM_VIg0$pShSuq&IQ^eJ{NpLWvqF0?Ru;kUoDg}7Z zF_ITh0Pmgh@rAcommGDCP45yZO=Z;ApLtEstk~;L3L4YXe@hHKc*(9Y z{akZXyZRS{J^p|s4K9v7VaFVPj=LRokt2thhQ4#GL{Y^6xg&vHbHZ;H=YOHJq7kK+ ze7qdvmf@}QI&ebZJ2=7fyYu*L~#Xb%Qo@jXrQ*6SH#;TT9k2K0RdT*Xew5 zoKSPV94;Aue4Bfi3}aJT@4S_EadzhxJx*|Ah9TDuy<%|9dwImw4L?>Tf$XD2uLWx{}#%OL6bibtYoT5|aXE}X`e(^*AK;LfuPtg;cm%<8McsSz( z8$ocQ5LUJG<5&zN6b=qA?~`!R?=4n~qzGq#^j~=R)b*YE%H9ac=d4wGR_OgW5_9Cn zV64325ENn#S3$kx2a_;h90Tg%QkXK0KCqEsIN^*+0W`Oq*4g{v-c4qLY1c2#R3HfU zKinhb(}eJ16b<;+FqbBP{xA?J2x^B<;J_`tS^_=KSep_@!;maq1vfbJCK;j-RKC7( z<;qS1Qk3#{h{HnbXG-&|MkL_mo$nf`SEW4ROM_8kzP0Nmq=tyw^7Gb8tF_^_9;=ST zcH}jJ2$!-+ls|3dLSk;J0t?MTrn_+}QlKM~rRrsE>gLrzHZgQ--2O1;podr`E~YGz z`s)Qqw{7bV#B)K}FlYz5JY~VkvkkV*#{jLqr<_s(F0I2tqQ38p>Yed~1|2?!U%pm` z*>EgFLv8)=1ua!HYng(eM>)eC^Osx-^La6MjVAE*eD$9=b9u97+#YdBAW-bGOMzu_ z>NKA??5@ZZ#A$bhPhr5p*~iWbH00TrQhc|Z5yrZG=gs{@1{}TP&h+KjueJRMWgUqy zqyTE0AJcMPas3hlsO6m)%}1VnVisE%57@++J^qM@s?~eVUhx6C@{@k>fne@D=PM=S zcmgE}!RI{ws^`675v=;nUszM>iw_LmQ2;8#gDJ1y0ZbMfo($s?#0#d9*S?%!L{1Tt z9{UUw9_A%SVDEpR7R4AIall{)(VU=8H^y&cj*CoX)iFQ{ge{}Kzd(Z#qI3RWr0!jQ z3?jx-I52kaDv*=LMqCU30L(khX&^o@k|gL;^|^-_tlf|W=nf1T6p7=k?+di1rVp%& zU1DgMyB!AJec-}^!oG}6fwl3R4N>uq_CqfWAeEJC%VE)wh6UR$31}0Cyt5LbjW`Sy zCgF_{THwO%2Nc0)hi}-No^}ZNxx4 zJ2G*Gg+0i-J>`U+!FtCC70D)qZYMY)0iv*aD`s)RafdtO^r~W3{xh~TNTK<}GFTnC z%izIcT|DD7>Qkrx0C3UoJYda^&M~b{@JRC2a2vhq9(R@1M8vEctQL~bb2yJHF$1mX z`oW+?dJmg~i0@4l`^0nx^!G8FUElt(ozVmh8t0t4xK!9a3;FzDMOCKJ+I{09*M<+&2?b&e(psm+f%B{qk5KHZh_o#vvKx}FJG$)K&d~_l3z_%`|G8!+8|iavDckJQxO*R7T0o%b`ao(%Eu!h%iG+ zIadh1sz#Sp?-472TaT3TWof8ZIbBXX7|c~C0?*|zP@a6}CjmEbeg@EV+t%hCDwQeI zmdfP|4$mKW%WV$5J>@T3y5kz%Imd+R$$R+8!0$9~&Eo-=q1)psq)Po{<6(rm#I&~p zO2as4>GhXV9NssA#OcS3`8N8WptU=oKCvv7a;>=E0y$?MGn{Z_n*RXt*EsbYq=~!D z>lIg3di@T~*MFyb%j+IK@z~oY@*#TZ%U$p18GpwK<|nMWnLEd;^?|$wKC$IIW2}Qw zqDT3{-+459!&hz8O!~m`;t&*Uc&D5;F*&)TPRZ5=z$-TOg>cSOwSq&socD?0+0G)A z)vVK&RQknjxv1584vyTQBM6J_bRRg9{{Xn07rfRf{iB zgzp4&elx7tT3hz_n+bQu3~{$8`5c3#4lk^9*dO016)nB*SZUtpoC`O+JMrAx(Sk>j zPQ5;65L>z7z*;URJ>aBtw~Qs+W7=->j>$-65m&8y!rL^V2Ev+MUbBut*I%H;{Wm!S z;B)VI+ZV$`Py2(d&UY1RIurBCcI%_1!hfMd}YB1kl1N?^^g!pa9ge+ z&o27J6dE$K3|)wZ1Rx-ZvHt*!K#l@Uwu!;?;l;Wj z{ur0CPRy=gEKa$&9z~`Tou@Ci7nac7JYMkN3>CA{`qr^hYjhe1t>lVyQ^_3C&JTEm zvUm-^1Ou{(asd)*?eNJ)>X`iR0Nzb+U1stcRzkXe(vRl51j z8FHpL0tDmx$mzl4mw23n`7i+RCk*!Uf-PamF-ubG;{*{(c*V0^Vyzy4C@EdLG;bwL zwL*+lPtH8SzdU3odQm-|90zD<=Pp02=hhM`r-8qmQP>0m8De0peiEs9Co0( z5|HWdc(uat@Ibrp`0o^QwcYkSIIcXqzoyKF&icizXz1~V>d)Y0{F4}s1iHv*J8qjX zK_F`+23X3%_M7KC#R3u5+M~xA)^e)gF9!v18S5F&I=J#O$Y{B%&b;B@8!}NaTCMSp zY9Xg$j~TL6ASev5M3REyGy)bb@Yj$FR$!^Y*|)|km0IQY<9C#5$Zg6|MJ-@pVLae1 zd}O<6Iq%Ln%qin|9`jr{xakCXxm1@D1>=K`>d+Q|BZ|$5q>nT*khK29v6A?6s z%kzjh55^#hJ%?ZOJAHU@govm*_tropN0m-*-a8m2bVKotPyj2#cz7K}*AIA}lT|-- zJ>$%AP#l3~{g@z{`OCE#ddZ{K+mw;fz~OYAJU1nimjOH{IPSIvjl8( zoVbQ3Fu4lrG-T6v#E!cqsE^cWDPz#Rej#8MEgmotntH)(KRPlO21<8NR+hO`cHAB-_Gk zCa!+ir&y692w|r5kpFM@reVP0Tl6!3g~i% zE5;4kr*h}5`_7IohC(i-m%dNGsR zjS@9X*%t~W(WGkUEO+sg5aaW;Y%l$mbwArU}Ek~P0GSb;5M7lY?GU>!am(~LR1P&NgA z#sL6*;t~M54W6-ZuUjh~rNhVG0;$9zZtM8PAb=^!@vk|l)PWr%lbXcf^NDl-)`^wK zlFD;i-^NKCz*nzkCe~}6;xh1=h>YU}7UQ@A%YIp#PDHW8c%;6H==keCGe{k|woNjs zqt`t+D>Ud@<=2M=8$9-!2F+s#N=36J!$NV07oIgR(Qgt|KkgV>29?;lzrHBD5U@(; zPk1Q`Yojs_c@e7r0P)5-oN$hv@2niP1*r3e%F0wwidT1^ypK6 zwQgS<=K>PM-F4p%EoLxPlTzwo$b? z#M6a!i0sb)069{-R-8XL@S!J9I9EZtwE4}np{|5HIIK#AI_Got%cj#?oZzVl?9)y$ zzz7F`;{(qdCh_ZM9b%k3JVLKr^WF__i!Y4xg95szSuxnr<(95>&Pu?b8hLe{Gg`>h#%@GJh~h;k{Cy!=l=#QskG!RAzBH*bTkesh-*81iJ=(~r(9fFT>8*IFCXyt;UEzHr$tO zLdvb|>mv;UM5}mD&N+=E5j>^>0Twt9jI)ReMeY3W7Q}PYOqY*A!HOY66UJD*;FET9 ztZBEr3btO4IB7C&A21hrCOp9Oe07%eZv5d%q&eO>nSpfM%dCwX5EAjnI4H4Cyk$N& zg(2)50`j3|KA#LG@}pg4+&;m0)aGDHt4EpN?~Z^Mjdzk&Ig=1(o4JoI1;Hp#*k6;A z`8TU6h^f}w50~#1<5Au`a$@rQ@s66{&0Di-)Ur*4D zirMqWj7C-sI*Gxqv2e#sq4~puZRwrllukhpw;eOCA`*v(Bj+2pYP@;JN{|y^-J^mP zHlKGGUW8*pdO?u&ilS=}aO>|jhRwQlFrq6^p2>K8JmeE-gdplX++;T1%q|K99S#2g zGl%906qVl3j4;$Tu^pbUM_@K1p!Q;6MIn}Sb;dr^rpqyJhOlT6qDJUOBgQzOB!vy_ zdo;XZB{h~Q=5>ST#qJ&^GRFz9yQlVsI+ck-qVtP2Ex>lO$3J)!!tfJ>$zGJusM>`* zbF++O7Jkz=Q0y3ka^W>n;p*cSBT+zaUYsEi8v?h2vPx3msF&5o(_-lyG!aXTltFhM zu=?J&4h$j`6^ZOFZ7Fo%hrldI##!0SyFM9 z2(1ARl#hI27*aptV$?9{)=o#6n>0N0i4^DKE2W_VuKL&37bw_`Uh!hZJo&+$VXT3= z{xW#E?Yv@nSDZ#wO;@)$6{NWZa&fFc`jKdZN?Rqe~3H^GNE z^W!`hUC+}<*INTc=lIQeZS^rgikl3N_mS}8gd}r#Auf)*WX442K3#q>l&$lf<2ua% zy)MU9_xgW06_mu{cavEaoOE2)Hjm&(c`#@aX-0Bdjn4rW>4ZG-gB%&{^@} z0IEP$zr{*{PfqK3^^FMPkxSx;@rE~d!f~%ZoKJbHqh1_lAT00-zl^m25CPxIBOBizU(t=q~W~M}4jo zgRRFDMRKwmz?!Dxy&v-qqEZ3x&A{|~j5|8N8^pVNhW`MZ3c{3)0Yve$CL46zW+PhF z>R$eHUmQ?(k_ zFb+97=QRglOQ6Q0DHUVo^MU|@{!#oGm=SJ8>G;HhMU?^8`2PUrRZ~p5kmla;zWx9y z;*qljJ2S;ke;CVEQ5UYUw~L@}Vg0!YQK=D#BCO<(jeFw`mX@)vSaHg$;5~W9Hi$8P zF`&9*;`i~y)={bxKAmnI+>!IOH9Cyawf6E@Bjclg1mP5knE#Nt;% zP&^keD3JgMtE?ItP`B#$mqE1f_Ii9~!fLBrr#zUF;0>Y-Jj|sk$Q=aMykt%X&xS1; z-7(3Fd3V;bdQq+;Ix>kU)~dwcFL?&_POiiI;Y8fB;C2^ZhC5A^4?)}D-a>gM-oEiZ z;N7#w#szw(YzQm+Vyc;{TH*#}hsHTKtK$?eX5*91$=G=eR1qFARYG&actuJHp`51p z$qHe(heO^IKnlJv7EhzhvpgW) zKpI~U87XaV7>E)H5_LMkQVepNJ~0+HBM;?|l$ghYe)%cMtlt=`Lv&JoVtC^KAVjcE z@m0?AKxixyJ~ELg74Q(m0m#!I-xqfcZsp@RBsTK$WnyYyAE*f|M=AG{SKP$V)8g#U zkBokG^POa92H~N1SwO5!4_8C7mP%lgPYLu8>hj}r#TTQq=q<;W9u%0 zr5_vT6e_o;{lb@ldNgCPkcW~we{5kRn zf$h;;A)**;njR7Hg`evdOXInXk)>1}E;VreZHbMEW zt)4o$eHB@3`^6-{s4(nKFmOu(PsRmYPd5s#Fz_!pz}=fYXC$}$;X^Y?F7TZF`Nnse z7zk#5*TaY%-y;Lb<19H?u5*GP4dmTNp6W0Zja?i|0ibWGjk)AnN8Py}ke zaXE*AKzTl;$z?CD@S(ylBYd9mVs!a6;Ne-tI6|^&j4_4 z*Nu0NQ|S_cxL#S0tx%y^E;>{r5VVJf-taj#kV|=TLZubF`*9C_rF*zss?!ve>kf^M23|m`Noo@3Gssf8s*SW69`s(opzA- zyNBRXF%f|B_?pB7#bWm)r(-do$T$S)Fzf*YYkmD==wa_d_{A**YeVVrl-!yH6{MbV zwM_*|yy4_$0X7i4b=D$az3X>;xZ@Bm77b69O0tVBR`UMl#3|k}n_q##)^U7;?`1xVX}q4buPy z!aPhAfjJGPow%Y(gH&|yotZ!{AGT|2yT%3)KvP$bUVgJ`zjzAkZX!$BoXLm@5u@W0 zm1?!iqei>Un{C^y(5b$+`f>+OK`*QYErXEntok#qx%~@G(C2<|T19+euC5JPq~Pm0 z*DqKlEuG92yWDXiB9E+Fy05&U-FkEPa4VBubDl9^04A>9et*oTd)5?@XLpQd_kG}j z@RZxXQsQ)CnDxf5kKX`OY3ntjM1{AJX|EV00IKWW7BfD;Ha%g$O|mB5awLGXaC~IA zKM*|n!p*RbN6hn#u%Jy{``#||MH2OZi-|NIT{wZD&z^D{ExL1Jof^e>2a~bz+$ruh z_mu$OISr|^UpNHR7Ng&si9t|s4zF3^LrC$5YNpjscOH$?gy-)$B5S_x-hJRTM030b zms>8MA2M2^QW4?60Zv=Li<_ zf=@x1KFq~r1N+7W16^Cb?4*vl!CD5IMh`~0xl^Xyco?;$kR6DRl7B8) z369l=#y9WLCe)wCEzKUFcnQ3ap-{U+1rXb$_{ZQy0|iA+Mh3GZcI55eS_Z1$7@dt5 zOpqa>h@_P8o&4sjDPx(<>@q13g+PxhE-eEA1Z_R}!4}9WID94`{#Oy$`C^$UoL$0| zQ>=;#fr202t{{g6-)?gaJWKU!OSM*zzCSHx&?E`X39H zyOLx?W_upd(SnZ^@p z1EUn4GzwB|>*2$=$ej+lWlSN1r!~#Wo8-C(j?w zb96L>_wVBnG%1kpglp#SIW;ljroVVs?qsZM3Ju3Snqx**e%_wNN=r))i)VMql~KnIQUhKLXeZw|R}o9VUb z57Gwk31PL&dBixK?yf`NP+b@23BhVrrFi((0S5wTe8X-ECI<-f9hg}pfQdPJZ}plm z8g73WlNiw|SJr8RWY1jl=N5-hV%;-dapmUZ9F%?K!L1#%-Arw8Y7P&7uSXegc*&qQ z=Mn_u-($uI-;X%qtnwUg^!mzC0F$Atf=aw5hda+WP4a74$OkR7?+`>H?6X+AW{i*o zywS#rJ?4j~#Tb-lu^eV9K-HtNb9l(2oN~t~grNTHvOw%XQa&AV@ro%%H!u;v4^ag> zKN#F=zs5yNCKxGA>)d1p94eDP}Oj|LZJwy7tB#_^070;|Gt_k`OHWxQNE_|BBB|D zf-obOYvYV#!Yg*8Ut7o#Rr2}3Nsf)tvo#q=2fR?k3Maf&1W4Wg02pq8d^lKi!QUo6 z`ZDr#;wd8V^^8MGAgklE43r{A!$v{fflXa6A6TS$w_Y)8q7#gG017c4YUi2RcDE={#=s*N;t=eREm`o+I&hd_ofO=pu-HqMtl#gwV zXz%ORJ_AZ!CiZ&B*QByy@~|9g_lmJzUA1yHnr*+d%CI1Zx3OA!cZR6cybqkT&3ORm z;wMP;u}hJHt2LwcU@v~AY-$_YrLPX7QIYEUIZX#C?{t<};E zI}~TEg3}J%F}-2d&qfl3+$nEZC=nus%#O?rg=+a61w7Db1pffJ1_9Fe{_sDQ!EM~j z{PeCqpB``oYXGC86GtuiZ;x4SvIWOFoW2Z?Wm>`RYdD}&`w!zULv<8-1H3}ck-`j< zw~Rd$2w^7*?dvrGR8bOZStKn$JHeu{dBjbiLm%cM1cQXVV;w38JjaX{D=5v(CGkjr z6?gZH6cSVphcxf3TwLExx+B5N%if~eRnb-Rr#7KMU+sYnp@ab8B#XmtIu-q(`SXi_ zMwI>72C|5D>-WZpEhIqlonXrdm@RyI$FVKs1;_vaac&cv{b4G46^RoIp307 zJbT0*aJIF0^k&fr4vlF(GEvwx2Q^&$V%R*Url|7s#yOTchnyquKouTbL+0XA=+aQW zJ!a@I2u4SALmGI#vWQ9${{X$XlyDrYJP%(uO=bl{vreOuS1C&CBz1`eZ=vm@41|wLsB0JaUT?Xpjf6ZZwQUSg?#*}$Uuf|&blY0FVY$h&^ z(rwGFh7<)AK=uWQ`!F7hatuMS;PHnO5`jqFJz>u9q!2dMk?{QDLPZ>i$%*@y3KDpg z<03m~k-sa(MO;H0$?ZL4`w;}_bpHT2ASCJ@zeMX(gg7m5Ay;!L!gQI)G!j#IJ67JN z3AmA~$(k`a1lIm@YPC^O?&~ed&P^9$IracO&&}9<$%|s=F4Gva>O_e?e~@( zRsFD35=pKc1VUElGZ9`*4`x$@dae_o?;*}}CoDd04ls1Y^e60bg{$LyV9*Um6+M~6 z0UEuyy-`Ltdc+5C3KO37i;>_w7DmjHs0a1Vr>Wt_t8dd@=40p5F&8uDQR z5-U?$2E;dQo(xe9gm1ixAv9ip?mczaeQ*iq%f>g56d-8xc;EGm z>{7l72zzz+n`{gku72@I{0Vs7E}i38G$mGc_ZzP8ldZHS?_INVE*Iq%D*)J&5NENR zAyy&R8L1*5MeNah7#6N{G$ZWF3doRMyz9m)5S06O?*=dmD_iBny+H-tc=*TxfEb3~ zoOdYI2}gzC$@dEfuO2W-{Y!nd^X~vUHMt9;2I+2TD&&-16GL%lI)*6-P*5Jo^7V!g zY$KUD&Jk59dhnfK3;+Zf753r+(W1D5kzP(c{9|_#o9Np7Onc@8R=kDh1ayI+Nw16n zpbr4?g+zo3BfMPKc@@%Kyv%ls+zPey-Z24?Q1(H4?=I|&?UL199&ssmhiu?67zc!S z9@~JBG;J5RoUB8Fy11vN*+vI3?aHN)LrZu60CDZ7AfH2*07l3xqeV`|^MsT%j0w~M zC!AWVVkgsm+)=F*R)>+o`N27~3k2Y|^5lkr3fRUEgzGFXT?6=HAO@UM(sPB2CYO!u zJYow6jz_H7RKQi_{{S#R(p_z#TKW?quy#GBXpV*2Dx-$dj`7~&TuG) zd-nUtl%+JCM@z=$jD;rxn9mnD;X^N2#Mnj9cHy_LS)nMj==<@Jgbvy|9tnccT@D`? zj9?nEG8OwJ#nx|5Pn=9TD$f`e6~SG6IfkY!DHcHZI{3g^;&oB*cJqqsZi`&U!#F2y z0MOT?oPYpMJN$XWzOq(`!V&kCWzxI`FLLxApLrrOsXe>Ja^nGAabD!dELy0KoRr9M z0x!GA)@&+-)$N*;qLjjk8`b)2tf49luPe?i8v-Ha^N4iW$n$`cXN0PcSkV>%jK&)R zh{X8Fh8%6i2HbC29A^3_n}Gy3{Nj@bQhi6fzB2C%naCy}_@-Bjqk|>E0FePlBA@rh zU9CTPZcsay6;)O3%};pFu@7tQ#rJXZZ=7!rSQilzTjN|4*PKG}4+d!WjTU=7{*D4H z(!*rt@jAx6pJEc8Uh)f}q~2WWu=FjSJ>ZIBsrxGG5X z?*uy-hhWyVtWzyAn_e~69sq4Du)BwMSjFRC%LR5f25E2(VBWytfQIwEp0I#Xtv2n( z9*tz+i0RX(8xyBMb(3?vISLOu=MfFJPlq%KdmshoU5AX23$V$(G%$N`T*(Zm-L2qq zM?xrB^6-1WFx2Cw+S20Cw)1N~aT}(I5$VZ7s=T=}7RfCk?>+;{Fwu%W4+tTwr(oD| z^^2*aI~A^X$Ve}RYs#t6uW2vbz{y||u|O{5@A zy<$|41x;g;@22h+44ba+7rcv-4g@oTkZSu6805l%Y0zhD6cDxJIshF!VtR^cr$NrL zbOnmPzBNHHp&t=45-l{`q=9=JWnn_-s^LZ&nN8?U>gNUkOr6dqAosN>6&$ZP0% zx|1kDceG5CLr!Uh06h$6PJ8^~5wLmRKR7T2p$pFTV^|@08$DpIz}GQ6x*Ov^U z&Sc(4SajrNIMoM}tB645*njsQlsg2QS~Gd20ze90yDqK=prmsAH$HKKYiPUYfCKZr?^-F87EDD-JHi z-Wv*4RkwuEF+hqSgmh8yjPI81z zYKxjU*WxEkLa3eWd|=NkX+n_qgJp=+-re!z9R1}QIV&qNxABZLW6!*Mh>EEn!-`LB z?YZRlf);^bZMq{l>lkAM6Nb(2uX!NiXq($6JU-OEFco&Qc3{+C4rA>2$fmS-c4E4k zsr!M+-!P;38}AlIF2!)LMU09&xUeRG4}026`?hkkHHW|jf%Oq3uYMe=;*Y9R3ypmJY1L;&!w zSSUOkXS|#kYXWB8Fk~eMVdR{X2>R?m=0}4a)ChAG!4v4=`%St#1KuE95%Xz0;5H$m zN4NfAh-wgBud&`Y3Q9`7I&Y41Vy+DY9HXt`7UZi!e7zYmyQu@ix66-Gkgi9h@6(XloXK~H5~kjom_ibt$&~>G9zSd~1iOih zlnsVzMEql7=ZoGcxQB(fyU-sP_lrWF^>z5cLsSOC1|2u1@sz#s`{TX5NG3Tsy(cfW@adlAjwoL0kgnA|}c9WSgX zNu_s!f*T!VP|Ogn4{xI#f`g>HP(QY8=8sbX3UGAJE-FAY79R`0cN5ywdwk;1X&~t^ zN<-TRU4UZqINy#jf)R~}SPh$w@6QMGf~slDK^n0~`pY%*uXw8HSB{Kky9ap~g7&q6 zA>=loee`5Xqpcn>Kt$E+I}`4wl4_klT}uyjm?zLuBa@ZhKich}xQMcb@JkXk9S zp3Fc{CdY)!5ko*>SZr-f}bn}0wKNI?<*QkWT<~wo_k2U zRG>P|9vRv?zyR^O-M7{XkQ~s19BK0xM7;Ce&5=Hlav1>5DR%qKQ3J6)d^jXYx^DoCJmEK~T5C#ZjYPl-DxR&w^+8;)1Zkdh{W!u@uXp%n`TaS69~gEj zuPpPdNhnM?ahL#vW6OJRa0-J6+LTfnEZln(5-n{0GfrFpX`0EfIg4-p_g+WkuhiTSo z@zNA|JU13dj_{Y8-cJh2=pFoF!SGY_?*ZUUOx%-H{NuE z0O@1L6UGZ&7Mou<&A7gFUQ5P6+!?m9t65_nQIiuR5aZ4Fg}2`g_hciZ^)q24oE-$QTDpPHq8|U5&6*L1&Z*C%RSURP5jdhI} z)g83;@rfP24If#^BZ~)y`}K*a6qn1k%pAoMq98v&U6_+e0Wytj~Pwtc*Gzz5H9A9@^DYQDXI2cxFG_( z{29Z5vH`0&$s)0vPZLuvHAJ=;(R@#MY*-dLF-o^76g^CUv}p;+h^7P8vl+<~(3zpb z>BL@C5D;HIePf$tH$i(2v4DM2b%ec_fV_LcfjeREJb-jPdN8p`LKp>}b56c+b+t`L zeett-95Dw`282DgXN9xg{DbqG z4cHS9+{>+8P!V|>1eh2hbOP%3<%~Eu-OhclHx!XTI;eL0z$<$edezn-!hMH+@Rum? zz2r1B2u^Xy0^zIigH)CEZdHLDX#2-gph~qL^EE(iNA{<89rAsTF0+HZx|qABzA|#~ zJ>rU*Ixu;I@5V4T(owaE&)=NBCOk*BoH)2bNP+0*5j4#yodGn{dFima zKJ#{%)P!;o5Q3kB>n%bQ2q$?2yG9ebB5B6(!5IS@9P3!95EIA`@0%1lk=>Qkj*m^U zyl;;=CC0EK&;yk7j#|Z*_!N~J>kVREB+7=|TKD~BF49Q--m`8Y?0@4Lie9Blld?ab zc}YU)<0MfNdJWui4Naz=zA@cHP0u(C5cJGlif;*?U{xMD+WYjcDQ>lWZW`PGU z@5VtYQ5y6y*~|WnWwyH_S0~d+mLo&Gjb@3~d`HF#2+*tSyzEb`vH);OI@5>m0hEwv zCp$P#Sqc@%kUl+SsRICOqf^fY^F?bW!^aqcSR=9j05R+65$kQ}3;^n^4{>bGax=}O zq|$4=0HR9@fy$n-1vz3==6{^#i^SpiTmeUvTO6_Gela%^>6_f+;Kxjk(*mn_bJ@%D!WL09eTtCnl8ldGcU|8I4!4n1d<>-6I}G+AO?qk4~8t3>E1mTcn~x@qelH0 zv#>k_-u!cbOd?#n^4!3RD4Yi=uAJDk?Vv3lwGVk)Zb+qehYK~lEJgVPb>6=?y{)u> zZ)YBe21dFMI0)W71?3KKtm2UbIp@gXtzS`3H2yGAfJjwizM;Q)X?mgjyW`e#wMuUM z7%m8D)HM;?%^V1jt%`F4+YBjd1AV#0sNIR9zs3$!dJeu`^P&|B)8p1XL7@4MW*J>7 z1IQ+Z8u&O(jo5hBj&g_-DG9at)->DJ(9SLv&v(WF3`&-A(XOQThXM&H3s5>PP_&%` zW3iJ>poc*byz@*VX{T-H7zeh0;^j07GvT^fh#}jl;~z9`wSbbN%Q(O`<@w_g+yBa#TCpVtCkDRLlAc?fouN>!?ffktDEA^0_(RP|nwTT&!IVdY{jAKGU5jnXd7<6)WJAIQ2 zecFcZR3v~mhALI*>o}QcA)xQm-VNbUZl5N>pNs&=qm!>#RT#~6g*-0jSv>kG`Chri zrLyCL2h+wj8as7?LkqFIN)dBDTrG-%9=Z$1pT1hy!ytwaoU`v9~qzp+ur_gqF^)}n^(qA5SlKr!HGFGVA#1g zsUH{+IN3IES$B7*#J?dB%EIH37Wy)>Op?$AJE@fGk0jI8VHY zgHJ1;SYyRt4c`Zx4KG3HCkfE(aq!$W7o!JM()w$7qlDl#-Z0oug#i=JJ_P}3H{aG& z&PsLrz$WR))$=eCl@_0w)&WL^w_*6MSVF*ypMN+LCr6+jJM-r$ zErH~|u~O~BLm%8J$Q{=qrLzLf-EN%XRkx%8JfG%3zQW}BFbwMolL>g&-Vxbw?>TRQ z$DtpTC@!%WDv6>Mc7JRo1{SdczWFh1sshV>d>lg`i?+}o^BYJ@;Yf8bWvhLky5XW7izelaVE5KlFetI*YWo2l$Dlv1f~fk31ldqZYv zfb^oS_~Q??5bOiv-XKv0;=MaOXL-_`4|;ciBRp`hJeOO;a+_7JxMKx{5FYmJclgRe z1yu#Ma5?RAWE~!HajY7t`3852S4{+);Qs(Q*sKhJ@R%H5Y41(NdOYnBYFseD~b6`IwS-*Gjp-bYD|ZkfXo7FSo^H-YF1 zoAPc`Dk#J1;!Pbido|kef`E`v$cM=?iw1bTdoqhsJeQoieZOG&`0${@Eqr>-CXe3|I&a1SzEG#UMiCt82@e>@H~}E|FhF4s8}0Ff!UVT|QsAq^4$orXk*c+Vi(YBFi4-I8lya_!1kyfv zz=U)Iyn#b|=hiWTD~9?S?+{c$->iHFMuI&)u~g}RK}Utv&4!?vdBsBAb+=u7;gBIw z*}smpgR}>qr5&fcZ}MC?T1O@Ht!IC@DCX<5&}12Sb2hP&T!E)7F2#NaWEF?&c|2L$T|ClAgAu|4SH z03zK0GI?J(xzenMn;X`SG73@HIX8`0b**{C@Fs!QBs(2TpBXpI!2vo)XUWz)6|4cy zM9QpK>v)hX2i`yf#=bH>7{8o0#Zz137=;Bv!~4ZV1qM;mjZjWCjjB;oit&PL!zPU7 z#zHQ{-T?yu4`)s|jDaW#$zJfl1Kk(*OkCfud1^O9yx;uGQ!rw0*8c!}q(Rsy;!o!^ z9zHRrf-e|l7Ms~YqI}|DK_o%dJ!2$}h^y&{l6B*j=)_VXW{v3hz|ce;{Q5b#8%#a` zj?Y*q8j9Czj}*?52NyNK6}ab&E)gZ%G+rG~mrl$IlP$j7$LCMK!XcJ+uv6b8MMG(Za1X?M;b+bW9ne0sr%iVB47zHyyp zC~fYB9o2=cYetFV4j1WQZt>)q0-H?vI-L043JBDkfaFdlI8sDijyoVt6r3lGe)!13 z&;rg(TznvSjqE&OXaJQi@uSg89k}&#eZm>=Y2`Al4*=Cv>>kYL%Rn8F=asA~sUJavSe%sI+!3$Tlc6<9*N7#$vQ z(FJwD#$*NPA^IT+IGXSDG+N_R&*`!N9y$FAczUW1n{~%uc#fH;j7|?DB*%+{H^(qOqFitW@))cg_i+ z#_&CcU97!6@Nq&MUCTCg$Bdyu2Z687Zw$U@WanLeG2Ln@NRs^ce3%De2#2==b14bc zM3vXh+%UzXDBQ@K@vNKYD19eA`NegJ711vBg?L$^qad!u(vspdCRGQvC+)(M=Y8WK zBOp=zVs|{?e+;vwr=Q;fkO}2DOV+WWR{_y;V!9kxA_xx9{oJuKf!WtQ&ske>6PjN+ zU?JHbV-3k^%5i|Pkq%D#!6+_OrJT&BrQj|lg`vg}yGHU~jN<+hWfC~;E4`r8pW6^6 zPEU*^0WH<8=C2nnSq%kEZ};ORs`^BCPhK!YhUinn!}FGA!t@x0Z&J1$A31YXmK$=_ zCxiyeK6jghS_d@Jx2}14!nu1}E@9(_g1ynfoTmAA<186L;eY%q+)c^;@u1Cef1xO_~c3C}P13jaI>~I^U)~8uf zZwB{*wO+D48?I=`}cz61VtP1{{S$#1emEmJH`W6d0%Vi4WjUjhqsp`r_DfIFSEu}qCj@M z&ln$2BQRWNzxXf{?t^8*zv!riH>l!gzm5L$T zfjD{1?vBlmht5&IVS~iMu>&dz;mf(Zb*KZsEbX$SA>UZsJ;-;`{{UFT3v7FQU{HpF zfg8hV2z=m_5UZD+Uk9wVB5A!YP;Ahxn>Pbk zjZwFwfjBV6%v{UK^UvcW)JlM!4atUJLV%&)FN{cpeTaH-0-fw~lnsPX=AbPDso?m| z6Xp#a`XRf{X_pbA6UU#tFTTi{1c@3l?55WE!N?*Nkgvw|TUi2)htX@^tl;zNAf$ zWxrS#W^bPv$dgaKDL62YFeExl-Vlix3m<#R+n#uGOp_O@L3Tcz_%hHHkp^=!N>U&blPU^w zX>!w|e)vPTgFh~>cyv8zA40g{3MinS^J44|I7N$!Ud~Niw}M<%vYLW!VhYhaKYSf3 zHvr*JSlCWUykRPA9XX*k@b2dPp#!8Z^^jZ&o2xg+<0n@`@LeXnVoMDe9x7KY+&u;k zi?d|702I6(<*9F=PORij}Pr^OZrT zYQ5pJvqjs_#saaYSj($D<665Bkzo7d3JV~b z7=lyyU=|eLnbpS>1ccz~Vz!!IQ@q_^s0mUpoOQxA3v*SI;~X>z_&oaeW-%k%H-}^I z)(D}6Rf3cb`oRIAeras+g+x>U9e3+47zG1wUl@jy05?rBh%=i?8@1ALCJ531MMu^( zO_Nbtsp|maC{P|K@Zd?h0H-8#esaY)djR;fJYI1{Rkq@XF-q$=2PvW#6v)w!tTZGu zRyN>Aq39ax1|1a=DX&;Mw>HyN^kXy_RMFvn^PycfLAt!&vv*>LTHS2=#=`I#7n2cb z`^+aqKI*`ULTSBYmQO03NkqrWUcYEQuvtK9Gks*Z-2xNY)Xiw%$`2#?$Av&`*3BKT z_BgmjAm#^Owct;jM^x^Cd~=4Z+fIHVl9~+ygwwYQNSiqS z0F1roQcV>d^^Dvj3~1=}akxDas&sKHtbFw?(`}IRgLu7I#SicEj=3PT_p^o#E}%xd zFMl|Nz#yY#ndZk-OYDj|~k$fTQ5&&%g6yMi{kvG3MwMSzaCvsnwMMh=v8Jz!Lo zTjkN<#iT~gi8!C$Drrt%I5Q@Vn?D$2R^c7aFi}w>0wX@L6yfD(VBx}J5qNtup`+`@ z07_e z0Zwtt6c`)6HJqBs=#7TNNb~0cnl~qOIx|mUfk%Kk{;>Qlr7C(od-IGIPRlReRH2F4fY^lI9V1{m0aT!1EcJw>gU_FH4v;6%KC-V$ zsEsc>=MX@I=H20RfI&yUj0*FEDr4py{Nw-=U|p|&^BZsB#29NN4(|N@;<6HY!Cr8w z2e542k665bGG%++@m%6G0<0Vx>#2b;uiFs1z&WQDLu_&}En4%0SMtKUR(bFjMAHJh-zt_4rsi}S6}W6CW!1RyxpZ2%@#;f7W{zX2@zT+MI8$a$~a5xK;1tTf|^l zbn6ZGu5t?q^k8goh?K^1&Uwvo4zk0!iJ*eq1?$VKM)NKC$T0)TmSXPA02E4(i%)o{ zr;TBQQU|{naex3GX1u=`A(pEZ>iG4Jz@Y1g8DQEdFE7IvjZ#Zv)pf@h@`^8#AMRVM zM_eEIj3T6{V|buJ9f9+P4WNai&4uG(Avd001 z43Akz2}my3*c404PsUWLn=oPO@$-m%V9=BhVg^O$h#qx-3`5xU_kv|ncsWejq{Y*_ zFYk#mUWcG3dhaYM1>tIa;El;#Rf#oDIm!xE6b6kO)VP)o^?|DL#_I$q_l+J3)0f65 zbrYxV{&B{V8-ES=F||&SCBa=uZxC0=pOZV5NjDQpoe?)-XFArC)eZ81iwI2e1)UkX`&>tFf%5*m!<0 zH~<@f>_d;dz8%pQ)q3-dQ9d>n@6IG)fz95YCh(bn9<)ujtZ3YC_sZ)xs(hX>qTP*( zqv4b=2jV>MjH>5xq`YoN#tua3FAh+V+XZT`t_);=F^Zn9R3Oq3cs;oeRe%OM@cP5M z=BAE1zHo`CkbX~Cl19Noz7H6R*^adV-1j@pb?nfti?^&^3;_QC4>@Z^Hvz&{>((X; z7aMQ&gQys8=7)c7KrV$4mCK%YvEvG8a$@E0rx1}a3$38x#AX4Z0c_)E={lJm>XZil zd&H!hTB|R{D}d0pj{b0$3ZPW;@MBYAK{ejb7_BoF66UV%A%vu&XCEKlZWU;}zC0Mh zj{H7xo5g4>;YYVBmcH+!(Qp3%a1=#{$md@e#YUvzJYhy8=cn1{6=*)4eB?b2hVkXd zCTJ3c?-Ur0q5kE&j%2yD5vs|GNjG`9!c-2u;u+RecKzfo*^v`L>z|DG14zLU*hBM- z)VyzbL^RWu<4+6=K(}9b?cR5b6;(~EZW1`n>Rt@g!~_dDo9Fn#0-#W_Zlv}5m>^)? zWcYDd$8m3;cp|pC5A%aHH|F3Y91)})9}UsQ9<;H3M|$f9^1T*fQwEWbvwh;dyg}=1 z0M{s3Gp*xB^O&|7AVdvb{pFOyLO8O8EZ=7iB@}VVku(KHL+1ryuAmjm1&Hc2pE&H` zB5S|5%XaaUd(C-k`aq4JP zIqU|HTfoF(O$6;Ey5{QxKt~QO@9PSWg>aa%a0-|fj7vvx2eL;Nn@lt(5xdCx0b4D- z1o7`2QX7WxM=Ovg)-LL|8~*^i#*(&sIqL!@@5UB{d(&|u*15R`kOc{A{bK^I^{M;h zPiBBhvc=NU+Wq7y1o3#tT@*Iw11HK6(qAtbAh;bP#L9_-!X8+*Hp5%|;Sfh=oKOhY zgU$tY1Q7OhFq%kdOOwBI&==0IhT#T`Rd0}dBlRXS{~1M>Wzv4{9&Y4ym`o`Ie_N>0GWM^2@&HRkS5Jw_WWYRsXDY3Z&+1m z*g`E!k0&-2u3BgebyKc3)VP8i?fj@sh+_J<<)l?iktV;6R*c%@`;c`47C2NW87# zA2{gMf`>+ip)#gHqYruxj6jKWGhNIXpviXlTw*P04K=Hl_tuhLldJ@Z$YbzyX4}%7 zI2TV?V>|$py^rmR)Rnihy^JeWTiWp}@pEZ{ZOwfAWNAp?Unn^}W{}8t3siPqg9w8H zGKT|*rkx3C#o2*{GU^iky9+{{Wf2CRUF-FF!bvYcN3C9LcYEg)Y<(az%Q5 zU^A2uH~Wi%1BQS>{{Xo&Y?MP9-^IvxSQ@l!d|}PSypNBZSs}rz`|lN5JR;j*hi-1G zO`+gUzwTWC?iX7An9%(S^*Vc_CKSI+s@DX!4TwUAnNI#Pf*PfA(6AY$;2P@pOa`F6 zuQGh%3KtkUm@-t5*~|wtd1t_Ke6vVTq`V+q&zwyyNwhndV~*ZC%$%<`4sMN2E`z^# z>a=O`nwwR;$bsM;%pkIoN5XsFQEfri%TvZ1gW>UY2R8SLQ%7)t`M z-sg;1Lg_D*-Y5W2pV$2uIy{jMgfpJmtoCn!XIB8>4x(ah(Rj*u4^tRW z3i9hMtIjkCy3GWSK7YLI04;3zb7`{dtca+p>UAgYj_YDn74HL}63x8pD@f&Oqr~%+ zx+qXQ^@KodDJkbHJu(G{pLp;Ql%uAdVGzKb7UJ2p)&I2d2vV*+k<-fom>wlB^aMBP+9c*)++hX_#E zoV{hNDoz&$P=<=w{O52$zLRUtvt(W#L!8#Oo!1v7c@=oUOF^MVp0ktEkBk(&m94}r zRe(Nq)_E?j0m&X02KVKo8xIHbnh@S_0-obf#wUZQh~xp?o39uE4U2R?Y%f(B92Y!E zt>IG&wbn)i5sFjCyhN+S8>>I=a89r9k+ZvnHS7VsV2)v34_L|td~_cftyj6Oj5dbL zrz|`$LEEfl5mFb+&QPMhb4Jk8L#F})gbtbKC3s@l8yB(f5cIB>fTItLAtI;JS*(JX z95;EOO&~!Xmm9!j1#fk_*Q|(Rkwrpl8V%AwI^r`o&pzB!Gf{Ll-mw;sn_OIr-ES{; z2uA^}b24Kp^T$Iw%}S)L2VC@d#02I#4{5WX8PzL9Dn5R28XX4bpI8KD-kByTFGc)T zFPVXqm(F=vx_Hf$*bpK-`Y<%VBA^iOci_aWNKyoxAMSEM#G`g*_Iv@8ivIwZkf4xa zQ2WBv;1iFWD>lXhV*TP-Hg@0t05Bi`$v`&07)GC>Zw0AL4F>-JH_kr_HdI%Yd7d#W zllG%1Nu16jf?a$xIOoPRQJpY?ar@xV zX-7}$bm2t73J9EsvmQAL%AFzcg5pBJZ)^Rq<5A!vsey+O3A?yg{^o$eR=|G4Z_X+u zN8&L~kQ&bA;5y?21ge5IfyM%Z1gEoSE0#5ce-eL~==c-Xgtx}#1e(FMd^^jb2Evul z-tIpl_dwa8V}$w_5tQG&7Lda1kPYn7;{dkJ2PWTS^M}EY53|-tBX;Pa#``fXa<{$N z>s1~xGo+LuN*;Y+=>jvJ`sb|R?P`Ek{{Sv36($Pmz28}D43s1CX+7nRmc@nm#sC9F z7m@L+zgW6TBJp>8i)qC$d^MVf+alE=92u1VyKc&WXe)8_eZRg$^c3gpTZu1!atk*NVrRvCq3oER0 zvqC0aZOg#VLIyeXmq40%9bmm>HJUZ?o6_ugGP8a8&cG|IPnuxJCXiLUrVvtY!LKMA z#uzeCRv@#n-_~q--V4hrT(OuGRpC3rS}}-~;v45`@r;CSx9iqNf!beqCGs0&e7GgV zJl%G0*}{vW9(u!QLEz+maNG^&2g;1f0CcDY5`Gg_QERaX<_BBf*6M=ilNbiO(j?tIf6l)?+wU}3#oj$R**#khmSuP z?m0$dw9`@p6nq%$Yw?#%F%)}XSYQZ7mj{ch zm4Zgvo54fsmolqFn~Q^QK@Jhw{QbCPKx|ytG8RYo51KZKwbO7LY$jT)lPmc zQ3@?9Mdudw3&rCbS_<3FdGUb7nlY~5&OiJY-X_l4B;GS_U8S#)*4#UK}IP0x#InG}d)+FRzO5aAXQl0?r^@vRA4NX1kBB=5_xhCl8 z-VKi_2NQPUt@oLYXqSsWIH}8gWYi1*6_HuqYOfC)S*Y`Qr@j4UX^$39q1c>jN}^Y!4rKs+Y_`3?onlX+m!TZZ#`-KJk@J5K!FbjBbLJ zXumDKGKWZn1Lv8B1KH4i*NkMp(yDyxoSwp#QK*Zv+`~7$JfB#ArY@1-Gm}7s+vecf zzeQiaI4ZGZO?X@si9Y0$!t+?TJW=q7Zq$azKDl5@8M%2xZ=V zlHdi)$RpuB;+0npQ@&>=76qESdzkT+a$fx87DO(t=YQU5nz{;;qS>)Ehjsx%=8<^F ze9(mx^2c)yRJ#X|6PKQF(4y#LwdR->>ly8{ScXkd5bExKIZ2E}HPyTL#1P^Uv;P3Q z#R|(~H|k@NMR7}bJ7x&JA)76EUOCOK5SbEhm4qSCk=HjA3rIhdj)xHQl9QZ8-_w=@ zmgE}mZx|J7LPO$4jN65VytQ1Nc;gPuLbR8w*~SPp3Jg={tfH%U3`sBoV^m#%^MX&G z6nvRsEH&)ibDj!LW6a%=QD6JX0`Imf(HlNEuZQ+Rm3 zUNOK{1AJly8Rf-SYMoqw)QIIHzY`8)0>bY;^{wI{F&a5is?)dL0ca5l<;IZ*NUtt^ zc)%PQC~266LBF=}Si!FTUF1gJ8i%Z5v`Tcik0X-r8u3_rLjB@8fC)qY0B-^ zgMe2cgd-ImdEw3`h;Cr#ZyNW49g21g)&+S$7RkH^WY|;Wc4cz%?LU(hNJehFzOm9o z<8vTN7b!Tz*eIM{zOZi6j7E{Lxe6h&2dJdQI9rQleYbgT-o>Hu-cyUBS#B2SmwV0- zQ}lT;&=prrxXrnfc4NS2I`V5F)bEgi>|Mj1;-4Tw@T$ zP;Ty@=UD;*J6b+-x-W0>lu!zh$E(gL%Ifal3;zJPzy(Kc&KyD3BBm5k2g6&$+5?K1 zs*lqXK-W&@JfSp&e+ET@W}||_*TQv(Xs@}OLZH}fTesG_&e)3s97FJ8>x%*e<-Fox z7*?}J6tOOX8Pk2Z>C;cfAym=Q*^ES>su%Agx1%T_GH63ATZ+LpGNc@oY-bl-zl)VS z@B=_Jb>|&QfnxG};}P#I(|)zAL9HWo!^ROda&|GJ)O3ix`Ako3HlfA>Rv*SSP5Z)| z#+O3(F`ATS{&Cm{0jG}e1+ut6xeih7CH=85)})U^%hn9QB2aJm#Vdf|2jp-HFY^x1 zafI#}qYBYgcb#F;39O^Y;N({nTkXOGfNZIXzzb`E_m6pVcyp@rj1Y7zGpq4GeEgtn z!$*B5e>V(b@Y3aeE+CwQA|sUY^N$IJ1(EO0NlBroZEW%H7wsL0bOtL_IPK`bPO-3> z-rn-5r53B_#xOZ83YA;2$&_?-YjC(gQyAd$?fA#^I|+G!a^TG%Kx}50_jQmefrMEN z0{i0=5vVQ?bi~0BGJGX<=OC~qgg#yJYemGmc#U?S^jB=wPs*7O8#o*bCaR*m-ei9(CO3xa4= zQ4YIoxS zT0nB*-GaS#`^8mqLqJCq4zHWeu>rjT7)W>r^N4o(EW`u?QD5WEeT+B6*BN^ic$}X& zh>g)^Y*3m4>y7ebv^Z91dbtWfsnTy6Gz924!?wiZT;~iS0b#G6elfZx7&4DZcIm0}kluywQE)p%HP>U=ha6#X@J-|(!mW?v z&H)ZXw;BXH*3>c*B*%K>yv^gK>v$;$s9E!lkQkc}B<~#o<9Wa*esS9Bc)_T2>1)RG zVA;m?$nR!UN3(d9=C1HmRUM6z4xj4^qzfjDumITSe2JWJo#c%@I3(x1{g{STC4lQI zacV6-gPpm(PrNJw&BGK1p`$l$0WIcw$B83?ykO?^*a6b{ImkBPYH&chTtXlYL&h4o z*6q)Dw5hagBp&q}?*Ui5J^R2W{udpTfL!|>Tv~xn4ZJUSiW^0Mc$wcqORuZ~N`jW@ zDS)G(4udWcx2KZnn-9)LzwRB+c1Mp4bqcRY{;@{xm4A-02ONM`0KPcDNYlD^Nv;cw z8c-l;fvklRF(FSS;|52zt>B)nC0&q}h!2hJ-UtO(hEMsLl2P`s^7zOS3Dqwi3@!lp z?+rZ=cO|Ri;~0&aFkl!43qfHFlooe+B%8(n^O= zb=j<70MMgvT>Q)(Y9GO&)=^-6-aarC1|X=tn3nZ-m%qkL6Bj~vN!`bsVu%XIBRt~Z zQq^6)j3o_QK_t=N)*`}6R)&u}X6N7|LO&}0FdA-Tq2DLAapMEUQTj3X07m6OS>F%e z2yqFw+s%jn0B{QF>)r-b8{@Y%I1llcAe$l`)eC{@}+-+x)&3KUf)ySOT?JeY~S zGa73jo^c2S0d^~O{^BBtiRTN-2x=Op=J?)NJa082PQjn1uymvDJm+|br)P}gzdR2a zNw__{VIXKI?e~sQ@GDm)fk^6hdBn7rc{Bdylty@NMbV*C`O89)H*%158oTz&76Hze z5=&{#G68_o$KwlPEo+?TV3x-XVT3l>UYOV(?y{++2Tt>1b=Rx_AZRZ*Q`mBfu`g2b zfkYxP9!yar5l;o+`@vmcC}a#M7$g^M!%b7A z+5Z5HN@2{mn|OIb=iAeU0l4|NA5Uaf;?w|%ZvA}Wja58@&B0AVXzI93V#Q^4<$hR4 z1BP{g8lu(S2ne%vf#|__31*GwMk%=Imp2rn=e!h>rGtm7m7twAf#co-Y&a-s-UURHt{5sertk?U z&hbSg4;L&rt3hf`dFza-$P09L^UhaegD^%_uA7H=>PG8&;lZc?1hWMjv*SEQY@wF} ziaQ3!)+>pD_#uppLtDdRyNv-B&T>U>GkB)7O=~&{tV9sKI6(&baI`7*$-DdIS=kVX z`JOYLK4K2J>BcIp!=HM4V6DI%DQ%}ec>dnd(fKiF++Bg@V${{3SI5`hTo)F^TIloV zBiX~On@=0p#xoI))OiOy>juQ2?A+Uv-e@f9I1DDj;6lFfQn(GFv;P1%(0eJkc=^Rh zQh>S`a&>mcfI9fcCiy`Zj(Nt1(_6n$^^MyOx^V&Af#eP=f+wOI@EnvyAYm{QK(tW$ z)*J@yYcDi$=%9$GI1~8DER7kiKJx9t?cW5%5)=eqB09hs(pHtcmaZ2i?c4W&tQ($A z^~;xLtTieHC*wGa;9r6g&p4#MprYrI;qfx?4GerI_k(piFBr#5qZ^_)e>ntbnsc@P z0GY4?(W9m3-ta1d0OzC3#I+3;5LNKj74>>IN$1`as2IrPp3I5Cx+Hg+o&7z*1_jNa^1ca;09=G!N;>y^VBMMsuDV^5)-WwE5b9aq?}5sq zd7hM}EbkfMddlIeVGsA0#Zn26>5;Y?0w~(HlNegk2~qsq3p%_Rvgg^H!?{|2%@y*r^7ea>B+s@AN$Bv-LNMBDmtg3=desfy= z@s)^O88@HSEQ#1@!L4&{I#3+E-_nW-he1>gErxZQ$0&#eSJov5AR;~-XgkL-;p@LR zKmk%K&I))AAtCsl!Ys1Nm(HkY%^M<0~C>ON7=H@I0 zSIMW1o52!bjjW5-LymjK!iw7(Q2M}v2?lETxMr;}d}URzIs3*!CQ!FDk0JTT;A2?c zy*LL>;cpW2^@S5ir@%Lk2e%C%pC9HL2~R$L@QUcH8T;iLMw>3@&LyKqu+N`aIP5(B z9F!IrCuP4Gdc!E}4q4#1$t_kx#M?JZe07`HK*A?5F_~2CM)>IrINHCU-x;;2Mf1F| zx#fNX5L5)l_Imfc1hB>Rt@VPC0aP>N1PaZQwVVmz=LcN|mB1eV0NiUQL3M~2CRkc! z(59a^2)P_mYkE{8Oa_q za#y&3*GqgEEo`21KvTe6C-!RRHsY#N?>L~X)qG-=0)l^>4D%Q8EP?{+ zmOePmBOq`Q{9qs)sszNFxV#PTC2=biU-JeteX{VT1Wp{`0m;3|jA{inQXX=B9~L*6 zDFG%Etm+dt(0X6n8WjO&pobdZ!Verc;yF_&+o{G3Faef?UAUcdae~p$H+ZF8bG#`E z;52&=KR8(FmXlsJ$IdZEr~b81phAsQjqctR-3G~b^X!r4|VH@DUoCsV<`L*tC`Hg^#C#Up2# zX@XT<2bZ6WOzeGKzXC*1D~4gJbdP{ zhz7?5Vb#6M_{NiZ2aCh^anpfX^E$aod+w7B^muqDu1)XPl`t zC{VRV{p8A{V2>_eIA`L+%p&6+CfxxV(C>_T4-RH}E-ln?WpqBg<;@c@$@vAv!$HIa zl<3x9)^22kUU*@OO`tnI^K_byKr7pBonkuM=7r&O=P3e|;j-z=yj+E{PvlHQEptkN zp!b1F46`4Q^mXqNo5G`h53Jh}2y#1``NBl%=!cvkvrEuT?)8g+_=27Xcbo44OK=Kj z+Iqr0a0@|XHq#3Y6v(6o!ah#fZ&f~VLxrrU)2}HwO z;~^IJlqlvyKC^CLF>iJ@!+OFJg=d$%xW_=WISIr3;wTg}?=}Shy>W3_0Gy|O@i^Vc zyKeHuiWlAq6T=Rlng(af; z_2U&Cl#KewJJW*uz?}u}w~PfUMyj=DQ1TH$&4w{g0SwZUPZx{;5wO*H!S(^=#QDb2 zofZge=O^B1#2zm>GsTfxoL@L9vVP2~Bgp9bhk4#l0>2C}=^YJ*H|-5PRuxLJ0)X@3 zz^p!nw?B+*c3}SicN6+652FfN_yPUl-KS#x^@EEiC=3Q5d7sq40)9aU^6{3|Vh~TP zw22yg`Q8+Z)(@n_q57jAjN6rVK>RLUDjl6pt8n(Kn?nLx?2S??c3>DInBHo@aAe-c z>pwA$#wLddHz)a#P(c9JC2v@Zzc>_8H%xvot5LAz_3JANJjsGXlLS#GFki+WFFbA^ zc$E%H%KMHXaiJ8(%ft{)i!h`W*=acPBRVjmbrG)js)^@|c$<;kKPzIwt{ z0XPT4kRfvTU|e?SKa9MUj0bu$>gdFbDkl~C!+;VxSC&pB>T3M<_(_%aFJV|GqPxQbQrUUUj^HL&yT%To2ZLkVfGYk$ z#r2A$Bfuo`dd30+8uUD_B?K1DPo}-^G{c;}&jM-41na5@<#jaqC9yr6CU8wBr zbA%{t`B3WxHddBZ&0f6W0t*7)h4Y1afdy_5<-`M-Y|lZQv54$iH@!?I0(TEcxQ!PJ z5gfILpoFSxX8d3haRf~lj@&T%F)Q;-WFd7?ISC)mcT|Tc@At-QiOy4N;nx{@rq|2j z_{LNLKge~vkcUbLa_k3htpq;yYWdNE}+o9e#(^xamM~tiz zD0xp#D6*jAFAuZUP!jfs7ZC1W4r~A!I$wV{frvU-xJ6wDa^lqh6J+G|tR&rlrIXa} z2m?xDmEiH3D@16n#~qX}P9X)VSEGSqHF?HeB6e(@IKz>mZSMs5oB91DPzc`n#MS&| z@(^sF-a0>gX;;4nJY3XRr(Sx%0^q(c*7nV>xq+e zoG8tEz|K%}p}4*J!&2-zd%!Rjg-|?i4jUidS4{IWW4f3N1f(XB*Vbs+X7F?tQm!SG zQji3*K*JZiO2>C_FrI@{e3;V{@tdfi4=^5H^v4HQTM8%a)*(woD1p{3JHpb_6hVYz z1t9W!!C*laM@#P>HWk49wI3Yo5ey-rr+dFxBE6N5qbdZP4_ki*B^!up{fX-eBMuFD z9#=0>ip#@r{{YT0HGv=*8Lr)BMoEEnSFaQUdV|D^O4dDTq$`=r`ACu ztq(fu;|8i|4svS}T)^Dih|rDAhPQwv*En}ph5$dYkBUys4;c%zR=e={bBDKd3O$Vu zcbg`gw>~l&pr31b#F<>b$Tr^@qDX8t#=9`|w$N$N?)%GtR?6D>$^=BrE4S7Im9i&C ziGf3!@#h6gP*1^MS#U)aZ`r9Zc=LE}#dWL)sw{sP91ltKKvOiK+WElSU7X+n($=NA zXak>|NK?3s9x=)7ZF}>SbAd^~ZE$$cLE{%2AyQTxBr8=T@!w$ra@!g=?(Ra~fICpLlx2fQLt0 z$DT_2XB*2J2}>-*F?h|U5?7%!>*E7^uDLRDE=a%l!~lZRUgo{v0iU+pw#MsOvuLqI zdc-ZXig-1VuaI4z7*v4qbmMcXuS{Rew1T~L=O0sm@Ov=9blv2Hr265pF202l>b9g$>$0VuDzY80|>wPk$N1t}r1K9fjA< zO#z9<7B}8r`X{^7=L#WKge2j-M*i6%D_3)wkax{>z0B!Z)f3^%fF{VnPCjuL00cOK zfttj37o0$^3Mq{j?tD7LPZKx<)R|cdqhf7#5-I<96wB_4Izd zGRt#hQ0v=?0HpHmRl+N8F7N_S-G%Y-h)WO#F-!A`0$Oq(Sv6^_GKJ_KFPvqfhvP%e z2>K@uSf#O~<>zdkmfdHi{;&balrOi|0X8Bi4+-lhLWNVM^}Lk>TXFJpikcD1?_rX339SS$ z9I&)01H6#Bsa%6O?_k*X^N&4!U`JIf?*j+Sd zhOe-9gw!NKKu-Fb!ip=oM`&DaF0>mqljkX*S^#O=mz8|x%Zi4r29J)scQERP)K^ck zb&#kj9{%P&2*6cy_KBC&s|xvFUhwmGYgBUY`^rI9YY#5mdhA;`<^KSjJd4eO`5bH2 zMycMpee;l!MMlpmJdw0e#6$td6dFKF*!d_F}H69oHc*Sj$HqPV8 z>k>un5_tsHF(HFR!LXvzCcZP}3srI~kX8pAn^zntoKxX@#7iLuNx&VqHd+zN+`4bZ zurRuMjC_3M$k;{jE0!?C9z<3=VoV1Cf`B|qj>p$%-K5E zqq&FEa0?g5-dU~zdcIfQYLIkMPtG}c$+?qDPGrupAZb^L z*PqcrLy+{DEk#fi!+@vWaKqtZ4by@fm+Bu(7OYP2*J#=;ec%liyXwW>(#vyvrkv&v zFB9H1!5t&1jLnTV>j5@?F#up@f%VJf#S~2!A$&LO<19DIRq=(&aW9T<-N=9&6rMQ2 z!GYS}%hs_Gl#n3k-#8{6RW%#v{_=vhQoHr8zOj(SmmP2)-g4_nrSVdl#S@e)0m0!< zOG}C%!4uog0?^Pp6KEV}{S(xC!#3rTZKdI%yi2A=FXi!oSk16+*Q{tKRFDsoy?M$K zSaGh5Ei!rI;`z=wY#I>Y#0mta#qG|#9K{CyONi)jP!Av(u|b@J4Q5O8SB$JIz#I50 zkDRN9k~i14sgm7{1SosWOQdog@c#f9+d(p^ct6ZqW-#z9G-dl89F4NlI>=+Ri3ZEQ zbmA8XjMDUcae*ODO_z&_1|g{MBb1uO&&I01t0C%>fALc`(|sBF067v)&tO z2(3S2VQ)1kCe#o25B;dayY?o+^@#zv7kTK_O*qB`N6Cpfn1_ID1UqossmYT0Kv_7Q zwY>e}C4Mjk?;)``)2IGoa;lOc?p#Ae!>u3v#ELkM$@$hWkSplK!i3I#!EJJe3+G> zuD%nj;PDVc3BU{d;e<{&_}80+9OC6bgpKN_^_pEf(0o^a25wc!1vWlB_mbp+6!H}( z?}7#?A)q^D9Bf>HXoPkj83GcIAbEafAtHjS@tiTEkNcI-TD9K2WyMm| zI0_qhz)J|;4X=Pb;A%jjYsi0?P-{yZ^T&)p%XOxd0pxeaB>-q?$NvCviEcR2ug@kj zvD!m>9qjJ{ks+m<#YPNKw7xJ>AedOTxh_FuNBoC*d{L6rJAQCLjA&|`I^_AlPfq1s zO7rd+ds;{c_Ppa39c>=&E1}dd7rrCtv%rc$VW6|eLKMRz^O(bJ~2A!cP;R8!cx6k zy>a~91THwx`Y=mY_%P8;SETvp6l9ZSvzu~OIP~GWesR#y-cUNeF{pV7(*Sj+yfOix zh-@w3{9;PaVSeG(4;U(?uP0L!0$CsB=N_jdTDTd0ZA@s9e%w=I?=`(X@TQ#9=XZ<@ zp~hW|zlhw&BynS((m3cDC9^-Mq zztjNq;45fCZoe7hi^M&NzM|pY@MgQgI+5~YAe{&vnB=V5@z1O{PEhywP8>k-Z$--<3A~0ALHY-y23`%7*QX*P)orf^BZcaF`O1#G!>{+jhyc*eN?0bD-&${SIoCW}*(esf8{+ja2%r>7RU~6>09bpBI6e&ASv+btE_8u`{ z25@K&BZRJ^ExGOq;gPr5n@(LRBfP|yn zXih#qN*MZLyJt9I&oex!%$FIdZ+MletGeY2th=&$#bmr;L&cmrbGM$R5D3_V&Hzve z=RTiVb!)J@X~V;Sqsz`X3L!^FhDow)M(a2pPVq!iH74Hf0)U5Hu}-{Y zPb36$%OYn0o>5;1u5oo5Lcuk4u5)Tv!@l*-QID%OdpgJP8ML;y)-XUG0*`($c3PfB z9VkNYyhci_tk;D6u>I76uDoEJ$&Pz(v9vLQ2sH6)^u+eK~<1$-PIGROu(nlmL4~%I7tQ+I3o~~Dz3A2+JC=>g| zqOIe8FkuLZf}0<_5x@H29~w|2QBOI=4G9;AqYAXDYYp*|P@68Dm^w+;RAB-D5oM9 zXU9x)uALA=0SHHk&AP*5w`_t%Lp{;9-eYug-#D#7%CC9eYcTng*o!`8dYA9^Y;K5#IE!>c)%j_J!+!{-68vk38DI7ApH zU_9X_nLu(!qvsASssdgUTrO9u9wDc!IFbRX6UFi8oLR)_w4Mx7ZM&%Ni zG`Xsv5)X!#%Z!`MwHD=GSY%5loUrM`=K!QYMf`-oWqHZJ}-Q_`tk#a0P2Ktaezo zPV=ne5Lz@%ynFo=N^(;==RWe_XL+(Ol$~BPP1tpMykeY*Up^KA$pZvh6t%d8(K6~fqHuX*N4~%RPK#ldU z+XztDcB!Z4%ya?L)^mJwo-xKfE_j}DK}!JK*U!9e?n~4<6z?NWkGg5d_;7O27lpI$ zPIU2h<-l91$oY*mvOX08ZXgWbmC2Luv-D z8Spw;?Z8%mqTTv16@oL~6fcaY8-N*AWvjWADk71lM0XR5-Ysq~tXOaB0E;3Dm7@CNHkg;%(u|d1eZ@3yaFg=eLol~3J6D* zx6c~LmRH;r1p8wWM@s*ysq1cd~a9C;&{L@EJ;8b?+t?_T|Unz ztWTS<=drQk;rK*|bn$0+xFd9h4hzcTYEmQ{%ypFj*&tU}+m3P@OmP}6#N?jvLw;ea zu|2!U)q-dn%rY0Q-7M93Jso9=fn5w6Cle0jQ5uGz^6LaQP&E+?G! z)H{c~*Mz5$z(ND)62&KtoAfpBSm3!%nW6gX5e~Aq|S35AzUBE=Pg!GGmEw1Ir8M z;w_hwoP1`9O-~ASj7NOQij=;8ugFI|q(B#s;(wVZe^1 z=Nz#}zz6ghBUXePVt!aqz-@AEJ8@xs2E{X;vca3#wS~QPq8(q(AqWMaE8`hFl2ftw z?<*>d^7z437M$Z4`o!oxs7{kh@q#vs(|5m&(>XRk?13nx>l|k_K!bx=oSmGutQ3G4?nxa2hph@E9@H5>~dBP$B8OGDGIUHIM@3r@igy$^(08AnPfqx(C z7W1aoePTrw9V;;rT7#dQMI*CZ;!}hmy!pn11GFw^Ca@@^+21)lcr{A#Fkmd~)H~DX z9QTSe--DJ510asY`pW`ltFO}YmgT`+U;#3+y<`?|2xtuwhsEQLP2zF~jxeD@LhOfZ z?<*P+1V>Ek8VlkKVc&+-uQ(E~;}o@u6N#EYS84oDIJ-Z58@_I7j9Z1)2#90J!;}h8 z(Oxmp!3nHgZz-H;n#o;Y2V>mDrkVivUh$}awa^z6K-^k=F}kjc?DKG@zPh-AL_}`G z`nkdu^KkS^wwsNfbxH3D=DfED!&UmoAZ4F;N(0rdu_~b*f$b+55P?_+@*mD?lo~k! z#KIewfvDo;1}nk4$wN>Om|oF*v#@-;3LYw=1_z>*p!6C!56qdhZ4p8oE-E>lUk9Vt5nYArqs~`o{y% znogD&v%kv(oARl%iUi7YdPRIYF{!7K8|+SVghd9AgxY(T1^6k^0Rp8$B-fTkMQjTalJiGmq2}Mn7 z4$g!3!K^SN+gF%k)w0IeCmw?#MI+?moU?AOlE_&&DU=w1Z22xWY9{kpAu>MJI09sEI^*`^xuskAfqY7$&=scbgq5 zGmQlU)jZsBhtB?-qWD1gA zd+R2(8-b&L=3MzLr0_X4T_cfr$4U-ENchAI;JOX*dBi|N%6E%2i>ecXb(?!)ps%N5 zI>zG?jvrn!ke@{MwJQ3-gboJP^7_kwKp;Z~$ceLeg>-R&s@mR%o8CGUj&mFbjwTFS z44{Z#y=J6=pkTVaU<;*QJDu|T&GFtkk5e?cw%rad!z2Sijqo^I-Owi+J2BZPVt~Bi z8@8n5*x;wpY27>LF}3a9SfHid8K_`On%i}k@>s*!1>1x>B?z_8h3^2x1=Qqm!Z(dq zcMW9~2?v+PY5}i#p}mv5pe+cS)+L}Ox85BGesHokgpFm0-jT{_<#ON?0|QxHk_*%0 zIvArv>#S1B^V25T2u9a86?WGui{R%j0)pbAzhE`zL55Zijh_dM0E4Gg&chJfe7E6- zPACqJ>!TFS!kfGrT64xafVAL$Ye%$gtlaHra4 z1dfMi;{jo?rP+3M4)@uLM)$NTz(IuEcP`oSju2G75BrYPN0lq{=PIM4?TiAUc;4|w z?mIgkOlgcRk1km$G<2sKL!G}oU=0QQ83B+2K}4zt^Q=I*z^{~YV6g=ia`T%2iiy8? zPeI8ptkzfU8dMwrk0=d$U+W!_8b^ajz*S*C0DJY4%C+hoE-ki2(*~oQh7;GuOr$oP zzdkTI+9qs&_ZeVd5wsrgYMr-Fkn`^x91IC;NyPDop{3~ZU=MNW^ZQ^oPlEYkK;B&^ zdw1g?07D@~dN8_XkC#AU#Fs>l_TZdFCRY2uz6EyO@5%V;;x0-6?YHE{`n%+C3#pAp zWu!T5*PNi;!4Q23>)u9g-4(rh#vpXisdPQQ@cM`?8Vk(zf+%G`-4pxwg8V=_*cPO4 z4oRiZJL`upIO=8@&k%3kZHb7ZVr}3zyZ~=Rv|6dp#!iva4;$$39n>8WHh0b(p_I}K zXN~Iy&7e(`QF!q9!gt>t#Ks{`6brr2ykSEiLhnV-98Bv$3j74P!$Af*UQapIP-Oug z=f9Q7l$19WS~x>o`NpIciYDDT@Ny2FpO%>fh7+)$L$}5`5KOq&9PmG!Li!H9n4VL{ z2NY*TfMY0*M-l`TEF-Ta{j#YgyWth_jX=R60(JA_2q8DsWO1SLv?KPxH05tm({MHe zU0vY97zqCWI0AysW);zXGw(apet$-`6)y0aUN|NO5?evioMgLt1SJ0ea%$@=6Bsqs z(X`h+VI_D1xVj)5BY$~El+#YWF@g_wHZ)VQ_GJxWG_XMQTYx&9sYQPn`ed?{UFhD5 z{a`$yTOIJ_#vrSzxm`S*%%4+hMB~z!Q%Vz38p153l)ShX3kOY_z)9T&anM%8Yl$)x zi^p$yBwVdMAN!0#sI@fj#mg#<7XJWj^N8dF>-S%J3es1c>`}A@fe`uweJ061f@5Fe0V)zm9{Nb z&~UtCY#V#)^4y9|1nuPagoq$_;n|ZBNETD?D(vH2<({@w03Ce1U_((_2d-OlNOZhu z9|u{_OR~J-&`UO1Ka8daQAqK>{l)`Gp%g!76;V~KU4(`rtRZ&!?*T2=AnyX_EZ$uXX3USuG*gLngt0IXCAz~A z2ED4{um@)|;}U;xD7?KmED@!@0sY`0_6<*-vDuRCHF2;yAw)jonz3dnL>Jy62|Z;t z^N^P+4ExKBAfW}Z9{&LQh18l3M+V<6@K2Cv&}ujHn+zqb&nIk`Ltb~RFGQOYC<23m zM+R!~nq5~BTN5oouA~j_G)-Z2`SprU*l255OC%&3Q{RiMB%$aF?;#S(y8cic%7@SqYFe@nx6BH-KekN_`zv*5eM7F#I~9(ySkIC zKx%+?pHs#imp>{z9`KijQai{FUoJ?OH(+6i8N$ zI~M(6!tE<;QaRT)Up~-PquvUKgC8_2i;|S*Zd; zrou-~`!bBcr8?rr-U0vx)J^aD@rd*4Dbi)0Q4^g)c_eWFNr2vg59`y9fZZT^X@lxk zJ}|xzMuG8(hH40T7ZBaRA%lgkp_{VFU`$=KuKi*K*z4uv2Ew3IW97~&$1>f2)yr@} zEC|`ZCJoXJWYJwd@ks<1mn!jwfFLfTv(th>$W>2mU>g{Bfglor`yF`7&Dy{_Yi~Vq zhD#7M^_QEG-xvs~O@-Dv9}@@GOQUy;_nl?(T>-z0dd{#$Bdbx(LO+}>JflQgmJ85> zM{Nu9=LOoaLP@tzSfZ+l!RI2NL?A?dFjAG=*mlnFiu+}rBo0G=#voj=uHZrmL%*Df zS;k0i;bGLU)2>q`x+qeMW5YZ7!4*pa(;9V->q5L>OqmmN*qYjLfkIIc!jR=RvjH5Y zp|jWh#Z5(OXGY|G=TMKxy)G2HfOqdNa0qk;3TTRb=8uEZ{N|`)7NBvrqY#ID4nX4> zRxPy2BCXVK4qq=0@~eXhsuP0WjFvpRSjV4OiAeO3%^q=4DMT^$5y@IK>*wFBZErac zCIk@w01?g#lX+Ko<3{2w9HD{nQ~>3EaRS-H6u09d0ia1s>z}-F1e5UWaRQ7!WaIhA zsgRLpWyb>}3BQg>gCdX_zp(X@y0Tz-4*+0N+KNveGnuK1UmRj25!&;QX*fdl?-eKn zbuUl65O#WRA`JK5KS$i~d3+dpW3|b=DI-7I1G21MF!npk?t!)NV=8gc+lkB?&)b}R z9{a|vJ%g7X18B|k@Nx+Y8h|s5z+In%{$xo*q_+4v#AgpFtAbQZ>#T|1#wL#}#V zUU7WqUhbS$^HaPuNE4^LtI>zLJi5LzMxuryohj3r?eb#;8jwN+;MNNOh%`aFH5gBM zXF^XdAS#ye{oz0lV~g>d8PJ>6KHL9pr*=`WKW?elEM2~;{#H&ZQJz%0ny87~A<`n}s z$Abh$*$RhfdB>T=8G5R4`oTpw*Z$)vmyw3|yUsfg3E=hE=8eX!N23zRTbmE_9K_i< zJ}-v>Z-G0~!9oEc&B7oFs2Us9?b=uwy z^urPX&^#G+H64yevKSudJd;8^A;eer~2GR6g$nEkzJdJ>b+iP~VJ1q<%||i6l5DSsmUw&w0x5?q{Yk z4nNFA+MC`aQt`*UCE0x#5Kt%vfz86QNz!$^(C<4AR(sCsWk47gmsrF>f(6(w4LawH zloZt2!~!=A9JhPiCIJEb-pOjB~!)W6qv4bUBd z@HwA2FL;qQ^gr$}8?`R-sO~?U1R?S)!lp-$Bz7dWVkK3;2czc@sI@Nwm58a*Lb^R* z&>-dL$^=xKQeQJ1lh|z@Fy>RmxILK3QF+RERBk_GzHkQJh?+b(CIZ3O64~Qi;0i!3 zoN2*4>kI1^4@b+5zHTt!4S>&XF4{RZ*9y-r4Flu`J#Q8-86g_*ZY*j1V;d1uxzRRy z;{%~GU`PDTEf$(=m=Vl9V7kyNVc_^`X87wXfev?ZI3%EzaD08?6Gk@}(AW3(iovIS zzj+J~dAa`pabVW)==A>p#%xh2l{~NQ&pH%%PkwQcu;6szK%H4Ybsq2>t|=F16a%Y) zrDBy){{WcEt1uA)Bjxdi0VOHT%k%(y&M>I|00YnQjZx-aJ~N(B^ZEj8FNXvGuY(=B z&YJPo3fqpdx5ftM6G{VP*e4aiw-8za8%mi#ZM}HJn*vTxp7Qzb@#_=6IQZ5a;m-KR z8Z(m+Qi=+j>+1mukUhBj6K2GAKM9E(M|(Bz8I7c`mSf3{aKq5i#l!5IUzc+H!6?-C+tgJPY&b;b=`2!-16VW7v4 z9rK$2jd6fIk67;Y5xE*#5Z)gkOJ!i$CHtd9~J!EQRKa-QUueh@rgnH0oA6xV?crj?#6(G&YkP$Id+2V zao-r{B*+gJ6^XnwJn!CLxRFTo^P9<9;9Y=lVIt}|4}TdTNKj;X9l2XVf&uIoMiJPv zI~YEB?>3MofjPYOJIB(^1OUdpjU5!|SFU%T5!c|F-uRfLk0VZ6Ca=6o>8uB#1AxFJ zTW2ONhR_8KH?m;@RKi3Z{AK2LuqTt}5}+swdM_UsEHwxe@*giaZcRcFG?uu#I%5vv ztPF2#O^0VABn5T4W6c$SeVyStNeBUUE}xuC*BUFOBjX0?VBf@N9CwenbS}rd+Yq`I z3zQH}g;jR@#_g`qGS56S06@iap7g})q*RY4AHadNe17;;%mNy@TEa zvzfi>OjAi>3i0m%wxAGgu6LAX2Ozu`tzs`^HN$z+X?HSNCz0jxjBWbHk77TJ_79x4 zQGMV)?TG99a2|*od6}-}3XXS|!-&7%o`R<5SRy(LP08WMc~>sd`|*f*>%3kQLi6Jh zm0fq^IK?=SfjM#YgdGS5C*Bn;rVREzyy3hgTkOWph*c#ulaZ+kYrE2>BOwr{ZX$pg zUN0EZ{DBVTdd4I>40=w?34#`g+&A&N7U?7J5Z;yD+iN(xB#VScW zUh%$HNI`w%MmGj_FHUrJR`?t54!v|hQ|_2rf<6t;lV%jzXryS+u8r}VR|*7%X!Y@g z2NW%zKREyjEe`G5Y{!lTTl;GXb$P(NbCOIYtGna=;2|9z{{ZU+Ph*SMjC}1dQ2O(TKnQQ!)~jh zc}2*F@)^01j|a;6%UH}}{yD};jF+@?fXP0ypkQ(uxow^vu+gaCzi89M`SFeI4$H^; z^5W%>E#;fIxWuGv=}(^>V7hX>w)w|W)ot^IRCFBA+Z7st9}X<#g}kmU*XJxC!v!G2 zvh#{bbajFGLe+SfJcXhs4k{99u`WSS168k@!xsArC8n~;!`?U!k)Z4P;|Vla@5IFh z3(!o^1FCdKv;O2SB%J3aZOe+HwRYl0VxY7d2T%9w1$oD5lCNbylN@U{Xug~j+zVQI zo#SdCS{-@9k2z?iDDu4PE9Q}Fc8^?+ay?17R11Sw#qEl-R8=L|HFd$Pgguc5OVcV) zgT^c)eohZq5jkDy;{m0)rB{x4!tiiAKOD#71bC1|XtD3C(h%O<7$N9TKzbdYcnMOU z4|!%zGn!lB?VYlSJhkgBRRi~8j1@e*b&O;Om#jdbVhf5E1sJKgH@|tbRzbV1@Oako zZX!^>zEDYTD~|JEAiO3p136(m$6r|lMCPa86AO{3oBDC07MHUSa%g?^N323ac_Vke zFPyPV6^Z`iVBrUJ`T4+iD$f}&E`v{4H;Ui^#bda2lwn;I4xcNHJ3awv0C$7M^bQ^; z`-p-NpuGrVv2XxRUu@klB~ZM4yzg7HhqmB*k+7mOwlwI6}r2%VJxTmj^KnL)P_qwjlg5WLZwPPNuJAwmFbAde^a zl(2G^nz8%Cp;krEoSyDTNZU4EFV0t6kb`+~9{2Anp$?6DbDTMNJ6Z1MH3eF!Q*URi z(D6_WA>TX2s7p3B9e#10W1yRnTY8uSH5iRqzCHQFIW}+I#5d#sN6GJu(2YmNz4$&H zrB5a&W&^0`bX*HBTOJRLPRJq&&%8)GJcj=OIaoYXlL!E9-&Rk~8V#Oi6c*nb%Md?X z33SXmVT`iV%)?;lZAY1fh#FpH{{V4UmyKZ}uKZw&$3u^_3I+mUgd|6^BMXdfD|H;lL~&RQYO2?+|{ z-|HFn?e&t23A0Ht&LC6c7~Sz*n2kdtyBr`Xv<^qsFiNBv?8Ug_p1)id4H!^Pot_e8 zU}bp{%Z^^`)HU~l&}kXhpQj#sl~Ab~A>t?TddvAe^@orfMM4E0Omk- z;VI5C0JnpWj4r~RB49Kj^>V8^q&(sQ2ner_cxg$3EzXPpu)E}$pjOHYqXR;MZ>ap@ z7h$_=7&zY}{$qWetDhK>s`BP%I2RD_{K|_cqUDgL(*Z-TCA7&F6RHEo5)gnrtHadG z!w8NZS*+120WXJ)Ab_ZCIecZMfe31GzDxlYR)IWR*L$15WwXn5zy32)>9bNV4-OQl ziAo>gl@y9^?}<$=zrpj3vi6P-c-ANwy&f~7!ub`R$Gi*$9VWcsjG$ZE`^1tJ4)5b0 z&D+^KXIauD5sTz`!G^&g9GkD}DZRDqkISr62GdBMA33acqLJasj_Hz*gnyj0dN<3` z`^Fm)E`!c;4}=fpc6?@nEiwkcba6Yy_bWsN3E>`B=Ztk=fF8+o`_>}^p?Q#2 z+fGiT#!y+MMg5FI1*WuOs?$Qm16aF;o3W$&^_EVHHGa*wW7Pz_kEQ1g99$R0<@v%? zo7o;RRj>y_m;U4%+oKqe0da7HJ3@}9@0*I{r)SPCiV$u2#x{j$n{T{`4IMk-!$Z7j zYBAMjtMA@uOS8D+O(~3q_l_LzjAU(r3*+>%{{Y-!Wf^CbaO{UW-^M<1fov|kWgNpb zHH7bp)&~(xHG^-!Vw_>fjaTEm5lV@{S56aSH@C(scdO0}e7(x^9 z#i+F(T6j+v749Hrt>MD~4ZZroh^KAN@(&FQ4t%(cO9sKUuDiGgAXJDa48Z2*M)1SU zj3CA9HWPQY-|Gt0L~8}v$l0ziE;KxKKiohY1>7%<IzhX*Sykap{15LWO>GVQnNzw|Xr5fN8sSIr{^J@~o$2%M6b?+&9b~_5D_|K=@S036cE!)w!g8Yk zYV^DrLs7KXpYtilNLOdUmH>limo8~xYzyUbkx?0-_6Ya+$7aA2>1gLK-!HzO%NSE~ z(w`D*0%oM}@C~m{0HzdUI@TH>ax5+ohjCxC#sCzfHsym}Vu5|WUwKk0Y=Q|!cymg} znUC87sw_TDBBbyy_nSS#y(bgryoGJ! zC9gdH063tl@EWN0 zX-F-FnOFprEa*p_caLDH5w7|;yn4xiSQ=H^=L1@TX-AWKaWqdfm-p`s1h<2hbJi*f z7Oo9Prz5!+YVVvuOHRzS`vN>2;(b6D2P-w!iO9dcCKRei*xp5)B1qHATpC=(3;zJz zS0buc97`v>hFZ{{4oxoY)>A=O4>)!UgV+nt`G_%wg>3d?%&6!`k2r${Da(E_!J$o! z;TK0%;EYpV@+zEoykI3PgHBF$&NdoDhzIE!-PeqRg8RTgImLu|TpdkIaXt5d(|Xyb zbG&7Ufwf=#<3Ef-wceP`Vk)HYvj~MEsrx>#22YSLmn;4P*qoU8MNz%a`;$v}!laBz zLGW)O7-S*G-fKETTcqeF_wO8tA{0@p>5ses0Qp7!FikOmX*}-{<}wdg=LS#EZp#+& zjUfnj1J?1|>5-*BtUv(}dh1^}MhO>F;p-k39R;Rr(c2tojv%;v;~P&6%ah~Q9o(Q2DO0~#Gui-@*e{bBs|!Zd z@9Q~00#mPsQUhm32m}>{W?MsKRY&8_DivdoS;@XKG3Pl4S^$%~ol93vJPj~HgU$qJ ztf*Zf{{W_OyxgDtvta0WxO>C{Vt4!HSYJm6&zB>3cJ-EI_J5b&B1>v^`SpacQlqKk z7LYU$+w+5s+pH$QaPH46G(`GGau`uf;Rn+u0vNsfVjD;*`{Ojk6(!}w)ayG&ps%dtEF%C-DUoz}f6U%saG|#Lb_N9%seLc}P*P^5j?NsUyO?Z~DTZxROsS5kXYxPQQE(v0~U2=Uo|4+jSV;k01TT zfS^K2wO-F!!+iuK8uU$MikLcWwEqC(im-~LfS!y4AZk7OaJJ-*(~K3+`~1F4wKH7a z4P(;zX&;P$TGB5o_e@H|Ib!r(vb|))j~i(0T89 z*a;3>x6h0~jV=LsJ-JkDZBagZ#$!g%6S>a^-cSUM=w6v#vY5%)JJK2+dCEG18ld>w z#xf^fcT+hz+PsX=_dYn9w|u`iO=yk#O--kHptH>U57*CthKjmlv|T(h}BYPAEO~9-|djJ zb%Q?hPrRtE->HfkoAYy>cs8E$NEAOFvZghD>CE`=5-Oby?jCi!j69YJz|{K4swfr2 zoHLxU%>dBDjd{qd5{VQvL*oS@Ci=!Uk)Y=^3MmQD*SFRQww;cc!WgJ{_H~CR=6TLW zh=7ILO?>Bb3Iu9@Sua#~cH>=i1UdDE{{VAt7YDCAPMm=YP_%$@@Z&5K35YYmdjwsc zvffpMSD-W=j4BpU2;BzsOpCU`Ky;0cJyTXe{{V10q0mX#Cs-f}LuC2z;CNO*BBdw8 zk=9l>cu&Jq90&pfvl{{^1xTiyc)?%A;-~X();1Gev!6$o7b-82J>znBi%*DsIB@6* zBJ*b~O=D@l{ut0+h3GPG$ize1FWK{mfygR<;}I0|ca#S$6+08+;5;8=T^cbYbjMhK z45BTwhu5syG#~~ASH$xCVW|+;LpM&=<3zjnm5;muO9x9XHT8<_259uv#dPa=K=2CU z_Ezl}Yj8GI@OBwx0ucoN0B$iy-Blz5yr>lIf_TTx+kkNILYDIfsxR=9(-4fTD57)HQv2!b(J1N0oiOXBUI7oL#Clst3PW zV>quXuyFXwIR|%ca^@ZPfd2s8Vw0<|{$>lp6kJq<={v|XkQRGC%iEU?zCvllgq_eSJ>Uj_J(qmh;}LFXLqzj`tU+LEpeNE@dBinQs`2rHk*z8K z=6i8COGp^>Um3axO&0V%{N~@%Hd1W*&K@(k6c^dE8*am|7{L47Tni*Nln1;>8&xBd z+w*^H5eHl6!GHn~QaiyzxyyXw*0`!j=~ESX#$8$OE+TR#d&;E)Nj>7VyIj$rpy_ep zl4_)s_6O6UTq?LIaX#Fi8qsnoTP}(mriyG%s-ri1_k_K!cX~t9TT&o2fipc zZnC63iE@(8(TIl`_hr~d#S zSmNkrkAVHLR5uL+w*<75PW=xC92MQje?mzdTo@a9^29urA7}T78E0&jnd0(4%z$cC zN^U$jiG>mu#tNZ9q3k^1cZ^{HcdVH%0W_XYj9L*i@cD3hoZUF|V0#M3QcL4IUs$hb z>99U~%@@`y&&D^jpxzW3R^|vxvMzL(9esmXJL>0B3ipk4BOA^~g;6}VQ;E*-bdkzj z1Xym6yUkJn{Jd+{dN(BF3sbmJYvVP!1U$iCTn+^*G$mJ-zs3x2XBaCUFqg1OJO{kA zMOM6K>1ZH^yZOq5zO@rq+vmKyP(%SaJKhS-kp&}4c71b%hzP(Qe>jEMYx4JULTLT{ z;wfzf(dXkVG2|_M=QE|N)*2f;c`$gP@G zo=&k$fnXdnVo~A91e9kF*LW8uodsRIyYq{%7<0Uu0Vljd*t?o|`NdMr2s^mvSb(FD z)pB5kI#N1$d&1lWP?QAc=K&G+1!(fBzPZK? zEf(S5BiEUn;aEEkdUj#5*rkbno!~NN&MZ4eWk1$6e;Psl=OTRSDVsb{ z1fEB3FNml%^Q+ce*c6@&-7f2(INyI*-&ohYmT#Gh9OHCK>3r*~N<)qaedVobFWtPo zOq-z;2hMAsz-)NI)Sg6N3V%5=0Pd!ryh#M)3Vgq87ZBs$=ZvW_)w_lfg%&kle%x|6 z23c*I_+CqrsFDpsaQBFLld&EhPrNF?k!;HJke(L}E7cJ|yt+K&K_P>6a{9uSIu@(X zqdTlMJHpXB{Nrb#p##x|;&cKA5_APkyRbU>z}ub$af2z}_kx0Ud&g?AQ93RmFlmhTjBCjt)_m)0fWE+K${G?ycM&aN9t54>}{LhNhDf2sh`9hOrNsD;sN z#V*RpylX{azg>Qe7BsqKxH?OW!{s>?C|!ePkFy>l)>|Xerkig&caH2$>LB8WtW*%R zT5p4RY(i=CyxVJD`@rD97?m#p=L}JRpx<-6idhq4W315KE=OgAQGWXgQvHb9Tp3Du@t!mj-~E$ULduC{i=nS55EdG?!t!9;U8nK9$|j zyVi0Jj1knsQ?Yb!IV$O?hVMLi?-ntWhEGQPbBSI?+K=eMW~UL}NDzWi$VPCg=;r?b zSoih_(aGza)*5*{!Xtnk`owDPy+84XX#fa1 zG1#&c=l%zHN6+3BG#&@%F$tQ4c&UptPi6L$9WyGKq+7t z_24UBGI15(Ij9C#<2TvzJgx=mD1NRy8^DL}hFzn5TZ4lYuU^NK6B`;D=)7b&8-)YO zd-sBkjV_yt4h~EzXxnwhL9o~eBZJNUaogB=bF<$rD?d_5cfVMqd29C=Qg|(x1jv+K zw=mHt*c=b60Reoy=7ulhc?)H5MX!e+c+lI+{{VH0!~{BO*4}K^JPs`p+caAJV$@jN z#CuAFE<&pwCP`OA!_HV>Mch-t=L19{MweO00?!!*96TS+GTI{B`pp})?XS)ffR-8= z3&_ZTW0qV?TJFyIez9p~__6$Cloe2OQ49}Wc{Jn2H4uw@vR_vq-=r{vor!YJ9H1UW zp}`=93s02R6HVfhGuCV&XjDVSX{94jZo|2i$@Y3-To(a}KQk$$dx$lhDn4l={SDEWJ zUG!#bNbfXY|P2y{7WRN*3ty?d-ud0ogr1@rg_Uh&vyQ8f})K z?GWdz2%+eO?Iw&>tnTn(uz4>Y^I`^HKR)tDZS{^VK*UpiJbS>lADb}&*Kmw#Y*vHagF4XfTCur1w(5~N% zo5gK4;~FEUhU2FAF+joe;+mLLUMG0QQ6fD!G(k3}yuZ->0E|cZ)fd^Hn2=aH%cE;C( zpPZPzFJyMOc5JT9JkFP=#vUoRVVdOL%fDOZWtnoR(!4jTI`Lt5F=%NF>lL( zQKuLG0CS2*p)M3?H+5WfYhsk=6$Y3j9lgGJ!)z~!U~s32jCnYBp``Bt9Uyh<7!rcY z=)B+pa&8*>a22Dq?+#WgrwO%E@E*^se`Lt-pB@ibJ3}Uo9<#RsH50qz5Re`5uQ=ZQ zWzWGp&>nu~E`?lr&zWAs#wi>b?Sz+LSbAl+D4=XT7?ryd)DKzONRCATclU+ab58L? zs4AxqkBoI`*ogL1;X_^5WA0A48Ml4!vq~)R4-{4cJ#p)(+e^M@HscNT~~>q8_~Gk{Z(Kch^~O zj#07y05FiQCDlNA?`AS6_&0^2*_>qHqfL;Rp$L z=68kYLGDYOI~@aeo2H)tk+F z73YZN`0E&zMA!cSSvvfpSI~8u!MmOw%%xsrg8~dBC#SiD$kcc6!k#2i%-+87=E{_O z;pUtZKzMua1wEm;O}<#7hKr4OacV#|2B53QXS_;+l9Q|N4;&F~J9oK+vNfi~yF5SE zMpRr^ru?}{##d%HhJku?D+`Sf%NrFzNI0kJ`=#+>D6OQPimR;J|<^lq0p}Yl1d&#R{hV z*}m}%iny_0AX!^-yS+E|@rXhl6C92Lyf7?^!{d3j=&Y{iysaI+Fn2?7naFXDoNnJK5$5A3MUVbd3&MyPK#H@SV$pIlT{DKHKM2z_;aHG zqp8Te=RX*678za;GFBTF18*nA$At2e+M(wv=}XU_dCw?vd&DbT0DH{^BT_CZoi3r3 zhl43_g(z*p`1w0b}H zoY3iaGQE2Ka!^$P7ngSc>_FfH&%=yj4@f*Z&EjWihnx7u{{Yp9_xxcS`xx~b$b}?wC0@6` z+Xu$5wP=~(b9~%N?Z>{SWjD?yLMr%NlcSLu_&rQHN)ELC*?Ke}KZXHFqr&YxIIaoT zeEYy~Bzzaf@W>izxiDxTPA7P(+f~;4?+2U5tlk`e^XE85#C3zfK~Q3R+(DaDw~X0G z14oZIFt*bWdz#3@plN)#i*@UB2f@djSP4_>6MVU=Zt$EqJY=Ge4M!SQl89emUHHJ< z*+QQ%Few`?+kf@QN`W{h4!N1E5P7?680kml#UXpqjdZPmYu20jxTQ6kK1ZKA!95sQ z`d#HUi2+k-Q{yj^JtK?ai2Mcxp>thAG%2?u_He>l|c2)Q{^2fda&?r0>z2%Cv>3DP8Z!ImDUm zBd6yop-Qsiz-`)fh-r0HNz|?P$%;=2RE$uryj;^+-70)x`BDdCVB~z`fecbtkn0I( zgU^n$*Z7htT@ zz2xOBj~(YIP+vn&7*^6aJiOv@MJZGL;+g?d9GJjV9(^)y%ZlCP!J>3*SIm0Ek(S^R z`p=_$M+#=x@q;$xcb9xBWfA}yJlsfDiCU}AyluyL8!voLzH&>p?}a8mJq&loIAt(h zgYP7u5!BRhTN_F5&KzkMl1_DqkkEujN_B>67ObV#yqtU(fB{=?&yQIQY}9va{bq!L zVITc4j$|#b#u|3P(({E#G>dEJ)}}`3HK~sRwoANRaW=fXYc3j(I?9u)W(f0jonljL z{{ZjkEg3D7Jnt7~gX0eLTuSoRxB4Nd+(j(~eh<+C4XptEjEs6XdA;XFr7K-P@5U`) z?U#&GWGNSqGXO6l(;eZngeN@vaR4P0>|Jm1j1*0g+v;n1??%Y!drPO{fEsZYlr#y!6 zCSdN;!A>VW@U?t86nxx>CCQB94@u5Vv~)rebAbW{9f)`E<&?-Y!Mge5dEkC9lW&mh#+5`z3z~tL z&NCyl-Qho`-xx*GVWGD@;SIuO?f(GIad}VfVx(L$BYnCiQG(NFj3B)9U`7X#+`NBz zdzT(`giddMu!7}E?CN!hGo2_OoCC7r!OaTb0)a_^9zOB*$66<@Il*urALXC?RtaFL z=rG!jgD-jG1B-+<_MUT-nkc2Fus!jHkNlYEgSOm&yF45Sw5BI`r)CjoI9H9|+aAIL zu&wCF8V*ULHkb~DCz^G<*-i%zEMg>;hC^}Gwoj~4%@bvOV;hG;Z!dgon2+ZMVp63? zk-;qFHm?=&gqFCN)6?0CwED)7tSdK+PN2sY2OVO%`!Yznpx0i0aXZAT`)vH-80k*1 z68DuT*aRqFY{J&zzB)8{uvORr0<;($P8 zl7D!H!Y6J43hcW3^NvV>5e{^ob9$ic5HTo9aGJy*g&uD>P|;cpYtY)ajG5vi!A|Sn z&P0w@?JHs7^5)!g%j3>H5)}N!2LmgHEGiF+_{n=5q~ENM2@N|NURO4^ajnUCa9mv+ z7=YY3d%-c#K{Vri+)D=T6Y{phoM;WKEIYV}(+U!veBd1fa2`SD_QYJOg1#8WZ;O)& ze`Ltc;Nl>VIS0yaD3^Iy(LuIi(Wy7h^)a=;tO z4Idb7q9=mi$4($1>Ji7_Eet<7L)j0t2z=rJSUF1IF_Sh8{H!q7(sXqB#?kGoiv-r4H+jF|kJU=f(;nl`yJ~mKXc)DROct|>_4|%h+B@>l8ae&$F;}R9S}A$>%A8j*(=pmvx92FnGECr|u51KEc=`8JJoU2#o8gy%Ij*MbD zcfRo?+CetpS!^-rbGC+%8gU*psH^_^#=@_6oL5ECHXSUx#C5>e&Q&UxiK7yPR!G@A zoQ>$;Z&(o&7i-o-AqlXLh;}ue` zZcPi`ax;x(w1zr1NOG?O1*{#|{kTWbMxKPhpluGv3uCpHPu6+o0xaVL`!zMe&B$oY zB1zU-O?zaG7&|Tt=R3eXTddZTyjM_4+AN2h7*+ zgXrk_!5~|@Uq3l!>!zMCQf~Kfr@lJCaY;7*88N7W(&Z$N`83xUXH=9zm&AI+fE$Pkrzm*G?aSIeNt+3c9E{@G z2diD8XqXlRF?N}v*Ar`khK3$4aZf;a93V4%&`;TnVni!|&jvNE?UgvC!AdZI?`MZN z&A>n*PnEpoPLhH{x3BLHqc^46x0~-MBoP%odwVg+OkmtQp7_WH8!p}Oe++;Ow3_*< z<0p|0FK66BeTj8etU`rq(SAKFr;xTCPGVb`$-5Y&+%V0(eD$Y3LhZAX`qi4f} zZloG7Z0jy*4TL{Gyubwnpb1YoqNwUc-_|48bJJLA4Xpv*Ba`fF!;S9~EYTX8c`>x7 zw;4#kg5;7Mo?LN}bPf5+Eg}xyc6!AbPj?t1AnNm!(_7wEMa{1jz)^e`CYPf%JGjfw z=!K6{9NFGkWtu!=gZ}_#bh`Xxb%aEO4w=Qu@-DdF7=%)X&R-cj z32=g|w#nB{CWBz_S1z$@wqVyJt$ta&QzIF_Kl3|qDm%XLpxQh6GUYLDDZ4a?YrIsf zDqa~by=N$;WP9^~ty4i$oqNPV1;frPa*$o!JD;{D%78(({{ZWNoor_V%zl7f4%yDhRNI92AK3wEAZAa1lYk@M1!!pZt5tEXgaeb{_BwHH=_*7g)yt zVgW&mxL}16(Q$mt0i&&L^QqtMo-pnGJo3bC$LK$6`n7@ngP-31f=(D5tHF-NEdq{A zgOwB-O`FC6ez^HX`5v-lRV%hu$>c1Rm`{1y!dwR+m0}G^P&QHA9(<3LG z6f%HsxSzg6A->zrZH4(>aD@qP7@%gZ2#Y*#66&|q$#E|$Xkc@w{CUA$99-GPBOUZ; z{y$9wZ=YlP%q#+~!SgL>{o)q>-W9r>u8+pT0~(JXab9$t7H+ zObasv2nBZQjE!3y20J!Avy3K+o=)%p6||Q$-ay&TzB0mv1FQlG0HY2B?fhg?2$6q( ztRMrR(7wl?dC$0m$Go04#AJNq<~aP6h&-((GhUaUc$Ghn34m$OnIBE*Db2^us8FL7Mg$LLVX}YX+8VRlc2sw6kU+GapF`>5Z7Rl}Z^wx{&LO;8A1JIzi+4M&K1#hNuB0`_b%rxn!PQavwv zxn~s5MDXjzX;)!@()<3f0}2$p`nd&k7oM_aA>g)RREnWn*?9ZG zatKBX#A*36lW(oySr@gO7@`!Y-tcbq&;wqGW5{T^Wg_(5leadArQ?e4UpQ$Zy8?!` z>UqaZKvHel>)u}Dwk*|TV7au@$0O?lk)GTM0i86h8^#R*BV$~g<7Gw9eHQP{n3d9x zJz=jHJkS2*BXn2zJI%_Ktcl#pL&;)2se9RoII2%MZTa_rQd5J@Zw$$vz{cDua=H&V zq(vfV$DAv4UN@Vnwg;b#iCRjF1o_8M7HE$Zh{V{svgD3-Y*NL$&KWU$b=de$Q7z6-;I0MSopf@21vIe1mTwyfjh#y_Ns-Vlpp zLLFDP-WI3?0C_EZ=JRXzF&puBgAO4`^!x|!e?@vL_Gta_R;gDZiyATYinQC!^MfV= z7PO}r-W*g#3TO_oK_>j%0FYg5a#34ysAxqlmoB3hG;SoskWhPH8LbLzJ%fmCxAB81 zYPi5tSOB%RiPz3d<>MR>-2~_32X2AW$nJ3YI>GpFiIjpwVq%hbFU~s=#gV2B`zMDC zB+FrjFBEs!z2d7;SId`q#$B(Ec$Tr}>kllnhPL4DBa@dK>pKDA7k@asxoB1Cdoe2K zY;8Z*5)d3RT)Y%1%i7EzpkJf(-w}SzzsZ0jCv8FeVey2h1H&}^;4Y36Uq%ryl<&yn zpzs;@ieXjgz^?@Ng=7^L?*mN=v!%X{F-tsi9$|)G3e?AD3}Zp()(;D(c;4ONcdg{( zQx;DU(LU2AS6v!Wn{Rmm-~ytpS-x!e| zUJrS+2u5(|e;9cty8)VZnN)xuR}1O$io}w3JkOYAoOoG$4=-3KN0v7p@uHT2tMkrN zkd$7BEB1vrW>i+W@ma08H!X$0vfS;zxzhuwI{lUfQ9uO(?c zVj!2ZYR&jZKsKm#n}ZFN0i$l=ntg%LCYPmo=U9qJY8Y-`E<%-2fUiek-_8>@z@<75 z7*K^n9z)m@&K7Be9R0mxRLb$GKWtOi(1N#v$%S~RR1Zfv%AtV(Js+Is43iu-HqP$nu6k10h zC9h=T71{uBaWQSy_pEpo&N~Vax~gwh{ba!kgkH^Ylq?5HybyL@CBv>c#pz8EqmBOn zxWLZM3sUw9ff!^U%f^j8Y2QS^TwIQd7G$-ecLGogF$Pw|BxfJ=aaLL$kx z&L?O9>C-sV1wK*d_Q}zA;&u0ts|YzfWfnD5 zd50JV2SrVK$ZM7t2;GcaTr}v!#WX}+_xFlbV4CHHf+bT)c9M0@DX5`0;J`}CFObV| z?}5-~c*V?BCy(O@KV|1R`Y|xEP^C(?Lt}qp#1Ipk#%zVxM_w>|? z3)4sMe;9*DW;MZ8fOn07pdvpRE(v{k!QF%D$q!jaQ#oAmw-e(SX{;eTSEc4WePVLR z^$xhh2LAx1tP~i>wALb%j6t*}#OnmqfXye1^O|#CoK`xcN5&*Y8Q9YzRo{$lhxA2S z!dM~nIP09S{qm=B;tU8r4l}0rhBw}i_kTzJHN6GK zvtj#xM+W0YV(M2g>fnlv=hsU(TI zxc#4BL#*qe_TM zW$GkE#_Nyo1z-an8I^#-o|xc*Q$@TXC_HZ);|b*D-y2JyuP(Lc7?@}_uJUZ2CuhbQ zQ40^?$4*=YJz3wp9veV7I^H59Hw)Wc+*=a1*1Z^Ag23`T&A)lAd0^-keE7$h1c5X; z)-1@V1g_kVoF*M;)EhVN)<94x;68E?!lMP#&QGA{#?>9Z@Qz>=g3|ZV=RBgv0`NFC zXo+7hj1q7y5IoM)-WWv+Nj)c=Q>zw|+ho|9m<1F!1FrzlKrX+W;3+u{SS|mcWE+Am*M4$Wdf($A$nWf8iUR3> z_c*Zaud_BOi)I0xY70J$;pY-@=p<`<1H9IAtngiYV)b-!oqm_lYx9Ic1REy%FzlrZ zx11;h4dPKQo34Jb9XsdN3h6Jr*Ru0-Nr|*9kd>V4?*EdHTeJH)b3b??Hsu#+Uboj8z`T6=qpev}pNy#t8TeF85RMfzU--KNx_UqHx@1 zIrzjL-oai2I3KbxCce`C@#7(0U&Y260-$Vm^_O{eawApYz4xq`D{#nE!$sFdAD3Aw zGoUN@%Vc;krYpeyOl)5bVo&mzFB`!`O3`e~P)hM`ah#L#e(&gTgg(xHY;|QpAW;SL zfnSa>V+){XAT%{ThTKtrwcx~gbAgQoTmUxr{7!L;v0%4;S-eODW^Ff7QG)2}{p zCa-iQRg8;dj{=^9h-KJk?X(R1;ZuLC#Tz*g-vm0f$+e(|ej{3aC^UpmI{5Y#f(kRs8= z`tyypffJEG%oxt!4d-}b-!A#WGp{SgU21Fc=7q(0yFPIpvxj&;jB~b5`Y~(zB-ej9 zoOt~ysrSBdG=Wj1o*uG7#vJ6pVxY4juv1Gg!I0_ejdz1|d4}o_S2k5Q-!4I;yp;e^ zh+kjMBnSd7@fj43#)qkuy~hZ5l#H9cfN{LdGMezTXR zsU!nmH!1~TbY3~i?3%nyevLb>)Od24l;5^?ijJ^lX{2uPzS}8R9T0iI2;B%hbBE*3 z83rlef4&>_#pexh^UgC{S*|=I(j+y8RADB8;Jx8vWlR0on^bGU9T>VXpYBzV1x=?17U1jPV7q`Wk;3Ca0E#Nv-}Rf+j-_?Zvta}PTO6F6;jE-mnkRoh zpiK(kJ}Jgrd?i@CX33mF7yBRG{j%b513qTIe15Bw+k%l8YJsEg&T8qt@(uvPHBrEA z-Ng=w0apZlwiEJr{%}xtAz!y93FS=mpoM=}h?Ym-KRD360B?_uJxp;8?7?+ohX;~y z_`IK^z6npD&Wi+4@cg%B z`B~_UfWVKM0IpmhAZBg;&sjdt`Y|v)Zs97IfXDE+9UmPZIBT#pB&-^LrPLUF?&b7G{rR; z&-;u3A!kG0Q&%r24-M-RBHhlWYBjo#pZT4FXcpDv@&5pF+J^3UpP9%AKMJ1pI@jtf85oBLhK&rhY(0M-Sahy79gWucg75K zz-PJ`FhETY*7;LeVW6t5q9(OlI(z>mU_Te4q4753M!*bUSso{ z6-t3Y*WY+W3SKcPE;;vlP+!->nI?2yKp&42Lmqu08@{j(k_E}-tu_F znZN^{OZhQJg5~J$d|x@tCRYi-J5$);gp5qBddH6}LhFnO6^Ph8;_Yps2J)cF4>&M_ znsIO<*sYSf-+IL%8Qx=NUQDGx1gv?EQsj%z@=v@#0yw2c?_Z3w?6A>hAfleh z>C4Qx6jDGe$yklZ`85ta;~ttqvu*)^O-WbIbviiK4)s19P{Sgc) zeEDXoq(AZ8#8(zB*=M|sV9`p4c>CS~{{XdwLrsM}Kh`m~I6sEk`tswKPg)PIFyIFY zjz+32t#?04X*L7f!})TFF>e@yPQ9)EzfPj3_w)N)A7lFyx6}8+<`Ml6@sA>%6sAMu zub@17{Q>h?`Ha=DobQ-fQK=FzMV!8vNszDiixH7_5-0_m4R9>3$a9;|KuJ)*%uBR~?q8>e2fc zi9p9##Bh>GKEX^^gDnI1aKytX>bF@rc{r-5&>7 zbpkp^j8BS*%qf3w&;b{|TR*HQj~PCM3a(3RyWhj*_nP5q9c{bcm)2Pk#X<0Ko52sx zFyiZM^{<`&mV?ya2cPK0P0iQm6pe@#KFfOHNvw|7So|#|uYbN%AXl@a6V(VTeYkNP``hCH1bSE8 zz~@4Q(3|_fLf;|%v0G819;Xz+fD*@1(|&Gq07agI&Gn-e+6>ybp-`w$?^XR{6hfPB z-hMH}kP01^+!7$Nv_3JRXi)-AZ13k18IIy!RCV)?;JpV&^@0%%IuNvPH&fmKP})QM z=L=tnj43t|t2O7Gw=1;>{tPyREx6?&<=y}mbdF7@s0Q&7*7bGc7AXPD!pau|f^&Z+ z1{hU>UwybM08sJZ=Qk);C^t`ESi!nWRb@uu+KK?^9ni%*wV>wJ>f~`>AUV5aoF_P< zK5e%f8|Y1UIkV4M07!g8p}Yv6Ia5|sk5}|pYOi+}xQ8b5@#hFBo_k^6FY6H!rCg%j zzA(kBp!8#nyx+Wmqw<(&+97M%v#nt_j%4p0DNRm$!SI2_;~5c1u9NE(0d98o$eU@6 zKYUtkxRj(%<(mTAgO?*5q0_#!=$vZ;lp}QT=E8DovDMtytRi?EeBwgTk1pmZqjK#q zpfTic8D%P}mzNs?2v>v0#t7Ogc)Cmt{J5J3SP2b!0!u#gl6H6gCeF<*>;P@+G4iNf`}4;Lb*R0G=o z01QYK7HbNSoSAwej5vA4jAyI{3O5u-?|kK{cb3-_863yCnhgT2KMi{R^VAc+I3@*D zUP4-Zm=h;?U!+k_+IT-WP8oWUK4Wk1fI->8gX6>R6>#_Z6PW<*aM}m|00YF$AT!HByLAL}n0RzS@fC{_@CR%9m!2N?Ad1k{Sh{f^2`Vx?Z>%-+Be7V81 z{s8UtVlaKLUXKP@&IA2RkL&ap+>&}9>yEn3##9JT=O5?v+WKSm=>3dUa4VN>ymfy@ z2mk)vmY#Ora*qbwpsC>nNBDp<=g#ZhMs-DPvYf^ z?+nkJE4=$l<>30my*dn*=dEG(>mO7Xh&}@pxECpZ8|{ch1OtG`c&1;IgBL5wg4#H> zh66a6SLN;e;i^C?y<-Jlo{eHQQUTr#eo@`i-<;biACNn{`Fh8WQ&oA+SSHbl?+xff z^nRFDO6}{H^P1ENt#|W|AI`A5FD_dSz8d^ul_#UD6o}Y1Z-+0mY06$a7_20U(d#K8 zX@SK7&x7;l8Zx9uH@68UDd5TD4WVJx&E-d+>bQ9p)HdU=9q9qt&r=o=eXr%3`=-OU z2iB#bUraRs#9xpPID&-`q^^|s>)t-&V@_)T*(#f>&!4z=4)*q`7c!Q^s7%B%wh=1I1!dy%n9yg9xrrKXW8TJFmUk4d2 zWFx+eA?8_i4__F9up@o3rq-$j4_Jj!Tx?7lS_ux5#tO3Q_Qr)ZU8LvZHY?OQd||f3(dLNr?}#Sqg`bOP;D>%7=TpOYm>#tTSc;= z(R;|4&6^!$bT<0VJHw1Y1y`DA_`p1X6$i%5G!&qRUdi4S2QCLhmv|=1flLEYLfsvY zuQ(JUqdx-d7rm_TBTGjUf{62)z{uDP{BP5`S1X-iLm= z&7i605MNoy`8;A)yybl0gT6@Nq78{}lc;>-P?-^u(J?8@(VE`7$sTouzRf4Bn2IzB zZdx#IJ21QOZ8LFOi^%{Fm>@y%n^IbWdgx4TXr(H{YWfV73p#Mxym%%UkwOeS@a3wP z$wbZreAC7p>5_*Z(qJM70K_ArOSq@gI+s7j1D3J&}O~j@7DO*GQb0SPBV}O@*QC`PqqI5 z#Lt#3Ns9ve!2uiqG7WG@hq7~s3cU}vmnbOXR|E`VzlCh#4tKU{H*tl;y)JP9SkGfdOWzW3*dKgje$17=JCJlEw4f}ZNm7&mU`pb z`M4068l4YTX3_&zJ9#b(HADeK)xU?N#&hho+ipBlhH43jqS_{KN};A$IG|Ys8Mnae?#a zEtcr$e7)h3I=W>tm~4)Qdg~1)!$7&XTdW!j13*0g0Jv4KX{_|F0T7pyCAkn1N6siy zhj4Z7@y;|7lBuopl|WHf6!nYKfC)Yq#umUAgznQG)FV>w{{WZ}HgR;|NWya$@W8e- z?YhPSt^QfCAb7#4w{X*6C}JklAqLrXd}3 znC@yrd&gB0p@{W~RW|OpoAKV<>BDVkNE&Y5?-TDlW6PT+hYhkR>bNM=Pn?H@I~rrn zkk1d0X(toUyNRNl@cA)MiCFg;R7Wu`Y^T|grl7ek$AF)YIRNJ$x112(Sb*pa3h?>G z(?QhVSh=L*s~dzPxnmx21G16sWa!(L2WM`)OqWOj<*>gQCGVqnHY5SITH&>Hge z9&ysG+Pj!E8|jvdg&|y5UZxRVV>iZ9-=ZS5)t%^-_dcAUAc2) z4ABYg>(B4>&ET(!}-e#`z1ZS;mRPA%0JWZ7r{2#yZj^VDLqDs7b^NJ3o5q`0G z0o%^J=9ndD=K_z1NPcXyzTA7n2~o6gU5UI zhd>S8&4iEv!9t#BWm0qi4ktivE?5h9A6SVAElyvYa-f)KJ$tz62U{5YjB9k^7R(I= zx5FJFItP6kxS?6YMR>fJ#SyWujPabXBPH8}EyJWUW{eUboMpSK?HJx(`^2%7Aq){@8v)_SI*3=6HMBV#xRk<>fXhd99ygAJ#Jbmm4yKt> zhdIGR1faGXNM%1pK{of}Tx4uO=}B=?xmcs>3e*XZB`Cy_zD&hfZJ~%&VoU?Z)X|DQc{qB76P%f{P!Hdacdt6-E&bcE00LOPe zS)$<$^~;SnC3NMKhxh*g#Lu2fl_~zm_lv(d$nXc=Ycwy}hqgGo277DD+qzbNnxPZ-!oVtQNlmH$rnl~4l+>Q zF|8z^ab3Syf;3Y5b3*j=*tplWl~Y^BsY{11=LozPjgMoSQ|3X~eQ|<@1lCt6=LJ)V zKkztOq%`$_qohJ@x5>oCN*0#Df6TH8Hspsf!cYyBRe5eP3TR%>I3d4M-GRgJ-VmWw z_Kym4A2_{s4$b_`5PM_8&TUc!+1g+03Mo$$Q_aI$p^$Yj61dcWId<@4+d`! z2W1tGC%-sOrB1=x4Zm3CB@?wBnO1kw(XKXwzVhMMac9;P+@O^aJFY~hjx@c0=Qe~| zYu+V5p>!$n{NSMYK(V9Du?=_YJIlbRZ3v&C&bN%hDhq#Vh6&w(7BiEdUQV^1k#(Nwd10;*y2rrKD5LXShd(OdiimvwHi9PESX+a0u zf>E}AY0uUW0P=_G>lGuKEF}K(M`Q7cR;R{20ZI8|>sT8rsVYD2II!@ac!8I~1HXA} zyG>y{{BVnkgNSAPERA{8sSg1f`9-0*D2 z1Ofoa;}ZhbLHT!pXm(VA{o|b*De?aR%&@^0pcm%=17i~LhDEnHoNwg70vgEMg2puu z7$plvm-Eg}fPvsCejo<5zg&hI7L6ukAT9fYj&47{>#6DNEh_ z4_j(aSL*UJSIe$gE+I}W?i^;!1<@JbE3}*Z%@MEa5O|5l{ zE=e8VQU3sLCpb5hGBUn6KWuI+0yOmB^D)~t^T@pSKA-0*;f~gSFK6HRis*>pr7gu3 z2L57T>-5}8Itcaj1R;wIvgju0rus})y#6j>|dMu9!lH&Pi`-F zj7j$Oi-7B<{g{dND*MacB3~Fg(hS?X@rbF#DNmW3n?xWcA%vr{3;bq;Fyz{LGAOLy z^2qY5_Ta7}5yQqHz)Fa+bUol?8=~yX0SaQzj4;jm5x+U7YoU2J@sXBd1D0uc%|J@A z->guiQ{u-WA0Yj(QC@Rp=iae!LXc?0Eeaql;;rW#$`)O#diup%FN6O8b%a`Dz&P94 zmh{R1r^dU$x}A3O@rH;RKtG%(46ZS@A2_bZ@K`LaxG*AG>WS^;$;lU9x=&A8$cU34 zj4HCxVuvN`Hq09!yT4t$<-4>K*ujS=KD>S7@F2y92jd)BMAipW;mLc1kwcRd6S1|) z;$uCl;^d1cYJ!p9!GZ(eJp4In(2HK?a4~=g3D7)aiyiR=R`|h>DXz1|zWn5pM5r2i zbG$4v4V@k_dLr?sSdp1I&aoKEEDsqj;iCWo1$jJS^IB3X&bh$aG;5r6qt*xxz?$zQ zfI~(;SA!Fa5(q0xP4d>U_0=NL_xiXTgR_vCC5Xl% zwQIwB%_`Ce4d)5O8z++(5OkZ$oH@>%XG5`SD)J5KdEPR)=$EzEmnP${oLhL2T)Y@$ zu}Mb~Hd(ur;_7Y3slPeO2g!!TLP$N}@*V}VxavqxSsI21tAZbRUaK%I>R;a}vd0+< z5jW&t)>lO68p6zl5X?j`1Yf5JT{e%s}4OEHEw|k;N973!6~_6?nqP*RbNt zL=tyA=I1Sm1O3VXA_@ox1n=d^@G2FBe7UAc6jJh&Q1^+UJK(?;!u?P3z_`8Wgdlv* zA6a0CoFfwy(7CpW3Fw>{CWGag2NUe`fX*eKtoeOnBf5vYQBmP0E-NH*i#x>fL|$f} z=NzF3M@ft#=}Xt%3NY>G=lo!A1SNmWsi+s*{$;@m1_zA452zn_sqf@m0g}zB^TGN7 z(g9yaHM&33taKeFp{M5y&sjZ+{xADKM+{1~55qsK<9>2CvL7?_Gz)30Wk?d1+!i{t zMsZ%U<-(sA01}BzoFKq#K>Yc_*VtQr3@RZPoBj`3M`j-PZ~2jk_a?Eq(im3Uw$mZm z!v6r7wDA5|Bti$E&z2$9f%fkPG@v!}#ABGMDg^@s!SVEf>} zK%D0<{mYlouc}}kvM@NTD0>$^6q85Gl?wp6etg_bvI3FWX1z>i_yI9}uNWJ-unpgg z(Fq>QCL0n!-fTPh$-WNfZ+9f27fO@m$#&YN=SFsjGozNR_awW2INAaSPdMd?T)bfo zA7cLi<-A0bk-C_o0Jn|91zWR+uQ>rj;cW5Qn!afj3%K<&yYExJmi36y&@~G3I-Fpv zE`^ug50XH-jd#^`j}5%#eFpyk7;Upin>8ms?kRCL4KIywVwF+>K}lEX)=1@ENlq^K zF@jk9f#$eluJ-Iu!P&c=NaOfp4wn9SuJj%lbkxQ}dlSdBR&(cf8(_zB88%pm)w9K@+D4Km(*5;a41i@;JtE z6;N@{IG`r)SUrnvxypIIaA{0OU@w0ed$>goJh>C0`R5uD6w|VQ_Z&efXxGNP@rC7^ z0bVN1pm_6;-chQme)xzb=x-R2)xGBDAl1m@Po7*(N>FHt$mNBT61T=G;PCj$2soNk z=K$V}O)yY%KR6qBh7)sMaBv=9&Q7whyx=-7Y)Lg$a8B_@aMU*9E5q2|E@*OuN;iu@Q+TX23}j1>~>{v2V@ zDd5Jn4+UnJWS;Q7zc`39%$zY&?S8}l;Q$>u_&Chln4F^|+KJM3V0Kx{weKH%Pn;ka zX*l_M%h5Ov2ZIDDg_uWQc$`WljgJ}E1T_Bu>5Vzk~UF z;zG6zxCw)*;N$N;dGf@9y{z`7-`w@<5RJ+_;J*zM9XZOO=OFsK&GUbo_kHAZ(0#x2 z`s9W{6-R#$CcpzGBAo|9;$3HxFtaNJn zz>#e)i~7Z`@=Js;yA#c|{$rPHfDzO0@5Xw|2Lr){OK)&CmkN!g9T+fH;Qm-v0!SmbEKq!54h)n| zwRpJH1We9G(ZCj`gZ>l6+}<2dJ& z&lmtLi6JGoyNr_PcsRsDszUz&zH&l6%!dr~?Vc_(fnpD-Xjm6*rHfS`K2$rUL96l(K;=(V}<+>I32 zrE3fjBQ|5Ih3w-S0O+;YL+3^Smxee+FiA?SzfO$eR+uY8N85~E{{UBd+k{pP325=_ z5vUCT-_sH2@|hc^@d%ntCCdCRH-`y#ez4+E!UCx4PY}r$}ztQ~(-I?=_F%Amy z=)_y&r_O9?oVK&PPr(BF-bMaV3G#Q1P`O(PcZPr~crpu0yQlM<0CZkHa)kmU1>-3R z)3=$3ik4k2v531RPuZKL*3+*y5Q`Va4Pv5=P0jdnU{d&S!2l|6v#fUUhPgE2FbF6? ze>m+|TjLU}LJQ;cU8O^6ykWLZWWhwx73W=Glr%fWq6)HigXfjEEfJjB4-CWwyMB}X z%Ft*Ro^d6bvgb&w*cexcg(@3gZs%CjVg48#D7gF%LRf5kIF^&-7@5nGV%z}g)mkQ2 zY7{PKoLXw}ta8AS+ z{{RLKtT1{!bwA4xNqY_%rSPHqV#><-&JErpS->>kL&weu0PRViIvKcf zg~$)#iYQmhm8I45im2cyJ~mIzHN5Fh;}vaOFJN_*VMy@aF-#F#84%haXkFuhvRhN^A%p2C*_dk%r&K2*IiYVx0ZvfPjg;sn8$Z2)bH!yJ#6DSLVDKVyS$JN7_ zwE4k#w9)0lallYgULAEl@utTs3Dj=zqM`&(oE_|6nZ5Uo9`;g^z1fKq$A=*BbZ_)F z7tt|bJ{@0#!l6Nu+4?;5tTE{zfzW)G1vI=RV_uuI6ClzNWZGHaO^Oa7U zdB;=t${v~=B{|oO4+pj%fWbu%2`BrG*{=h}Mi7UKiwoZvgHQ^GGT8@ zEgU#vgj^1OaU~ohA-|kZ>eJ3#>A1F)Q~Azx8^k(I1=Qb+yerQc&O9@bx@=_oN%e-` zc$VsG@sO+^r#o$o!VsAFOO0OvYz z0`s~Luh25wjm^V^EdE#U;=n%2{hZ@c?m-+^AEG8no{KzWT?P$J;46zIB?iThzgY3f zjA*lRyw5XIJ>ht6Dbiv%jVH9SYicJA-gW2KN zITg*nSSl>zyu38aOt{KGX*zW7+l%^-WDdMcHoH&)B{qK_qVELjtCWZWhXe8J7dB6L zX=`aFrYEd^t!w@V2DG|A*@!7Ds6GmxlNw3S0r(&9CIF>`4}+w|@)UU3eV&DR}BBcR!Y01!pLmLkBHqB!~=gD8}z z2kgPbXDP#r8x^w;laeb3XG7K^3889+09IS$F)?DgYsMzvWB6w%)38Y%9b(y9A+<7< zQ#9k_H~=eWmjIP+;@rP^3o{Y|a7<`~Zh+QUiXTOsILX*FPdJ1~ViGnY?;L%D#l&#O z0Lkt5m(>?K2P3uHhG^uHKXPO)VQdxc@rN!n6h@?Th$70Odl8khtV;v-!Rw_Eo}9ec zhc+Brqj|F#K|(R7J^kcZ#0Vic^NL4s2`~bnfn4jKjAB|6e0ed66s@Yba+En48~}E{ zav+#Z%6GfK#Yoh9#MKju-ox92U|n?o04!YsQb(*F7k7+=Hg<&g9`H>fP{TYn7r!{8 zNGQz|e$^USQu@Y+03fJ&PPOxg-5r(Lr8pt2U{+i%oMU$LK)Bt=HN zzs%#4JJ;x%qfWm>DLi0XIxYpTqWk9^1=#>c(bgMrfWjRvl&JeM(xXGHtt_DQJa2d@ zFUiv69!RY^553|PNCmC`0Ncc%VPBkkQX#~m-tvG+dpI!~5Cn}coCpvgUSB^rEry4q zyeOautf@R=IVTtg?e8K6?MZfI$Ti8eT#q1aPbt5qAm&XTTj7JC6c~5TI$+bz0m711 z@rY)n3wR0HiK(0v9CNHkbBTpo)}zO~utbg}aZn2M+C+9PgPL+n z;|VEEho4ywxY%BK^5)IsdGFo|Vkn(pla!Wyv$|f!QanoSmR_xNePXS-5QznA{{XH8 zMNZE+yXO{Q_`?H|4v~q7qB`48yeJCxNB;hW!5!oCKX>PxWhv7Ahc{8YBL47K>T}xw z^u`YP%;Ncn}+11_T3;h(8%>+zuYoD`Ts8!+6m2;fM$+ zbjJ!D4dr`B0f)I|85hqF)8lRVXsd@TI1hvI>rhM+S5^&MSqvRM^q{E^luWKC;G)5Fu|T2xJP{BgCII(v6AbYi$O6z1i)4qIUjWKg0P*= z{o>HRMraW0&g?rfYfF2>7$PIgF@7!rf#Td-Pa=48@rW8LfE{hFSuN4_mDyG*4;7FSQ0X6MGe>Nr2}QANHC6o99`Tq7WVx2o_uGw7Ui@|;q2deG{4?!U%o3MrB}{r58oZ`?+JIu zth75dTo|Ub?jotbeg1IBlmJ5cT?SRv21~Du4?zs8#sIX~nxk7cT1kr_HlRcCgrx(v z@yi2W2Nwu~5NVbiP;@v?8}aKdxIS@Wrm7&bPJQ*tG*u<9oa)`&U}#N?t2xV}6!|U* z6c{mlV6eU5G?}LUCY}6b;2v9XHVnFM096p!^^Y^-;}Jj!7jl@P1RWGVID}H)-U?|E zPQ&2-vu_)yx%=*X;@DX|VCrKEs3Z7x=;~w|_0C3bcykcC?D8yYpd}7KH?N!UN7_5(2rSSKM zi2M-qkbw%od}ZE07e*TKo7ys#;8*>`2@tr{=QaG}qvIy98@aE>E&l*z*JjT05lHJ< z2-bc|kY00VGK&)#h(H$pitoM16~4x-~B z*+@9%U@L1xd%uqvNa&@2-j~LAAy$`+>+b=A!h$;TzI5XfE$djJgmg>!b5+VQ4zcM; zHA?&QfX!VUYgwy7r;gl8ts|EaDM}M&b(SzytReWo?lZ}V>Cs#Om1l6{2g!4*tT~y* zxaVBqOhOgqxsyMv7%2@&dC5da%e(~<3-ById@kD2foBc3&#Z-a5wn}?ym)a7a9j9t zq8c9<>%9ttdzUp>AYC7fE4riJ@Q4P_CL^WAkXUy3$bqg)oaaDseEu>uyx>N8#1!^Z zA|;Ne_m(uxvr*vjwZH-_;c}^QZ1;kwcx36oK@yN30_&Whfx2oQOj~Ur$?mv)INIMBw3z_c z1b9ahPk64tY@A#aS0HKlaKoUjOg{R=YYsVZvt=H^KXWProGan1(UbyA*rS8aGF8hi z27niQpEzm}ic^OYgw<#%2fh>MIY{Fts9m^@!>censlRy{QA+^%d|-jltttJ02m4A+;+jQJ~126M0cmZSQ4&s z^Y&pt5^_fzEfIEsx7d`9ZPq1igs&ccaC#9zSJHKy4AeBM{9%Q#<9ae$ z048W+LI$U=jFf{;F3g+cEj-<4iN2NDic$>Q$ujWh#iya*#8s2ytUVt%dE~&lG31SE zF7r_~c1kVhoc@wHa@d~K2_@mY5=>Jg5)K*6S3!zd(H|F?7DJ^GzYwYBvZX$ z0k?LK84Y)WY~unHu!bO1n?j=RXVxMG1QjU}4-br1HA?DH_q}G@0(WB-0@=BUz?7!X zLg;kmH5+uVIFbqnB>Tjnz5O^)cgH4aqCU)Aq0a}y^5ZT+k~wI->v-0X8$*jPt}qeh zbsV2LIR)&zU>=Q0pAOUO1Q}{9><9bDQ*EO)j29h;_;-rHi>7b037@KgKN-B2E7-(B zB9+IloLjYda0G{ptm~tRl>ps)cyVr@JqHyzZB8(BD>m(806I&dFdn4lrZBsC?>>=CFqYaYXw^QR0J!)?G zJm$>k%W;>_$h~BB51WQFy8}mN)2iaNO7^^C9f9HF25(aokKSxZy(6r50=VsR7ib*f z?B#mNc)H-gAzNA?r`wB`4FqY^6s2oO1~CEypz}O$3%IO1_F~ z42V<~2dAs)#MQTB(&a-!cYE`QeWf4mf@@m&#L(jn8L@C~^@?DH1`f@9Veq%0L(4V@ z+oMkXWgsW9BXQ&#b~mq#IlwUi>jERN9paL<6~}SlaPU8TTIzh~B@>qW9MU2oDv8|H z!JfqnW=mw!k+_&z=pK`^i=GOH<@1y)TxGcP#PB_0>S_m28s2Gx(EjmObT}>8b{Kn5 zoVoeHi5Ib_-VlPPba}$;l}m>b1ExHDV2TH^0}=OYZ3ULBS3u(p1ghJIe>mT%_&Lh=$oW2Y=%g|n3xj|l6-Yh8qDC9JfwiSdp7|5p%P-=NY#3Ftx&gDD=Kh zSemJ9boj*ue9M3-c0?wh4X@(`$Hqs`hcsi(j*0%}j0g~lym`u|X2zlZ<&h&}SUMZV z0KZOnzCZ3eEW0{Bao=hcM-alPrwv^O`7y+O;ITOgCnj!h0tZTQa0@CT^uF>I!PRgP zAWjFo5LzyotRlCsIV)&77X<7ml=IW!^@+5!H*%L$*PQCirkC*kibHDq!;`14Ij{WX zHLkb5aCN>e%-O?Tjl*X=Vq0>cPEN0S-S>?-Ey#KmHE1 zcTXDeiGQ4$ckIeq2t~u5Hzr2{(Hu4dHuZ_5mmm)O`NLJ@aCygr&@51?e7qjqYIa%! z!udSoRw}1iA6VB@&eMN5c8~!@^MuWiJ3cc=+Z;6BnZvFexSvaTzq2dk5HvB6kIqGR zHOqzGslafq=XmEDr~%kJjugHjskZT+LY8oE0pF}#s5?%u12iU^i+S%O9`OWp4)AZV z2kU4sgI{9a&sejt)8YRBF;Qc}83BsAX<$QPUd+wfZ_F) zOqQvSx*c`z)@)>RqTh@+K&b2e#Ldd^z5B)3NEKkz9$xZ{L?#<=j@*UV++9-uK$FSq zI2q0yd(H=VY?2V$WyT2wxLNNzQjjQknW#l{K@FSAMYXe+gmHub66lB?uX!U%LEwpe zWBH!E5vufOAzs(4AGGinL7d{zrH{8RV7CwrdFSgKwV9y7Ouj;O>l+~$9fz5WY>jt* zGG%tWZkTG!rL^834|z)tc*5+5fGLQjQrV0kTpb@V?{@`}TS)8gB%PE008a4o0-FTs zoLpk>QB8522qzZhO(^ga_Qh#_ARca10*wFwbbFZYVYO7p<>OU(smFLq9e8cPMPVCL zR&!jKLs8Qc?C%i40o=RA>&`j^S3Isyx70F}J(m^UE2tkBmGVe5A6N|4L3wb| zrx58e?uPeFb$A-kHID>qLyRt-u;vC1mL@fI1#y?@gtN}ED6pK!ZEsT=sv0mNHhaNn zuSQrv4n$qzm;w>Xb(|o)^M(=vs~96@>%{8Sa4Oq&L$uNHz;Rj4{#Mqh-ujG zD=~Z=&-;kS-iF=nuQ{v{baVz@C|8HSwpTI$8&&AY(1AhOgQI;xPrM>|3!aC660?6h=agG@lD#KZ8`RTF}!)7hF$*TZ}V{{R?^qz`Pnk6E)Lsw4A+ zsAWqhhC9Sal~g{faWb+hhsUfw`_Z*LzV{p`Tms3{X4k~PatJ661JWF<#vqV!MIVF_#fB*uV@a!J>7n4EOz$ zybBM?&e8p`9gE(uMR72CVoE0ncbr0{;NLh#!^`|wE(c3#u{?1wgbV&=;k}byfBAqS zQiyWp!*mqrI`xJCvsagS0fEy}U)g8q0W`WVmo>a=`^hesU%oBx93c)Gck_&uRW)8= z)(HWdQD4SE8#w%6soGqz5>3$Gyg(xO346F1Ad64!-g~C_-ULK=AIq`P!)4cI7< zvP?80S3P`Yy!&{M@w02l<14YCd01f=J(K6&HQvbuA2%C;R7}>KJ5}QiDuYSw{$tSW zp)d*4fZG|Rr#OMe_V?=~*)JmVj-6KY=j#le@*EE)T_53$m&dG0mJ@z~7C%+|;tL(# zFl#0mgmuBCBga^31a9)8L;S$IjI!Ylv*icv%d*P&)^Flz{mgQ}p$I76zxSL)Nc`oX zu)}F627zF>QBZ8McyKit+O*-t(LV)me>kw}=o;65c*vMulQb3L;sSjwB*YV8w!N?| zN~*3v4xQ9|Vj~eQ&t@t^n$$Gs0;x%4bA9 z;bk=TAHR6O&?RynF_5WT$D5XnxDJ%k>B#Ma6_rp}#et8+Y~l|7@}-6?vwU_v;*fx< z#Q4Mr8`Aab4|Ftg%f2Ug0;~usUe%2HGDA=?HoxXYk~&1cSPu<`ybvx#cg#75G{;=> zq^~Q!xN%$&-gCdn#%Kn#0#6~%5D+wiLjKbf0xQF;d3xbCSznAPwJb`y-+pnS<^c_S zW}cyjCfy;_UZ)20$i{U{aegj5S zX*8Vh-(gwH(eZ$iT0?_3=qcPi;8Aj;g1Z5c48R8`m$@`&8Iq>4YqIS-z(-5Nh$5({ z9)I^axR+V9i@~Gx)&zDZ&QY|eI&yde(Z4yYSe6zCctCGIJYd?ZG~Vy|h+RoKCRKUo z%bOfRIgLpP{^wX4b$_fCNzRMI-c5nwzJ4;p$UJkDMI_zvgI7lt1&5X1I6x%bVSob{ z3C{k^W?(cW{j$N3a0=VO{NNX05Jl4U`{IN#27o{2RVjf1>o~k=$Ccll*+_PK_`vKb z6Li2q2!{&L`0pl!{0H%Y2sl@GNdlgH_2h=#?J5FjlEo-RAyYVE^tfzTk}mDJi- z(ci{1d&tBdR{%$#qx5+(S=uhI7$7!8!OZ=8&Jqv|Yo+y^3T!vd_Ga^=6b%m+{o!5- zy}t~!sUYGx6%D$4ag*779DHO*KSE@EaR=Y)EG4=}ra2Wy+uyvHoFj00eP)5$s@nRT z;7plFKYV4-Mn-QDMA^>?@rw-|8hpLxg@B=;b_1?C=3@AAl5nhCeB|YA zXu9$9mxS8$f<)R#1e06Jjxlv|BVir4e{2NVSdh^q4%`QL0WHBh z6jy&3@`XEmVsSCF2EA_+NO0X`TGq(44uim4Abn8kM{|=6sD$Bni@?ny)f#=`MkwGt zjAi5);3x%FD1PpBlX$ChLZPkioQR%(Y!CntT9NU9xSHZ>Wi1Ag&%Crt;^v$y5)Cdv ztK#IMknA1I7OIL?v))LmkGSmc_;7A$D3a?7S$&1S7_>zupIvn_aeNN&`5GphT!R*^ zi}i!3(F4Ql05s1B@WX1dh*yl%?AYM-l?XmiV^HA{9pD3d$$6!hVgZZuEo}$9T?L}u z>DB{5bZqqH(uyx7dtbYR_ze&N=*h{lr2OUH7^6(wP}p>1CNPws^@&0OG)uH}9y`KS z2?#`WuCX-}8kz_yA0Ig;#MqShK5|9$m%lFUe4 zi)Y>itWY$4F_%kWcCKAu5Cd1e=IMnyz8T<%wvQvmykjGRRJ$}_x>|rQ!Hqq`M5moR zesTqFnKi+$oJS|9m0RPi;8wx=x^Q|jqgI6iZ?Eej8H5LjC(`Q-JIAf%GfjgbK6YWn zPYBVJlnFcRFcUk?Z>fu53W!JT?;B~~e(@4R2`W^+yFLK#Pu;PL_$@* zO!5Xo6IjGRmw(PPRUbC#^S#k><~M!vPI6Tyg&2=kXsooCioaEOJqH}(AGkUZnMbC41@%5T0-3@`}MT^XQ61>4p!YQZ32A0v6iy;|BHJY*RUk9bhQ z_3r_uwW;1P+Sa~ZV2uLxa&$5@9vo=U8S#wG^L67SF{mVI==G4TI-DqZG4S39+tKOr zyh&SVTpr81x62h%1~UXvetU+a4|7y1a%^ zw6VWA14l4a_49z68RYrOpnIEse!b(3$8&Y$z$FP3=;Ee>k;mRF@bg$_1tTkeIWy-2 zkXIcA*Taf6ibKx0#N>|$E6It z{)c#|oEAO(=V#~^{`snayoMkT?#=7g8evx?&6N@@XufdP44{SZ2L=ao5rkBH;Y+rJ zgl}GO6+aq**GGl{9=d~W9t|T zc|bfy?9qo@t!gMxWj`qqZQQv zL^$(~837JYw-^E=p5M19u(23;GucmPymAxrtLV+Q79Yf8-5_??iN-e}Z9E>aq6*f4 z#Dx6Sr{e;;JDnLGtPu!*0f>qm9o%OhkAWAF?*b&+wNBio zCCs~j;^p9RSU>aemu+R^z${cleZCBbIwDQKc&McG9c4vg%i|6J8vrrpytpY>fCNrv z5J^pK(0Iu34>Sewl-3%12-1CVhw1OVML5KHS{PbbyLz}LTt#1o3OK61-&st~LR375 zf_TK`4v6uTI0Wi`@)2TIc<_ERwzB=AIFf-?CY>tB#<3yTZs{`NAX3bKeCJ^xLn8ok z?4|%}M`FS6>n>5yG77pC-fS4Jm&ec@aPSdVG{ z0B}t<(WY3yyR^d+ge$NR`WZLX6jVMiKuHK;(7)zL%iY$wm?FqtPV$}fFbEbMyvz!v zQXZUIY>y6Ju%gSCon-wLRe5lr;9CN`JJzq99DqA!uR8A*ypYhoF;g0{;ORV=8B!dJ z=Y3*PN!SdF;GAXrKWwmtt6P1iylh+uwo_X>oo9C}*GR{g&NzW+kBsIF0EPEwIY8X3 zhbA#ndiXdp@}XN8xa=CN0U86%?&3Ir#=|+k7%Bp{M8|5cW<+}iql2kMg03ud`gem! zN)ah?E`aK{Z1`vA2Mnk=AB-u$eRF^yFxl!`4|svC>{{Z6@D{ND-#NAOhaT=&?8}!4$w@K9T?-9N&@B04$F#%*02m70p{`1yY zAhk`0tg4q-69Elar#PSuHBK^_a93H-6{!@-Kz5D{RJ2;!KfFATF%EHCD0Vpi0Cj*0 zVkjK+zOf6Wl<@P72gA-HfpW0>d|^mDS$r4=i2(LI>op`*30u82)?8PO89cLK(VlrC za%R9(2CWyYAb>hO;=$KxjdoSeTp&d#_c+Fiwd2IeQ$T0~^MT_M07WFFZ=ZcGEo>j8<2pLN6mM+BNL7dAj6N6RF3m<8}Ua8gNFI&+JOqi8Ln z@avNVB~sA3%NCc`F`jUy%}m;4yTIqyUUQW06e>%v9ONosFKLvHXf)@%8-qtVWdJRQ z2dvPE{9-2bG%au$Ep(H3OH3jOUc4U}T%J7RaTBHA=NyQ*JGqg~9*O?|#vTX)tE>rR z@lgC?q)8#HI0+8fb4xK4m)`PRRT_`HB1s*I^MDk`&3bc}ybKhqHHoEHG$NQS;%#yOi971M`AnR?5-y zjX{7{`~EOI*jt|%6&*J3w*we`1C36DXhS;>cr-$QL_gdJSSJ`bF9crz?>1;f0W1}3 z-ci-w0QJGfSP@0=H@bJPj2IV|IXq$$sRPTutYG7QY7j$+-TTB+wU?93#+)AaCkS?B z*uF)lN%7tcC^Y{7;^UN$Cbigg@rxpL0x;hXzWK@FG3STQLP<&@A|6*YLy|F*#Pfkr z09y$s2i_}a&>=jWhV;DRrmjIve>r$TQi2E87*A|rUPyQN!z!{xyV}>BB7tmsKJ!e? z(o8S`6#nyKHwHJpH{&D?M@m>{2Td{-zVK3z(9%i1pPq8WGqrZ370Ygw@Z*LzsK{|AW_tBIAqiS`C0k++p zWqxId9xiNx7|Xl|g9?&?%@>QtAy&sw2Q%}OMvlCmue>=Vm4ZGQV{-C?YX1No;}9%S zydAiz?0Ye}Xw}o@%?87|b?RgbrH-N{{1|$0iV3{i)Qz6s2IT=2VO}42831uy=Fk-I z=Ox=Th3$3D^NxE(bnW9W65iTuUHfL{OF*cyL)Q0*@ G{{U+VG=ceLFTpWMhM;c> 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

qtDJoDne=(XPzI%BHNb| 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 literal 0 HcmV?d00001 diff --git a/bot.go b/bot.go index 803a0ed..6b677a6 100644 --- a/bot.go +++ b/bot.go @@ -7,6 +7,7 @@ import ( "sort" "strings" "sync" + "time" "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -162,7 +163,9 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { // Fetch bot info to validate token and get username u, err := api.GetMe() if err != nil { - _ = bot.Close() + closeCtx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + _ = bot.Close(closeCtx) bot.logger.Fatal(err) } bot.username = Val(u.Username, "") @@ -176,24 +179,28 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { // Close gracefully shuts down bot-owned resources. // -// Closes: +// The provided context is used to close the API client's long-polling request. +// Upload shutdown is not context-aware and still waits for pending uploads. +// +// Close shuts down, in order: // - Uploader (waits for pending uploads) -// - API client +// - API client long-poll request via ctx +// - API client internals // - RequestLogger (if enabled) // - Main logger // // RunWithContext does not call Close automatically. The caller is responsible // for invoking Close after RunWithContext returns to release these resources. // -// Returns a joined error containing all shutdown failures, if any. -func (bot *Bot[T]) Close() error { +// Close returns a joined error containing all shutdown failures, if any. +func (bot *Bot[T]) Close(ctx context.Context) error { var e []error if err := bot.uploader.Close(); err != nil { bot.logger.Errorln(err) e = append(e, err) } - if _, err := bot.api.Close(); err != nil { + if _, err := bot.api.CloseWithContext(ctx); err != nil { bot.logger.Errorln(err) e = append(e, err) } @@ -476,7 +483,7 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { // go bot.RunWithContext(ctx) // // ... later ... // cancel() // triggers graceful shutdown -// _ = bot.Close() +// _ = bot.Close(context.Background()) func (bot *Bot[T]) RunWithContext(ctx context.Context) { if len(bot.prefixes) == 0 { bot.logger.Fatalln("no prefixes defined") diff --git a/utils/version.go b/utils/version.go index f335c22..e261a40 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-rc.4" + VersionString = "1.0.0-rc.5" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 4 + VersionBeta = 5 ) -- 2.54.0 From d04c91342b285b50ee5a76aee7ab8842f0475bc1 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 23 Mar 2026 13:13:47 +0300 Subject: [PATCH 48/99] small close fix; logo in readme --- README_RU.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_RU.md b/README_RU.md index 65a3c4a..abc0048 100644 --- a/README_RU.md +++ b/README_RU.md @@ -6,7 +6,7 @@ [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) ![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) -Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке中间件, автоматической генерации команд и встроенному ограничителю скорости запросов. +Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке Middleware, автоматической генерации команд и встроенному рейтлимитеру. [English](README.md) -- 2.54.0 From db31246eeba1d7b77384929a8bef9f20092e829c Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 23 Mar 2026 13:19:39 +0300 Subject: [PATCH 49/99] retract 1.0.0 rc 5 --- go.mod | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.mod b/go.mod index b6c1042..7bfdeb5 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module git.nix13.pw/scuroneko/laniakea go 1.26 +retract v1.0.0-rc.5 + require ( git.nix13.pw/scuroneko/extypes v1.2.2 git.nix13.pw/scuroneko/slog v1.1.2 -- 2.54.0 From 7776acaf124b1cad358b47ab5470a9832c74fa28 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 24 Mar 2026 13:45:19 +0300 Subject: [PATCH 50/99] refactor logging setup and split local/remote close APIs --- .golangci.yml | 14 ++++++ .pre-commit-config.yaml | 41 +++++++++++++++++ Makefile | 15 ------ bot.go | 93 +++++++++++++++++++++++++++++--------- cmd_generator_test.go | 4 +- handler.go | 9 ++++ msg_context.go | 4 ++ msg_context_test.go | 4 +- plugins.go | 61 ++++++++++++++++++++++++- tgapi/api.go | 9 ++-- tgapi/api_test.go | 4 +- tgapi/methods.go | 8 ++-- tgapi/methods_test.go | 12 ++--- tgapi/uploader_api.go | 3 +- tgapi/uploader_api_test.go | 4 +- utils/utils.go | 26 +++++++++++ utils/utils_test.go | 34 ++++++++++++++ utils/version.go | 4 +- 18 files changed, 283 insertions(+), 66 deletions(-) create mode 100644 .golangci.yml create mode 100644 .pre-commit-config.yaml delete mode 100644 Makefile create mode 100644 utils/utils_test.go diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..a7dcbc3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,14 @@ +version: "2" +run: + timeout: 5m +linters: + disable-all: true + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..21e64ed --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-yaml + - id: check-json + - id: mixed-line-ending + args: ["--fix=lf"] + + - repo: local + hooks: + - id: gofmt + name: gofmt + entry: gofmt -w + language: system + types: [go] + + - id: go-vet + name: go vet + entry: go vet ./... + language: system + pass_filenames: false + types: [go] + + - id: golangci-lint + name: golangci-lint + entry: golangci-lint run + language: system + pass_filenames: false + types: [go] + + - id: go-test + name: go test + entry: go test ./... + language: system + pass_filenames: false + stages: [pre-push] + types: [go] diff --git a/Makefile b/Makefile deleted file mode 100644 index ef3b6eb..0000000 --- a/Makefile +++ /dev/null @@ -1,15 +0,0 @@ -# Проверка наличия golangci-lint -GO_LINT := $(shell command -v golangci-lint 2>/dev/null) - -# Цель: запуск всех проверок кода -check: - @echo "🔍 Running code checks..." - @go mod tidy -v - @go vet ./... - @if [ -n "$(GO_LINT)" ]; then \ - echo "✅ golangci-lint found, running..." && \ - golangci-lint run --timeout=5m --verbose; \ - else \ - echo "⚠️ golangci-lint not installed. Install with: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.57.2"; \ - fi - @go test -race -v ./... 2>/dev/null || echo "⚠️ Tests skipped or failed (run manually with 'go test -race ./...')" diff --git a/bot.go b/bot.go index 6b677a6..f36bb71 100644 --- a/bot.go +++ b/bot.go @@ -7,7 +7,6 @@ import ( "sort" "strings" "sync" - "time" "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -163,9 +162,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { // Fetch bot info to validate token and get username u, err := api.GetMe() if err != nil { - closeCtx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - _ = bot.Close(closeCtx) + _ = bot.Close() bot.logger.Fatal(err) } bot.username = Val(u.Username, "") @@ -179,12 +176,9 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { // Close gracefully shuts down bot-owned resources. // -// The provided context is used to close the API client's long-polling request. -// Upload shutdown is not context-aware and still waits for pending uploads. -// // Close shuts down, in order: +// - Registered plugins via Plugin.Close // - Uploader (waits for pending uploads) -// - API client long-poll request via ctx // - API client internals // - RequestLogger (if enabled) // - Main logger @@ -193,18 +187,19 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { // for invoking Close after RunWithContext returns to release these resources. // // Close returns a joined error containing all shutdown failures, if any. -func (bot *Bot[T]) Close(ctx context.Context) error { +func (bot *Bot[T]) Close() error { var e []error + for _, p := range bot.plugins { + if err := p.Close(); err != nil { + e = append(e, err) + } + } if err := bot.uploader.Close(); err != nil { bot.logger.Errorln(err) e = append(e, err) } - if _, err := bot.api.CloseWithContext(ctx); err != nil { - bot.logger.Errorln(err) - e = append(e, err) - } - if err := bot.api.CloseApi(); err != nil { + if err := bot.api.Close(); err != nil { bot.logger.Errorln(err) e = append(e, err) } @@ -220,6 +215,17 @@ func (bot *Bot[T]) Close(ctx context.Context) error { return errors.Join(e...) } +// CloseRemote sends Telegram Bot API "close" request for the current bot +// instance using ctx for cancellation and deadlines. +// +// This is separate from Bot.Close(), which only releases local resources. +func (bot *Bot[T]) CloseRemote(ctx context.Context) error { + if _, err := bot.api.CloseRemoteWithContext(ctx); err != nil { + return err + } + return nil +} + // initLoggers configures the main and optional request loggers. // // Uses DEBUG flag to set log level (DEBUG if true, FATAL otherwise). @@ -231,27 +237,25 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { level = slog.DEBUG } - bot.logger = slog.CreateLogger().Level(level).Prefix("BOT") - bot.logger.AddWriter(bot.logger.CreateJsonStdoutWriter()) + bot.logger = utils.CreateLogger("BOT", level) if opts.WriteToFile { path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) - fileWriter, err := bot.logger.CreateTextFileWriter(path) + logger, err := utils.CreateFileLogger("BOT", level, path) if err != nil { bot.logger.Fatal(err) } - bot.logger.AddWriter(fileWriter) + bot.logger = logger } if opts.UseRequestLogger { - bot.RequestLogger = slog.CreateLogger().Level(level).Prefix("REQUESTS") - bot.RequestLogger.AddWriter(bot.RequestLogger.CreateJsonStdoutWriter()) + bot.RequestLogger = utils.CreateLogger("REQUESTS", level) if opts.WriteToFile { path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) - fileWriter, err := bot.RequestLogger.CreateTextFileWriter(path) + logger, err := utils.CreateFileLogger("REQUESTS", level, path) if err != nil { bot.logger.Fatal(err) } - bot.RequestLogger.AddWriter(fileWriter) + bot.RequestLogger = logger } } } @@ -280,6 +284,16 @@ func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } // Returns nil if not set via DatabaseContext(). func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } +// GetLoggerLevel returns the effective log level derived from the bot's debug +// flag. +func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel { + level := slog.FATAL + if bot.debug { + level = slog.DEBUG + } + return level +} + // L10n translates a key in the given language. // Returns empty string if translation not found. func (bot *Bot[T]) L10n(lang, key string) string { @@ -341,13 +355,38 @@ func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { // Debug enables or disables debug logging. func (bot *Bot[T]) Debug(debug bool) *Bot[T] { bot.debug = debug + level := slog.FATAL + if debug { + level = slog.DEBUG + } + + bot.logger.Level(level) + if bot.RequestLogger != nil { + bot.RequestLogger.Level(level) + } + for _, p := range bot.plugins { + if p.logger == nil { + continue + } + p.logger.Level(level) + } return bot } // AddPlugins registers one or more plugins. // Plugins are executed in registration order unless filtered by middleware. +// +// Registration is a commit point for plugin configuration. The Bot stores +// plugin metadata internally, so plugins must be fully configured before they +// are passed here. Post-registration mutation through the original *Plugin is +// not a supported API, even if some changes appear to work due to shared maps. func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { + level := bot.GetLoggerLevel() for _, p := range plugin { + if p.logger == nil { + logger := utils.CreateLogger(p.name, level) + p.SetLogger(logger) + } bot.plugins = append(bot.plugins, *p) bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.name)) } @@ -443,6 +482,11 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { // - Main bot logger // - Request logger (if enabled) // - API and Uploader loggers +// - Already registered plugin loggers +// +// Call this after AddPlugins if plugin loggers should also receive the writer. +// Plugins registered later do not automatically inherit previously added +// database writers; call AddDatabaseLoggerWriter again after adding them. // // Example: // @@ -458,6 +502,11 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { for _, l := range bot.extraLoggers { l.AddWriter(w) } + for _, p := range bot.plugins { + if p.logger != nil { + p.logger.AddWriter(w) + } + } return bot } diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 2931170..c2130aa 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -37,8 +37,8 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { SetHTTPClient(client), ) defer func() { - if err := api.CloseApi(); err != nil { - t.Fatalf("CloseApi returned error: %v", err) + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) } }() diff --git a/handler.go b/handler.go index b07228d..fd1ccd7 100644 --- a/handler.go +++ b/handler.go @@ -97,6 +97,11 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { if !plugin.executeMiddlewares(ctx, bot.dbContext) { return } + + ctx.Logger = plugin.logger + if ctx.Logger == nil { + ctx.Logger = ctx.botLogger + } plugin.executeCmd(cmd, ctx, bot.dbContext) return } @@ -131,6 +136,10 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { if !plugin.executeMiddlewares(ctx, bot.dbContext) { return } + ctx.Logger = plugin.logger + if ctx.Logger == nil { + ctx.Logger = ctx.botLogger + } plugin.executePayload(data.Command, ctx, bot.dbContext) return } diff --git a/msg_context.go b/msg_context.go index 725b59e..3b36870 100644 --- a/msg_context.go +++ b/msg_context.go @@ -19,6 +19,10 @@ type MsgContext struct { Msg *tgapi.Message From *tgapi.User + // 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 string CallbackMsgId int CallbackQueryId string diff --git a/msg_context_test.go b/msg_context_test.go index f6cb124..e1742c7 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -37,8 +37,8 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { SetHTTPClient(client), ) defer func() { - if err := api.CloseApi(); err != nil { - t.Fatalf("CloseApi returned error: %v", err) + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) } }() diff --git a/plugins.go b/plugins.go index 134c900..903718f 100644 --- a/plugins.go +++ b/plugins.go @@ -5,6 +5,7 @@ import ( "regexp" "git.nix13.pw/scuroneko/extypes" + "git.nix13.pw/scuroneko/slog" ) // CommandValueType defines the expected type of a command argument. @@ -151,19 +152,30 @@ func (c *Command[T]) validateArgs(args []string) error { // Plugin represents a collection of commands and payloads (e.g., callback handlers), // with shared middleware and configuration. +// +// A Plugin is intended to be fully configured before it is passed to Bot.AddPlugins. +// After registration, treat the plugin as committed and do not mutate it further. +// Post-registration changes through the original *Plugin are not a supported API. type Plugin[T DbContext] struct { name string // Name of the plugin (e.g., "admin", "user") commands map[string]*Command[T] // Registered commands (triggered by message) payloads map[string]*Command[T] // Registered payloads (triggered by callback data) middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help + logger *slog.Logger + + onClose func() error } // NewPlugin creates a new Plugin with the given name. func NewPlugin[T DbContext](name string) *Plugin[T] { return &Plugin[T]{ - name, make(map[string]*Command[T]), - make(map[string]*Command[T]), extypes.Slice[Middleware[T]]{}, false, + name: name, + commands: make(map[string]*Command[T]), + payloads: make(map[string]*Command[T]), + middlewares: make(extypes.Slice[Middleware[T]], 0), + skipAutoCmd: false, + logger: nil, } } @@ -210,6 +222,51 @@ func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] { return p } +// SetLogger sets the logger used for this plugin's handlers. +// +// Call this before Bot.AddPlugins. If the plugin is already registered, changing +// the original *Plugin does not update the Bot's internal copy. +func (p *Plugin[T]) SetLogger(l *slog.Logger) *Plugin[T] { + p.logger = l + return p +} + +// RemoveLogger clears the custom logger for this plugin. +// +// Call this before Bot.AddPlugins. If the plugin is already registered, changing +// the original *Plugin does not update the Bot's internal copy. +func (p *Plugin[T]) RemoveLogger() *Plugin[T] { + p.logger = nil + return p +} + +// SetOnClose registers a callback invoked from Plugin.Close after the plugin +// logger is closed. +// +// Call this before Bot.AddPlugins. If the plugin is already registered, changing +// the original *Plugin does not update the Bot's internal copy. +func (p *Plugin[T]) SetOnClose(f func() error) *Plugin[T] { + p.onClose = f + return p +} + +// Close releases plugin-owned resources such as its logger and optional +// OnClose callback. +func (p *Plugin[T]) Close() error { + var e []error + if p.logger != nil { + if err := p.logger.Close(); err != nil { + e = append(e, err) + } + } + if p.onClose != nil { + if err := p.onClose(); err != nil { + e = append(e, err) + } + } + return errors.Join(e...) +} + // executeCmd finds and executes a command by its trigger string. // Validates arguments and runs middlewares before executor. // On error, sends an error message to the user via ctx.error(). diff --git a/tgapi/api.go b/tgapi/api.go index 30a16c6..c251218 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -95,10 +95,9 @@ type API struct { } // NewAPI creates a new API client from options. -// Always call CloseApi() when done to release resources. +// Always call Close() when done to release resources. func NewAPI(opts *APIOpts) *API { - l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API") - l.AddWriter(l.CreateJsonStdoutWriter()) + l := utils.CreateLogger("API", utils.GetLoggerLevel()) client := opts.client if client == nil { @@ -120,10 +119,10 @@ func NewAPI(opts *APIOpts) *API { } } -// CloseApi shuts down the internal worker pool and closes the logger. +// Close shuts down the internal worker pool and closes the logger. // Must be called to avoid resource leaks. // See https://core.telegram.org/bots/api -func (api *API) CloseApi() error { +func (api *API) Close() error { api.pool.stop() return api.logger.Close() } diff --git a/tgapi/api_test.go b/tgapi/api_test.go index 2f3db3d..5dc4491 100644 --- a/tgapi/api_test.go +++ b/tgapi/api_test.go @@ -35,8 +35,8 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { SetHTTPClient(client), ) defer func() { - if err := api.CloseApi(); err != nil { - t.Fatalf("CloseApi returned error: %v", err) + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) } }() diff --git a/tgapi/methods.go b/tgapi/methods.go index e4a2f9c..948575d 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -49,18 +49,18 @@ func (api *API) LogOutWithContext(ctx context.Context) (bool, error) { return req.DoWithContext(ctx, api) } -// Close closes the bot instance on the local server. +// CloseRemote closes the bot instance on the local server. // Returns true on success. // See https://core.telegram.org/bots/api#close -func (api *API) Close() (bool, error) { +func (api *API) CloseRemote() (bool, error) { req := NewRequest[bool, EmptyParams]("close", NoParams) return req.Do(api) } -// CloseWithContext is the context-aware variant of Close. +// CloseRemoteWithContext is the context-aware variant of CloseRemote. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#close -func (api *API) CloseWithContext(ctx context.Context) (bool, error) { +func (api *API) CloseRemoteWithContext(ctx context.Context) (bool, error) { req := NewRequest[bool, EmptyParams]("close", NoParams) return req.DoWithContext(ctx, api) } diff --git a/tgapi/methods_test.go b/tgapi/methods_test.go index 649ff50..86f1aba 100644 --- a/tgapi/methods_test.go +++ b/tgapi/methods_test.go @@ -27,8 +27,8 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) { SetHTTPClient(client), ) defer func() { - if err := api.CloseApi(); err != nil { - t.Fatalf("CloseApi returned error: %v", err) + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) } }() @@ -60,8 +60,8 @@ func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) { SetHTTPClient(client), ) defer func() { - if err := api.CloseApi(); err != nil { - t.Fatalf("CloseApi returned error: %v", err) + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) } }() @@ -97,8 +97,8 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) { SetHTTPClient(client), ) defer func() { - if err := api.CloseApi(); err != nil { - t.Fatalf("CloseApi returned error: %v", err) + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) } }() diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 732b894..c244020 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -69,8 +69,7 @@ type Uploader struct { // NewUploader creates a multipart uploader bound to an API client. func NewUploader(api *API) *Uploader { - logger := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("UPLOADER") - logger.AddWriter(logger.CreateJsonStdoutWriter()) + logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel()) return &Uploader{api, logger} } diff --git a/tgapi/uploader_api_test.go b/tgapi/uploader_api_test.go index 7cf55dd..cbbfebc 100644 --- a/tgapi/uploader_api_test.go +++ b/tgapi/uploader_api_test.go @@ -44,8 +44,8 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test SetHTTPClient(client), ) defer func() { - if err := api.CloseApi(); err != nil { - t.Fatalf("CloseApi returned error: %v", err) + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) } }() diff --git a/utils/utils.go b/utils/utils.go index 889cc1f..144f161 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -14,3 +14,29 @@ func GetLoggerLevel() slog.LogLevel { } return level } + +// CreateLogger creates a logger with the shared default policy: +// JSON stdout output, provided prefix, and provided level. +func CreateLogger(prefix string, level slog.LogLevel) *slog.Logger { + logger := slog.CreateLogger().Level(level) + if prefix != "" { + logger.Prefix(prefix) + } + logger.AddWriter(logger.CreateJsonStdoutWriter()) + return logger +} + +// CreateFileLogger creates a logger with the shared default policy and appends +// file output to the provided path. +// +// The returned logger is always non-nil. When file writer creation fails, the +// logger still writes to stdout and the error is returned to the caller. +func CreateFileLogger(prefix string, level slog.LogLevel, filePath string) (*slog.Logger, error) { + logger := CreateLogger(prefix, level) + fileWriter, err := logger.CreateTextFileWriter(filePath) + if err != nil { + return logger, err + } + logger.AddWriter(fileWriter) + return logger, nil +} diff --git a/utils/utils_test.go b/utils/utils_test.go new file mode 100644 index 0000000..4df68fb --- /dev/null +++ b/utils/utils_test.go @@ -0,0 +1,34 @@ +package utils + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.nix13.pw/scuroneko/slog" +) + +func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "main.log") + + logger, err := CreateFileLogger("TEST", slog.DEBUG, logPath) + if err != nil { + t.Fatalf("CreateFileLogger returned error: %v", err) + } + logger.Infoln("hello from file logger") + if err := logger.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("ReadFile returned error: %v", err) + } + if !strings.Contains(string(data), "hello from file logger") { + t.Fatalf("expected log message in file, got %q", string(data)) + } + if !strings.Contains(string(data), "[TEST]") { + t.Fatalf("expected prefix in file, got %q", string(data)) + } +} diff --git a/utils/version.go b/utils/version.go index e261a40..0a0b5d1 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-rc.5" + VersionString = "1.0.0-rc.7" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 5 + VersionBeta = 7 ) -- 2.54.0 From 401173714e3da50745671e91ba846f8056e20c35 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 24 Mar 2026 14:02:51 +0300 Subject: [PATCH 51/99] refactor(logging): use context logger in MsgContext --- handler.go | 5 ++--- msg_context.go | 39 +++++++++++++++++++-------------------- msg_context_test.go | 2 +- utils/version.go | 4 ++-- 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/handler.go b/handler.go index fd1ccd7..fe49644 100644 --- a/handler.go +++ b/handler.go @@ -22,7 +22,6 @@ func (bot *Bot[T]) handle(u *tgapi.Update) { ctx := &MsgContext{ Update: *u, Api: bot.api, - botLogger: bot.logger, errorTemplate: bot.errorTemplate, l10n: bot.l10n, draftProvider: bot.draftProvider, @@ -100,7 +99,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { ctx.Logger = plugin.logger if ctx.Logger == nil { - ctx.Logger = ctx.botLogger + ctx.Logger = bot.logger } plugin.executeCmd(cmd, ctx, bot.dbContext) return @@ -138,7 +137,7 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { } ctx.Logger = plugin.logger if ctx.Logger == nil { - ctx.Logger = ctx.botLogger + ctx.Logger = bot.logger } plugin.executePayload(data.Command, ctx, bot.dbContext) return diff --git a/msg_context.go b/msg_context.go index 3b36870..4dbf52d 100644 --- a/msg_context.go +++ b/msg_context.go @@ -32,7 +32,6 @@ type MsgContext struct { Args []string errorTemplate string - botLogger *slog.Logger l10n *L10n draftProvider *DraftProvider payloadType BotPayloadType @@ -61,7 +60,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard case ctx.InlineMsgId != "": params.InlineMessageID = ctx.InlineMsgId default: - ctx.botLogger.Errorln("Can't edit message: no valid message target") + ctx.Logger.Errorln("Can't edit message: no valid message target") return nil } if keyboard != nil { @@ -69,7 +68,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard } msg, _, err := ctx.Api.EditMessageText(params) if err != nil { - ctx.botLogger.Errorln(err) + ctx.Logger.Errorln(err) return nil } resultMessageID := messageId @@ -99,7 +98,7 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { // 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") + ctx.Logger.Errorln("Can't edit non-callback update message") return nil } return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode) @@ -143,7 +142,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo case ctx.InlineMsgId != "": params.InlineMessageID = ctx.InlineMsgId default: - ctx.botLogger.Errorln("Can't edit caption: no valid message target") + ctx.Logger.Errorln("Can't edit caption: no valid message target") return nil } if kb != nil { @@ -152,7 +151,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo msg, _, err := ctx.Api.EditMessageCaption(params) if err != nil { - ctx.botLogger.Errorln(err) + ctx.Logger.Errorln(err) return nil } resultMessageID := messageId @@ -192,7 +191,7 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo // 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") + ctx.Logger.Errorln("Can't answer message without a message") return nil } params := tgapi.SendMessageP{ @@ -212,7 +211,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t msg, err := ctx.Api.SendMessage(params) if err != nil { - ctx.botLogger.Errorln(err) + ctx.Logger.Errorln(err) return nil } return &AnswerMessage{ @@ -259,7 +258,7 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) * // 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") + ctx.Logger.Errorln("Can't answer message without a message") return nil } params := tgapi.SendPhotoP{ @@ -280,7 +279,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par msg, err := ctx.Api.SendPhoto(params) if err != nil { - ctx.botLogger.Errorln(err) + ctx.Logger.Errorln(err) return nil } return &AnswerMessage{ @@ -327,11 +326,11 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an // 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") + ctx.Logger.Errorln("Can't delete message: message ID zero") return } if ctx.Msg == nil { - ctx.botLogger.Errorln("Can't delete message: no chat message context") + ctx.Logger.Errorln("Can't delete message: no chat message context") return } _, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{ @@ -339,7 +338,7 @@ func (ctx *MsgContext) delete(messageId int) { MessageID: messageId, }) if err != nil { - ctx.botLogger.Errorln(err) + ctx.Logger.Errorln(err) } } @@ -349,7 +348,7 @@ 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") + ctx.Logger.Errorln("Can't delete callback message: no callback message ID") return } ctx.delete(ctx.CallbackMsgId) @@ -366,7 +365,7 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { Text: text, ShowAlert: showAlert, URL: url, }) if err != nil { - ctx.botLogger.Errorln(err) + ctx.Logger.Errorln(err) } } @@ -385,7 +384,7 @@ func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, " // 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") + ctx.Logger.Errorln("Can't send action without chat message context") return } params := tgapi.SendChatActionP{ @@ -396,7 +395,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { } _, err := ctx.Api.SendChatAction(params) if err != nil { - ctx.botLogger.Errorln(err) + ctx.Logger.Errorln(err) } } @@ -412,7 +411,7 @@ func (ctx *MsgContext) error(err error) { } else { ctx.answer(text, nil, tgapi.ParseNone) } - ctx.botLogger.Errorln(err) + ctx.Logger.Errorln(err) } // Error is an alias for error(). @@ -420,14 +419,14 @@ 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") + ctx.Logger.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) + ctx.Logger.Errorln(err) return nil } diff --git a/msg_context_test.go b/msg_context_test.go index e1742c7..5c5c38d 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -48,7 +48,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, }, - botLogger: slog.CreateLogger(), + Logger: slog.CreateLogger(), } answer := ctx.AnswerPhoto("photo-id", "caption") diff --git a/utils/version.go b/utils/version.go index 0a0b5d1..9d77ad5 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-rc.7" + VersionString = "1.0.0-rc.8" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 7 + VersionBeta = 8 ) -- 2.54.0 From 3861746a3ea4e311944fa6bd550151c7a532cdfd Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 25 Mar 2026 12:56:26 +0300 Subject: [PATCH 52/99] logger now initialize before middleware --- handler.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/handler.go b/handler.go index fe49644..b605482 100644 --- a/handler.go +++ b/handler.go @@ -93,14 +93,14 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { if _, exists := plugin.commands[cmd]; exists { ctx.Text = args ctx.Args = strings.Fields(args) // Убирает лишние пробелы - if !plugin.executeMiddlewares(ctx, bot.dbContext) { - return - } ctx.Logger = plugin.logger if ctx.Logger == nil { ctx.Logger = bot.logger } + if !plugin.executeMiddlewares(ctx, bot.dbContext) { + return + } plugin.executeCmd(cmd, ctx, bot.dbContext) return } @@ -132,13 +132,13 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { continue } - if !plugin.executeMiddlewares(ctx, bot.dbContext) { - return - } ctx.Logger = plugin.logger if ctx.Logger == nil { ctx.Logger = bot.logger } + if !plugin.executeMiddlewares(ctx, bot.dbContext) { + return + } plugin.executePayload(data.Command, ctx, bot.dbContext) return } -- 2.54.0 From f0da64c7afdac94294613b3ff3808dc4948c9a4f Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 25 Mar 2026 12:57:11 +0300 Subject: [PATCH 53/99] logger now initialize before middleware --- utils/version.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/version.go b/utils/version.go index 9d77ad5..ac6675f 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-rc.8" + VersionString = "1.0.0-rc.9" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 8 + VersionBeta = 9 ) -- 2.54.0 From eda635e72c1aad0d430418492f8e633c4cb7948f Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 25 Mar 2026 13:17:44 +0300 Subject: [PATCH 54/99] fix: enforce required command arg positions --- cmd_generator.go | 2 +- plugins.go | 8 ++++---- plugins_test.go | 16 ++++++++++++++++ tgapi/bot_methods.go | 2 +- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/cmd_generator.go b/cmd_generator.go index a5b0d40..4658e88 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -10,7 +10,7 @@ import ( ) // CmdRegexp matches command names allowed for Telegram command registration. -var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$") +var CmdRegexp = regexp.MustCompile("^[_a-z0-9]+$") // ErrTooManyCommands is returned when the total number of registered commands // exceeds Telegram's limit of 100 bot commands per bot. diff --git a/plugins.go b/plugins.go index 903718f..793ef23 100644 --- a/plugins.go +++ b/plugins.go @@ -127,10 +127,10 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] { // Returns ErrCmdArgCountMismatch if too few arguments are provided. // Returns ErrCmdArgRegexpMismatch if any argument fails regex validation. func (c *Command[T]) validateArgs(args []string) error { - // Count required args - requiredCount := c.args.Filter(func(a CommandArg) bool { return a.required }).Len() - if len(args) < requiredCount { - return ErrCmdArgCountMismatch + for i := range c.args.Len() { + if i >= len(args) && c.args.Get(i).required { + return ErrCmdArgCountMismatch + } } // Validate each argument against its regex diff --git a/plugins_test.go b/plugins_test.go index a8fd856..7c7507d 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -22,3 +22,19 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial bool match, got %v", err) } } + +func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { + cmd := NewCommand[NoDB]( + func(ctx *MsgContext, db *NoDB) {}, + "mixed", + *NewCommandArg("optional"), + *NewCommandArg("required").SetRequired(), + ) + + if err := cmd.validateArgs([]string{"only-optional"}); !errors.Is(err, ErrCmdArgCountMismatch) { + t.Fatalf("expected ErrCmdArgCountMismatch when required second arg is missing, got %v", err) + } + if err := cmd.validateArgs([]string{"optional", "required"}); err != nil { + t.Fatalf("expected both args to validate, got %v", err) + } +} diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index 5956755..8531613 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -244,7 +244,7 @@ func (api *API) RemoveMyProfilePhotoWithContext(ctx context.Context) (bool, erro // SetChatMenuButtonP holds parameters for the setChatMenuButton method. // See https://core.telegram.org/bots/api#setchatmenubutton type SetChatMenuButtonP struct { - ChatID int64 `json:"chat_id"` + ChatID int64 `json:"chat_id,omitempty"` MenuButton MenuButtonType `json:"menu_button"` } -- 2.54.0 From 7901fb659eb62e9e4c1c364b99645196270b55ba Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 25 Mar 2026 13:41:30 +0300 Subject: [PATCH 55/99] fix bot safety and tgapi edge cases --- AGENTS.md | 102 +++++++++++++++++++++++++++++++++++++ bot.go | 60 +++++++++++++++++----- bot_test.go | 70 +++++++++++++++++++++++++ drafts.go | 15 +++--- drafts_test.go | 36 +++++++++++++ msg_context.go | 20 ++++++-- tgapi/methods_types.go | 4 +- tgapi/parse_mode_test.go | 37 ++++++++++++++ tgapi/uploader_api.go | 3 +- tgapi/uploader_api_test.go | 21 ++++++++ utils/limiter_test.go | 41 +++++++++++++++ 11 files changed, 380 insertions(+), 29 deletions(-) create mode 100644 AGENTS.md create mode 100644 bot_test.go create mode 100644 drafts_test.go create mode 100644 tgapi/parse_mode_test.go create mode 100644 utils/limiter_test.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6dc01d2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,102 @@ +# AGENTS.md + +## Purpose +This repository uses Codex for full-project Go code review, not diff-only review. + +When asked to review code, inspect the entire repository and use repository-wide context. Do not limit analysis to the latest commit, pull request diff, or recently changed files. + +## Review priorities +Review the codebase with focus on: +- correctness and reliability; +- maintainability and architecture; +- idiomatic Go; +- testability; +- performance where justified by code evidence; +- security; +- godoc quality. + +## Scope rules +- Always review the whole repository unless the prompt explicitly narrows scope. +- Check cross-package interactions, public APIs, package boundaries, and shared patterns. +- Prefer concrete fixes over generic advice. +- When feasible, make small, high-confidence improvements directly. +- When uncertain, state confidence level and evidence. + +## Go review expectations +Check for: +- bugs, fragile logic, invalid assumptions, nil handling issues, resource leaks; +- poor error handling; +- misuse of context, cancellation, timeouts, retries, and cleanup; +- race risks, deadlocks, blocking hazards, unsafe shared state; +- non-idiomatic naming, APIs, interfaces, package structure, and error patterns; +- unnecessary complexity, duplication, or weak abstractions; +- obvious performance problems supported by the code; +- security risks such as unsafe input handling, secret leakage, insecure logging, injection risks, and risky file or network operations. + +## Godoc rules +Review comments for all declarations. + +### Exported declarations +Exported types, funcs, methods, vars, and consts must have godoc comments. + +Each exported godoc comment must: +- start with the identifier name; +- explain the purpose or behavior; +- be as short as possible without losing important meaning; +- avoid repeating the signature mechanically; +- stay high-signal and informative. + +### Unexported declarations +Unexported types, funcs, methods, vars, and consts should generally not have godoc-style comments unless there is a strong reason. + +### Always report +- missing godoc on exported declarations; +- unnecessary godoc on unexported declarations; +- comments that are too long, vague, redundant, or low-value; +- comments that should be shortened or rewritten. + +When feasible, rewrite bad godoc into better versions. + +## Testing expectations +Treat tests as a required part of review. + +- Assess existing test quality, not only test presence. +- Add or propose as many useful tests as reasonably possible. +- Prioritize public APIs, critical flows, edge cases, negative paths, boundary conditions, and concurrency-sensitive logic. +- Prefer table-driven tests where appropriate. +- Add regression tests for bugs you find. +- If a case is hard to test directly, explain the gap and the best test strategy. + +## Commands +Before finalizing changes, run the relevant project checks when available: +- build +- tests +- lint +- static analysis + +Prefer the repository’s documented commands. If multiple choices exist, use the most standard and least destructive ones first. + +## Output format +For repo-wide review tasks, structure the result as: + +1. Overall summary +2. Critical findings +3. Major findings +4. Minor findings +5. Godoc issues +6. Test gaps and added/proposed tests +7. Good decisions worth keeping +8. Summary of concrete changes made + +For each finding include: +- location; +- issue; +- why it matters; +- recommended fix. + +## Working style +- Be direct, specific, and action-oriented. +- Do not stop at style-only feedback. +- Use full repository context before drawing conclusions. +- Prefer minimal, high-confidence patches. +- Preserve behavior unless intentionally fixing a bug. diff --git a/bot.go b/bot.go index f36bb71..40dccae 100644 --- a/bot.go +++ b/bot.go @@ -242,9 +242,10 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) logger, err := utils.CreateFileLogger("BOT", level, path) if err != nil { - bot.logger.Fatal(err) + bot.logger.Errorln(err) + } else { + bot.logger = logger } - bot.logger = logger } if opts.UseRequestLogger { @@ -253,9 +254,10 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) logger, err := utils.CreateFileLogger("REQUESTS", level, path) if err != nil { - bot.logger.Fatal(err) + bot.logger.Errorln(err) + } else { + bot.RequestLogger = logger } - bot.RequestLogger = logger } } } @@ -275,7 +277,9 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) { } // GetUpdateTypes returns the list of update types the bot is configured to receive. -func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return bot.updateTypes } +func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { + return append([]tgapi.UpdateType(nil), bot.updateTypes...) +} // GetLogger returns the main bot logger. func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } @@ -383,12 +387,12 @@ func (bot *Bot[T]) Debug(debug bool) *Bot[T] { func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { level := bot.GetLoggerLevel() for _, p := range plugin { - if p.logger == nil { - logger := utils.CreateLogger(p.name, level) - p.SetLogger(logger) + cloned := clonePlugin(p) + if cloned.logger == nil { + cloned.logger = utils.CreateLogger(cloned.name, level) } - bot.plugins = append(bot.plugins, *p) - bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.name)) + bot.plugins = append(bot.plugins, cloned) + bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) } return bot } @@ -535,12 +539,12 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { // _ = bot.Close(context.Background()) func (bot *Bot[T]) RunWithContext(ctx context.Context) { if len(bot.prefixes) == 0 { - bot.logger.Fatalln("no prefixes defined") + bot.logger.Errorln("no prefixes defined") return } if len(bot.plugins) == 0 { - bot.logger.Fatalln("no plugins defined") + bot.logger.Errorln("no plugins defined") return } @@ -604,3 +608,35 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { func (bot *Bot[T]) Run() { bot.RunWithContext(context.Background()) } + +func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { + cloned := Plugin[T]{ + name: p.name, + commands: make(map[string]*Command[T], len(p.commands)), + payloads: make(map[string]*Command[T], len(p.payloads)), + middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), + skipAutoCmd: p.skipAutoCmd, + logger: p.logger, + onClose: p.onClose, + } + + for name, command := range p.commands { + cloned.commands[name] = cloneCommand(command) + } + for name, command := range p.payloads { + cloned.payloads[name] = cloneCommand(command) + } + + return cloned +} + +func cloneCommand[T DbContext](command *Command[T]) *Command[T] { + if command == nil { + return nil + } + + cloned := *command + cloned.args = append(extypes.Slice[CommandArg](nil), command.args...) + cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) + return &cloned +} diff --git a/bot_test.go b/bot_test.go new file mode 100644 index 0000000..0d4d671 --- /dev/null +++ b/bot_test.go @@ -0,0 +1,70 @@ +package laniakea + +import ( + "path/filepath" + "reflect" + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/slog" +) + +func TestGetUpdateTypesReturnsCopy(t *testing.T) { + bot := &Bot[NoDB]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}} + + got := bot.GetUpdateTypes() + got[0] = tgapi.UpdateTypeCallbackQuery + + if want := []tgapi.UpdateType{tgapi.UpdateTypeMessage}; !reflect.DeepEqual(bot.updateTypes, want) { + t.Fatalf("GetUpdateTypes exposed internal slice: got %v want %v", bot.updateTypes, want) + } +} + +func TestAddPluginsSnapshotsConfiguration(t *testing.T) { + bot := &Bot[NoDB]{logger: slog.CreateLogger()} + plugin := NewPlugin[NoDB]("demo") + + cmd := plugin.NewCommand(func(ctx *MsgContext, db *NoDB) {}, "start") + plugin.AddMiddleware(*NewMiddleware("base", func(ctx *MsgContext, db *NoDB) bool { return true })) + + bot.AddPlugins(plugin) + + cmd.SetDescription("mutated after registration") + plugin.NewCommand(func(ctx *MsgContext, db *NoDB) {}, "late") + plugin.AddMiddleware(*NewMiddleware("late", func(ctx *MsgContext, db *NoDB) bool { return true })) + + registered := bot.plugins[0] + if _, exists := registered.commands["late"]; exists { + t.Fatal("late command leaked into registered plugin snapshot") + } + if registered.commands["start"].description != "" { + t.Fatalf("registered command description unexpectedly mutated: %q", registered.commands["start"].description) + } + if len(registered.middlewares) != 1 { + t.Fatalf("registered middlewares unexpectedly mutated: got %d want 1", len(registered.middlewares)) + } +} + +func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { + bot := &Bot[NoDB]{} + + bot.initLoggers(&BotOpts{ + Debug: true, + WriteToFile: true, + UseRequestLogger: true, + LoggerBasePath: filepath.Join(t.TempDir(), "missing", "nested"), + }) + + if bot.logger == nil { + t.Fatal("expected main logger fallback") + } + if bot.RequestLogger == nil { + t.Fatal("expected request logger fallback") + } + if err := bot.RequestLogger.Close(); err != nil { + t.Fatalf("failed to close request logger: %v", err) + } + if err := bot.logger.Close(); err != nil { + t.Fatalf("failed to close main logger: %v", err) + } +} diff --git a/drafts.go b/drafts.go index ff49b59..168adca 100644 --- a/drafts.go +++ b/drafts.go @@ -38,12 +38,9 @@ func (g *LinearDraftIdGenerator) Next() uint64 { return g.lastId.Add(1) } -// DraftProvider manages a collection of Drafts and provides methods to create and -// configure them. It holds shared configuration (chat, parse mode, entities) and -// a draft ID generator. +// DraftProvider manages a collection of Drafts and a shared draft ID generator. // -// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines -// requires external synchronization. +// DraftProvider is safe for concurrent use. type DraftProvider struct { mu sync.RWMutex api *tgapi.API @@ -133,10 +130,7 @@ type Draft struct { // NewDraft creates a new draft with the provided parse mode. // -// The draft inherits the provider's chatID, messageThreadID, and entities. -// If parseMode is zero, the provider's default parseMode is used. -// -// Panics if chatID is zero — call SetChat() on the provider first. +// The caller must set a chat with SetChat before Push or Flush. func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { id := p.generator.Next() draft := &Draft{ @@ -224,6 +218,9 @@ func (d *Draft) Flush() error { if d.Message == "" { return nil } + if d.chatID == 0 { + return ErrDraftChatIDZero + } params := tgapi.SendMessageP{ ChatID: d.chatID, diff --git a/drafts_test.go b/drafts_test.go new file mode 100644 index 0000000..5e10497 --- /dev/null +++ b/drafts_test.go @@ -0,0 +1,36 @@ +package laniakea + +import ( + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/slog" +) + +func TestDraftFlushRequiresChatID(t *testing.T) { + draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone) + draft.Message = "hello" + + if err := draft.Flush(); err != ErrDraftChatIDZero { + t.Fatalf("expected ErrDraftChatIDZero, got %v", err) + } +} + +func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { + ctx := &MsgContext{ + Api: &tgapi.API{}, + Msg: &tgapi.Message{ + Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, + }, + Logger: slog.CreateLogger(), + draftProvider: NewRandomDraftProvider(&tgapi.API{}), + } + + draft := ctx.NewDraft() + if draft == nil { + t.Fatal("expected draft") + } + if draft.chatID != 42 { + t.Fatalf("unexpected chat id: %d", draft.chatID) + } +} diff --git a/msg_context.go b/msg_context.go index 4dbf52d..43e640c 100644 --- a/msg_context.go +++ b/msg_context.go @@ -422,13 +422,23 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { ctx.Logger.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.Logger.Errorln(err) + if ctx.Api == nil { + ctx.Logger.Errorln("can't create draft: ctx.Api is nil") return nil } + if ctx.draftProvider == nil { + ctx.Logger.Errorln("can't create draft: ctx.draftProvider is nil") + return nil + } + + if ctx.Api.Limiter != 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.Logger.Errorln(err) + return nil + } + } draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID) return draft diff --git a/tgapi/methods_types.go b/tgapi/methods_types.go index 7e8a103..ea4294a 100644 --- a/tgapi/methods_types.go +++ b/tgapi/methods_types.go @@ -10,8 +10,8 @@ const ( ParseHTML ParseMode = "HTML" // ParseMD enables legacy Markdown style parsing. ParseMD ParseMode = "Markdown" - // ParseNone disables any parsing. - ParseNone ParseMode = "None" + // ParseNone disables parse_mode and leaves plain-text requests unannotated. + ParseNone ParseMode = "" ) // EmptyParams is a placeholder for methods that take no parameters. diff --git a/tgapi/parse_mode_test.go b/tgapi/parse_mode_test.go new file mode 100644 index 0000000..c2ec740 --- /dev/null +++ b/tgapi/parse_mode_test.go @@ -0,0 +1,37 @@ +package tgapi + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestParseNoneOmitsParseModeInJSON(t *testing.T) { + data, err := json.Marshal(SendMessageP{ + ChatID: 42, + Text: "hello", + ParseMode: ParseNone, + }) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + + if strings.Contains(string(data), `"parse_mode"`) { + t.Fatalf("expected parse_mode to be omitted, got %s", string(data)) + } +} + +func TestParseModeStillSerializesExplicitModes(t *testing.T) { + data, err := json.Marshal(SendMessageP{ + ChatID: 42, + Text: "hello", + ParseMode: ParseMDV2, + }) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + + if !strings.Contains(string(data), `"parse_mode":"MarkdownV2"`) { + t.Fatalf("expected MarkdownV2 parse_mode, got %s", string(data)) + } +} diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index c244020..7e87723 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -7,6 +7,7 @@ import ( "mime/multipart" "net/http" "path/filepath" + "strings" "time" "git.nix13.pw/scuroneko/laniakea/utils" @@ -241,7 +242,7 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str // uploaderTypeByExt infers the Telegram upload field name from a file extension. // Falls back to UploaderDocumentType for unrecognized extensions. func uploaderTypeByExt(filename string) UploaderFileType { - ext := filepath.Ext(filename) + ext := strings.ToLower(filepath.Ext(filename)) switch ext { case ".jpg", ".jpeg", ".png", ".webp", ".bmp": return UploaderPhotoType diff --git a/tgapi/uploader_api_test.go b/tgapi/uploader_api_test.go index cbbfebc..54f0d3c 100644 --- a/tgapi/uploader_api_test.go +++ b/tgapi/uploader_api_test.go @@ -104,6 +104,27 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test } } +func TestNewUploaderFileDetectsFileTypeCaseInsensitively(t *testing.T) { + tests := []struct { + name string + filename string + want UploaderFileType + }{ + {name: "uppercase photo", filename: "PHOTO.JPG", want: UploaderPhotoType}, + {name: "uppercase voice", filename: "voice.OGG", want: UploaderVoiceType}, + {name: "unknown defaults to document", filename: "archive.BIN", want: UploaderDocumentType}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file := NewUploaderFile(tt.filename, []byte("x")) + if file.field != tt.want { + t.Fatalf("unexpected uploader field: got %q want %q", file.field, tt.want) + } + }) + } +} + func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) { _, params, err := mime.ParseMediaType(req.Header.Get("Content-Type")) if err != nil { diff --git a/utils/limiter_test.go b/utils/limiter_test.go new file mode 100644 index 0000000..ab69db9 --- /dev/null +++ b/utils/limiter_test.go @@ -0,0 +1,41 @@ +package utils + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestRateLimiterCheckDropOverflowHonorsGlobalLock(t *testing.T) { + rl := NewRateLimiter() + rl.SetGlobalLock(1) + + if err := rl.Check(context.Background(), true, 0); !errors.Is(err, ErrDropOverflow) { + t.Fatalf("expected ErrDropOverflow, got %v", err) + } +} + +func TestRateLimiterChatLocksAreScopedPerChat(t *testing.T) { + rl := NewRateLimiter() + rl.SetChatLock(42, 1) + + if rl.Allow(42) { + t.Fatal("expected locked chat to be rejected") + } + if !rl.Allow(7) { + t.Fatal("expected unrelated chat to remain allowed") + } +} + +func TestRateLimiterGlobalWaitRespectsContextCancellation(t *testing.T) { + rl := NewRateLimiter() + rl.SetGlobalLock(1) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + if err := rl.GlobalWait(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got %v", err) + } +} -- 2.54.0 From 158625c220b7164529465f0b9a758fab7a27d2e7 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 25 Mar 2026 18:02:04 +0300 Subject: [PATCH 56/99] fix bot lifecycle and docs --- .gitignore | 2 +- CHANGELOG.md | 146 ++++++++++++++++++++++ README.md | 47 ++++++-- README_RU.md | 66 +++++++--- SEMVER.md | 45 +++++++ bot.go | 201 +++++++++++++++++++++++++------ bot_opts.go | 26 ++-- bot_test.go | 139 ++++++++++++++++++++- cmd_generator.go | 22 ++-- cmd_generator_test.go | 23 +++- doc.go | 58 +++------ drafts.go | 4 +- handler.go | 178 +++++++++++++++++++++++---- handler_test.go | 226 ++++++++++++++++++++++++++++++++++- keyboard.go | 29 ++--- keyboard_test.go | 44 +++++++ l10n.go | 71 +++++------ l10n_test.go | 77 ++++++++++++ msg_context.go | 24 ++-- plugins.go | 76 +++++++----- plugins_test.go | 10 +- runners.go | 10 +- runners_test.go | 62 ++++++++++ tgapi/api.go | 31 ++--- tgapi/api_test.go | 34 ++++++ tgapi/attachments_methods.go | 4 +- tgapi/attachments_types.go | 12 +- tgapi/bot_methods.go | 10 +- tgapi/bot_types.go | 19 +-- tgapi/business_types.go | 15 ++- tgapi/chat_types.go | 26 ++-- tgapi/errors.go | 7 ++ tgapi/messages_types.go | 124 ++++++++++++------- tgapi/methods.go | 35 +++++- tgapi/methods_test.go | 38 ++++++ tgapi/pool.go | 84 ++++--------- tgapi/pool_test.go | 62 ++++++++++ tgapi/types.go | 123 ++++++++++++------- tgapi/types_test.go | 91 +++++++++----- tgapi/uploader_api.go | 19 +-- utils.go | 14 ++- utils/limiter.go | 12 +- utils/multipart.go | 3 +- utils/version.go | 15 ++- 44 files changed, 1831 insertions(+), 533 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 SEMVER.md create mode 100644 keyboard_test.go create mode 100644 l10n_test.go create mode 100644 runners_test.go create mode 100644 tgapi/pool_test.go diff --git a/.gitignore b/.gitignore index 4d72fe9..f26b8e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ .idea/ -test/ \ No newline at end of file +test/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e1e409a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,146 @@ +# Changelog + +## Unreleased + +### Added +- `Plugin.AddUpdateHandler` for routing non-command Telegram updates by `tgapi.UpdateType`. +- Derived `tgapi.Update.Type` assignment during JSON decoding, plus `tgapi.UpdateTypeUnknown` for unmatched payloads. +- `tgapi.API.OpenFileByLink(...)` and `OpenFileByLinkWithContext(...)` for streaming downloads from Telegram's file server. +- Regression tests for update dispatch, keyboard builders, localization fallback, runners, rate limiting, parse mode encoding, streaming downloads, and context isolation. +- Regression tests for bot single-run enforcement, nil plugin registration, `L10n` concurrent access, `API.Close()` idle-connection cleanup, and `tgapi` worker-pool edge cases. +- `SEMVER.md` documenting versioning expectations for the project. + +### Changed +- `NewBot` now returns `(*Bot[T], error)` instead of terminating the host process on configuration or startup failures. +- `Run` and `RunWithContext` now return errors; `RunWithContext` returns `ErrNoPrefixes` and `ErrNoPlugins` for invalid bot configuration. +- Polling retries now use exponential backoff instead of busy-looping on repeated `getUpdates` failures. +- `Bot` is now explicitly single-use; repeated `Run()` or `RunWithContext(...)` calls return `ErrBotAlreadyRun`. +- Database context wiring now uses `T` consistently instead of forcing `*T`; shared dependencies should typically use pointer types such as `*sql.DB`. +- `DatabaseContext`, `GetDBContext`, and `DbLogger` were updated to the new `T`-based dependency model. +- `DatabaseContext(...)` now warns once when `T` is a value type, to highlight likely unintended copying of shared dependencies. +- `AddDatabaseLoggerWriter(...)` now skips unset and nil database contexts instead of calling the writer with invalid values. +- `L10n` is now safe for concurrent use and copies added dictionary entries to avoid external mutation after registration. +- Plugin registration now snapshots commands, payloads, middlewares, and update handlers so later mutations of the original `*Plugin` do not leak into the bot. +- `AddPlugins(...)` now skips nil plugin pointers instead of panicking. +- `GetUpdateTypes()` now returns a copy instead of exposing internal slice state. +- Update handling now normalizes `MsgContext` for more Telegram update kinds and routes plugin-level update handlers with isolated context copies. +- `message`, `channel_post`, and `callback_query` remain on the command/payload flow; non-command updates can be handled through plugin update handlers. +- Command auto-generation now validates Telegram command names with the correct character set and `1..32` length limit, and emits commands in deterministic sorted order. +- Builder-style APIs were normalized to value returns for `NewCommandArg`, `NewMiddleware`, `NewRunner`, and `NewCallbackData`. +- `MenuButton` replaced `BaseMenuButton`, and `GetChatMenuButton(...)` now returns the renamed type. +- Several Telegram DTOs were tightened for optionality and serialization correctness, including `InputPaidMedia`, `MenuButton`, optional gift fields, and message entity slices. +- `tgapi.NewRequest(...)`, `NewRequestWithChatID(...)`, `NewUploaderRequest(...)`, and `NewUploaderRequestWithChatID(...)` are now documented as low-level unsafe escape hatches rather than internal helpers. +- `tgapi.API.Close()` now closes idle HTTP connections before releasing logger resources. +- Multipart form encoding now writes scalar field bytes directly instead of converting through temporary strings. +- README, README_RU, package docs, and exported godoc were updated to match the current APIs and concurrency/lifecycle model. +- Version constants were bumped to `v1.0.0-rc.10`. + +### Fixed +- Required command arguments are now enforced by declared argument index, not only by total required count. +- `ParseNone` now omits `parse_mode` from JSON requests instead of serializing `"None"`. +- Upload file type detection is now case-insensitive for file extensions. +- Draft creation no longer panics when no limiter is configured, and draft flushing now rejects zero chat IDs before sending invalid requests. +- Channel posts with `SenderChat` no longer panic in the command path and now preserve the expected `MsgContext` fields. +- File logger initialization now falls back to stdout loggers instead of terminating the process on logger setup failures. +- `GetChatMenuButton` and `SetChatMenuButton` now serialize `chat_id` correctly when omitted. +- Update decoding tests now match the canonical `deleted_business_messages` model and no longer rely on the removed singular alias. + +### Breaking Changes +- `NewBot[T](opts)` now returns `(*Bot[T], error)`. +- `Run()` now returns `error`. +- `RunWithContext(ctx)` now returns `error`. +- `Run()` and `RunWithContext(ctx)` are now single-use per bot instance; create a new `Bot` after they return. +- Database context handlers now receive `T` instead of `*T`. For shared dependencies, instantiate the bot with a pointer type, for example `Bot[*sql.DB]`. +- `DatabaseContext(...)` now takes `T` instead of `*T`. +- `GetDBContext()` now returns `T` instead of `*T`. +- `DbLogger[T]` now receives `T` instead of `*T`. +- `NewCommandArg(...)`, `NewMiddleware(...)`, `NewRunner(...)`, and `NewCallbackData(...)` now return values instead of pointers. +- `BaseMenuButton` was renamed to `MenuButton`, and `GetChatMenuButton(...)` now returns `MenuButton`. +- `tgapi.Update` no longer exposes the deprecated `DeletedBusinessMessage` alias; use `DeletedBusinessMessages`. + +### Tests +- Added coverage for polling backoff helpers, command sorting, database logger safety checks, update handler routing, update-context isolation, channel posts with `SenderChat`, parse mode encoding, streaming downloads, and rate limiter behavior. + +## v1.0.0-rc.7 + +### Added +- Package-level logger helpers: `utils.CreateLogger(prefix, level)` and `utils.CreateFileLogger(prefix, level, filePath)`. +- `MsgContext.Logger`, populated from the matched plugin and falling back to the bot logger. +- Plugin lifecycle/configuration APIs: `SetLogger`, `RemoveLogger`, `SetOnClose`, and `Close`. +- `Bot.CloseRemote(ctx)` as the explicit wrapper for Telegram Bot API close. + +### Changed +- Logger initialization is now unified across `Bot`, `tgapi.API`, and `tgapi.Uploader`. +- `Bot.Close()` now performs local resource teardown only and invokes `Plugin.Close()` for registered plugins. +- Local `tgapi.API` shutdown was renamed to `Close()`. +- Telegram Bot API close wrappers in `tgapi.API` were renamed to `CloseRemote()` and `CloseRemoteWithContext()`. +- `Bot.Debug()` now updates log levels for the bot logger, request logger, and already registered plugin loggers. +- `Bot.AddPlugins()` now creates a default plugin logger automatically when one is not provided. +- `Bot.AddDatabaseLoggerWriter()` now also attaches the writer to already registered plugin loggers. +- GoDoc was expanded for the new shutdown and logging APIs, and plugin registration is now documented as a configuration commit point. + +### Breaking Changes +- `(*Bot).Close(ctx context.Context)` was replaced with `(*Bot).Close()`. +- `(*tgapi.API).CloseApi()` was renamed to `(*tgapi.API).Close()`. +- `(*tgapi.API).Close()` was renamed to `(*tgapi.API).CloseRemote()`. +- `(*tgapi.API).CloseWithContext()` was renamed to `(*tgapi.API).CloseRemoteWithContext(ctx)`. + +### Migration +- Replace `bot.Close(ctx)` with `bot.Close()`. +- If you need Telegram Bot API close, use `bot.CloseRemote(ctx)`. +- Replace `api.CloseApi()` with `api.Close()`. +- Replace `api.Close()` with `api.CloseRemote()`. +- Replace `api.CloseWithContext(ctx)` with `api.CloseRemoteWithContext(ctx)`. +- Configure plugin loggers and `OnClose` hooks before calling `bot.AddPlugins(...)`. + +### Tests +- Updated tests for the new shutdown and logging behavior. + +### Notes +- Registering a plugin via `AddPlugins(...)` is a configuration commit point; the plugin should not be mutated through the original `*Plugin` afterward. +- If plugin loggers must receive a database writer, call `AddDatabaseLoggerWriter(...)` after registering plugins. + +## v1.0.0-rc.4 + +### Added +- `WithContext` variants across `tgapi` API and uploader methods so callers can pass cancellation and deadline contexts consistently. +- `UploaderCertificateType`, `UploadSetWebhookP`, `Uploader.SetWebhook(...)`, and `Uploader.SetWebhookWithContext(...)` for multipart webhook certificate uploads. +- Missing media thumbnail fields where applicable. + +### Changed +- GoDoc for context-aware methods was improved, and `See` references now point to method-specific Telegram Bot API anchors. +- `EditMessageTextP` now includes `entities` and `link_preview_options`. +- `EditMessageCaptionP` now includes `caption_entities` and `show_caption_above_media`. +- `StopPollP` now uses `reply_markup` and no longer carries `inline_message_id`. +- `SendStickerP` now includes reply and suggested-post related fields. +- `SendDocumentP` now includes `disable_content_type_detection`. +- `SendInvoiceP` no longer includes unsupported `business_connection_id`. +- `SetWebhookP` no longer carries `certificate`; GoDoc now points to uploader-based certificate upload. +- Existing non-context methods remain available, and the `Do(...)` call style is preserved. + +### Breaking Changes +- Users sending webhook certificates through JSON `SetWebhookP.Certificate` must migrate to `Uploader.SetWebhook(...)`. + +## v1.0.0-rc.3 + +### Fixed +- The update polling loop no longer logs or retries after `context.Canceled` during shutdown. +- Extra retry delay was removed from canceled polling requests so `RunWithContext` can exit immediately while stopping. + +### Changed +- Shutdown behavior remains explicit: callers are still responsible for invoking `Close()` after `RunWithContext` returns. + +## v1.0.0-rc.2 + +### Fixed +- Fixed a shutdown crash caused by `DatabaseWriter` calling `Close()` through an uninitialized embedded logger writer. +- Fixed bot shutdown hanging during Telegram long polling by making update polling use a cancelable context. +- Reduced the chance of container termination with exit code `137` during shutdown by allowing `getUpdates` to stop promptly on cancellation. + +### Changed +- Switched the project to use the local `laniakea` replacement for the shutdown fix. +- Documentation now clarifies that `RunWithContext` does not close resources automatically and callers must invoke `Close()` explicitly. +- `Updates` documentation now describes context-driven cancellation behavior. + +### Tests +- Added regression tests for database logger writer shutdown behavior. diff --git a/README.md b/README.md index 8d594fd..7fcec12 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ import ( // It receives two parameters: // - ctx: the message context (contains info about the message, sender, chat, etc.) // - db: your custom database context (here we use NoDB, a placeholder for no database) -func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { +func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) { // Answer the user with the text they sent, without any command prefix. // ctx.Text contains the user's message with the command part stripped off. ctx.Answer(ctx.Text) // User input WITHOUT command @@ -64,7 +64,10 @@ func main() { // 2. Initialize a new bot instance. // We use laniakea.NoDB as the database context type (no database needed for this example). - bot := laniakea.NewBot[laniakea.NoDB](opts) + bot, err := laniakea.NewBot[laniakea.NoDB](opts) + if err != nil { + log.Fatal(err) + } // Ensure bot resources are cleaned up on exit. defer bot.Close() @@ -78,7 +81,7 @@ func main() { // 5. Add another command using an anonymous function (closure). // This command simply replies "Pong" when the user sends "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) { ctx.Answer("Pong") }, "ping")) @@ -94,7 +97,9 @@ func main() { } // 8. Start the bot, listening for updates (long polling). - bot.Run() + if err := bot.Run(); err != nil { + log.Fatal(err) + } } ``` @@ -105,15 +110,16 @@ func main() { 4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash). 5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T. 6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. -7. `AutoGenerateCommands`: Adds built-in commands (/start, /help) and a command that lists all available commands. -8. `Run()`: Starts the bot's update polling loop. +7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. +8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails. +9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session. ## 📖 Core Concepts ### Plugins Plugins are the main way to organize code. A plugin can have multiple commands and middlewares. ```go -plugin := laniakea.NewPlugin[MyDB]("admin") +plugin := laniakea.NewPlugin[*MyDB]("admin") plugin.AddCommand(plugin.NewCommand(banUser, "ban")) bot.AddPlugins(plugin) ``` @@ -153,14 +159,20 @@ Provides access to the incoming message and useful reply methods: This split keeps method intent explicit: JSON-only calls go through `API`, file uploads go through `Uploader`. +For advanced cases, `tgapi.NewRequest(...)` and `tgapi.NewUploaderRequest(...)` remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves. + ### Database Context -The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool), and it will be available in every command and middleware handler. +The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools should usually use a pointer type. ```go type MyDB struct { /* ... */ } db := &MyDB{...} -bot := laniakea.NewBot[*MyDB](opts, db) // Pass db instance +bot, err := laniakea.NewBot[*MyDB](opts) +if err != nil { + log.Fatal(err) +} +bot.DatabaseContext(db) ``` ## 🧩 Middleware @@ -177,11 +189,12 @@ func(ctx *MsgContext, db T) bool - If it returns false, the execution chain stops immediately (the command will not run). ### Adding Middleware -Use the Use method of a plugin to add one or more middleware functions. They are executed in the order they are added. +Use `AddMiddleware` on a plugin to add one or more shared middleware functions. They are executed in the order they are added. ```go -plugin := laniakea.NewPlugin[MyDB]("admin") -plugin.Use(loggingMiddleware, adminOnlyMiddleware) +plugin := laniakea.NewPlugin[*MyDB]("admin") +plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) +plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) plugin.AddCommand(plugin.NewCommand(banUser, "ban")) ``` @@ -212,7 +225,15 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { ## ⚙️ Advanced Configuration - **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. -- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control. +- **Localization**: `L10n` is safe for concurrent use once attached to the bot. +- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow. +- **Lifecycle**: `RunWithContext(...)` does not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run. + +## Telegram Update Handling +- Commands and payloads are handled through plugins. +- Non-command updates can be routed with `plugin.AddUpdateHandler(updateType, handler)`. +- `message`, `channel_post`, and `callback_query` stay on the command/payload flow. +- `tgapi.Update` exposes a derived `Type` field after JSON unmarshalling so handlers can inspect the effective update kind directly. ## 📝 License diff --git a/README_RU.md b/README_RU.md index abc0048..b7d3797 100644 --- a/README_RU.md +++ b/README_RU.md @@ -53,7 +53,7 @@ import ( // Она получает два параметра: // - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) // - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку) -func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { +func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) { // Отвечаем пользователю текстом, который он прислал, без префикса команды. // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды @@ -65,7 +65,10 @@ func main() { // 2. Инициализируем новый экземпляр бота. // Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера). - bot := laniakea.NewBot[laniakea.NoDB](opts) + bot, err := laniakea.NewBot[laniakea.NoDB](opts) + if err != nil { + log.Fatal(err) + } // Гарантируем освобождение ресурсов бота при выходе. defer bot.Close() @@ -79,7 +82,7 @@ func main() { // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) { ctx.Answer("Pong") }, "ping")) @@ -95,7 +98,9 @@ func main() { } // 8. Запускаем бота, начиная прослушивание обновлений (long polling). - bot.Run() + if err := bot.Run(); err != nil { + log.Fatal(err) + } } ``` @@ -106,15 +111,16 @@ func main() { 4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша). 5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T. 6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. -7. `AutoGenerateCommands`: Добавляет встроенные команды (/start, /help) и команду, показывающую список всех доступных команд. -8. `Run()`: Запускает цикл опроса обновлений бота. +7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. +8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. +9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот. ## 📖 Основные концепции ### Плагины (Plugins) Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware. ```go -plugin := laniakea.NewPlugin[MyDB]("admin") +plugin := laniakea.NewPlugin[*MyDB]("admin") plugin.AddCommand(plugin.NewCommand(banUser, "ban")) bot.AddPlugins(plugin) ``` @@ -138,21 +144,34 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) { - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. - `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). -- `EditCallback(text string)`: Редактирует сообщение, форматируя его в MarkdownV2 (экранирование на вашей стороне), после нажатия Inline кнопки. -- `EditCallbackMarkdown(text string)`: Редактирует сообщение с parse_mode none после нажатия Inline кнопки. -- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д. -- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие. +- `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки. +- `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки. +- `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д. +- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие. - И много других методов и полей! ### Контекст базы данных (Database Context) -Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件. +Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type. ```go type MyDB struct { /* ... */ } db := &MyDB{...} -bot := laniakea.NewBot[*MyDB](opts, db) // Передаём экземпляр db +bot, err := laniakea.NewBot[*MyDB](opts) +if err != nil { + log.Fatal(err) +} +bot.DatabaseContext(db) ``` +### tgapi: API и Uploader + +В `tgapi` есть два клиента: + +- `API` для JSON-запросов (`SendMessage`, `EditMessageText`, методы с `file_id`/URL). +- `Uploader` для multipart-загрузок (`SendPhoto`, `SendDocument`, `SendVideo` с бинарными файлами). + +Для продвинутых сценариев `tgapi.NewRequest(...)` и `tgapi.NewUploaderRequest(...)` остаются публичными low-level escape hatch API. Они менее безопасны, чем типизированные helper-методы: вызывающая сторона сама отвечает за корректное имя Telegram-метода и совместимые типы параметров/ответа. + ## 🧩 Промежуточные слои (Middleware) Middleware — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста. @@ -167,11 +186,12 @@ func(ctx *MsgContext, db T) bool - Если возвращается false, цепочка выполнения немедленно прерывается (команда не запускается). ### Добавление middleware -Используйте метод Use плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления. +Используйте метод `AddMiddleware` плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления. ```go -plugin := laniakea.NewPlugin[MyDB]("admin") -plugin.Use(loggingMiddleware, adminOnlyMiddleware) +plugin := laniakea.NewPlugin[*MyDB]("admin") +plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) +plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) plugin.AddCommand(plugin.NewCommand(banUser, "ban")) ``` @@ -200,9 +220,17 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. ## ⚙️ Расширенная настройка -**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard(). -**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram. -**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля. +- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. +- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram. +- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. +- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. +- **Жизненный цикл**: `RunWithContext(...)` не вызывает `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. + +## Обработка Telegram Updates +- Команды и payload-ы обрабатываются через плагины. +- Для некомандных update-ов можно зарегистрировать обработчик через `plugin.AddUpdateHandler(updateType, handler)`. +- `message`, `channel_post` и `callback_query` остаются в command/payload flow. +- После JSON-декодирования `tgapi.Update` заполняет поле `Type`, чтобы обработчики могли явно видеть итоговый вид update. ## 📝 Лицензия Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE). diff --git a/SEMVER.md b/SEMVER.md new file mode 100644 index 0000000..4422ed4 --- /dev/null +++ b/SEMVER.md @@ -0,0 +1,45 @@ +# Semantic Versioning Policy + +This project follows Semantic Versioning with the rules below. + +## Public API Surface + +The public API consists of: +- exported identifiers in package `laniakea` +- exported identifiers in package `tgapi` +- documented behavior in `README.md`, `README_RU.md`, and package godoc + +Anything unexported is internal and may change without notice. + +## Breaking Changes + +A release requires a major version bump when it changes any of the following: +- exported function, method, type, field, constant, or variable names +- function or method signatures +- JSON field names or request/response wire compatibility in `tgapi` +- documented behavioral guarantees relied on by callers + +Examples: +- removing an exported alias +- changing callback payload encoding defaults +- changing handler dispatch semantics in a way that breaks existing bots + +## Minor Changes + +A release uses a minor version bump for backward-compatible additions: +- new exported types, methods, helpers, or update handlers +- support for new Telegram Bot API fields or methods +- optional configuration knobs that do not change existing defaults + +## Patch Changes + +A release uses a patch version bump for backward-compatible fixes: +- bug fixes +- test-only changes +- godoc and README clarifications +- internal refactors with no public behavior change + +## Pre-Releases + +`-rc.N` builds may still adjust API details before `v1.0.0`. +Once `v1.0.0` is released, breaking changes require a new major version. diff --git a/bot.go b/bot.go index 40dccae..651bd05 100644 --- a/bot.go +++ b/bot.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "reflect" "sort" "strings" "sync" + "time" "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -15,13 +17,18 @@ import ( "github.com/alitto/pond/v2" ) -// DbContext is an interface representing the application's database context. -// It is injected into plugins and middleware via Bot.DatabaseContext(). +// DbContext is the generic dependency type injected into bots, plugins, and handlers. +// Use it for shared application state such as database handles or service containers. // // Example: // // type MyDB struct { ... } -// bot := NewBot[MyDB](opts).DatabaseContext(&myDB) +// myDB := &MyDB{} +// bot, err := NewBot[*MyDB](opts) +// if err != nil { +// return err +// } +// bot.DatabaseContext(myDB) // // Use NoDB if no database is needed. type DbContext any @@ -32,7 +39,7 @@ type NoDB struct{ DbContext } // DbLogger is a function type that returns a slog.LoggerWriter for database logging. // Used to inject database-specific log output (e.g., SQL queries, ORM events). -type DbLogger[T DbContext] func(db *T) slog.LoggerWriter +type DbLogger[T DbContext] func(db T) slog.LoggerWriter // BotPayloadType defines the serialization format for callback data payloads. type BotPayloadType string @@ -44,6 +51,20 @@ var ( BotPayloadJson BotPayloadType = "json" ) +var ( + // ErrNoPrefixes reports that the bot was started without any command prefixes. + ErrNoPrefixes = errors.New("no prefixes defined") + // ErrNoPlugins reports that the bot was started without any registered plugins. + ErrNoPlugins = errors.New("no plugins defined") + // ErrBotAlreadyRun reports that Run or RunWithContext was called more than once. + ErrBotAlreadyRun = errors.New("bot can only be run once") + + // ErrTokenRequired reports that BotOpts.Token was empty. + ErrTokenRequired = errors.New("token required") + // ErrOptsIsNil reports that NewBot was called with a nil BotOpts pointer. + ErrOptsIsNil = errors.New("opts is nil") +) + // Bot is the core Telegram bot instance. // // Manages: @@ -53,7 +74,8 @@ var ( // - Logging and rate limiting // - Localization and draft message support // -// All methods are safe for concurrent use. Direct field access is not recommended. +// Runtime accessors are safe for concurrent use. Configure the bot before Run. +// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session. type Bot[T DbContext] struct { token string debug bool @@ -73,9 +95,11 @@ type Bot[T DbContext] struct { api *tgapi.API // Telegram API client uploader *tgapi.Uploader // File uploader - dbContext *T // Injected database context - l10n *L10n // Localization manager - draftProvider *DraftProvider // Draft message builder + dbContext T // Injected database context + hasDBContext bool + warnedValueDB bool + l10n *L10n // Localization manager + draftProvider *DraftProvider // Draft message builder updateOffsetMu sync.Mutex updateOffset int // Last processed update ID @@ -83,6 +107,9 @@ type Bot[T DbContext] struct { updateQueue chan *tgapi.Update // Internal queue for processing updates runnerOnceWG sync.WaitGroup // Tracks one-time async runners runnerBgWG sync.WaitGroup // Tracks background async runners + runStateMu sync.Mutex + running bool + ran bool } // NewBot creates and initializes a new Bot instance using the provided BotOpts. @@ -93,13 +120,12 @@ type Bot[T DbContext] struct { // - Fetches bot username via GetMe() // - Sets up DraftProvider with random IDs // - Adds API and Uploader loggers to extraLoggers -// -// Panics if: -// - Token is empty -// - GetMe() fails (invalid token or network error) -func NewBot[T any](opts *BotOpts) *Bot[T] { +func NewBot[T any](opts *BotOpts) (*Bot[T], error) { + if opts == nil { + return nil, ErrOptsIsNil + } if opts.Token == "" { - panic("laniakea: BotOpts.Token is required") + return nil, ErrTokenRequired } updateQueue := make(chan *tgapi.Update, 512) @@ -163,7 +189,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { u, err := api.GetMe() if err != nil { _ = bot.Close() - bot.logger.Fatal(err) + return nil, err } bot.username = Val(u.Username, "") if bot.username == "" { @@ -171,7 +197,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { } bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown"))) - return bot + return bot, nil } // Close gracefully shuts down bot-owned resources. @@ -226,11 +252,7 @@ func (bot *Bot[T]) CloseRemote(ctx context.Context) error { return nil } -// initLoggers configures the main and optional request loggers. -// -// Uses DEBUG flag to set log level (DEBUG if true, FATAL otherwise). -// Writes to stdout in JSON format by default. -// If WriteToFile is true, writes to main.log and requests.log in LoggerBasePath. +// Internal logger setup for the bot and optional request logger. func (bot *Bot[T]) initLoggers(opts *BotOpts) { level := slog.FATAL if opts.Debug { @@ -285,8 +307,8 @@ func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } // GetDBContext returns the injected database context. -// Returns nil if not set via DatabaseContext(). -func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } +// If DatabaseContext was not called, it returns the zero value of T. +func (bot *Bot[T]) GetDBContext() T { return bot.dbContext } // GetLoggerLevel returns the effective log level derived from the bot's debug // flag. @@ -313,8 +335,16 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { // DatabaseContext injects a database context into the bot. // This context is accessible to plugins and middleware via GetDBContext(). -func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] { +// For shared dependencies such as *sql.DB, prefer using a pointer type as T. +// Value-typed contexts are supported, but the bot warns once because handlers +// receive T by value. +func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] { + if !bot.warnedValueDB && shouldWarnOnValueDBContext[T]() && bot.logger != nil { + bot.logger.Warnln("database context uses a value type; shared dependencies should usually use a pointer type as T") + bot.warnedValueDB = true + } bot.dbContext = ctx + bot.hasDBContext = true return bot } @@ -387,12 +417,20 @@ func (bot *Bot[T]) Debug(debug bool) *Bot[T] { func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { level := bot.GetLoggerLevel() for _, p := range plugin { + if p == nil { + if bot.logger != nil { + bot.logger.Warn("nil plugin skipped") + } + continue + } cloned := clonePlugin(p) if cloned.logger == nil { cloned.logger = utils.CreateLogger(cloned.name, level) } bot.plugins = append(bot.plugins, cloned) - bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) + if bot.logger != nil { + bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) + } } return bot } @@ -409,13 +447,14 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { // // Example: // -// bot.AddMiddleware(&authMiddleware, &rateLimitMiddleware) +// bot.AddMiddleware(authMiddleware, rateLimitMiddleware) // -// Panics if any middleware has a nil name. +// Middleware with an empty name are skipped with a warning. func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { for _, m := range middleware { if m.name == "" { - panic("laniakea: middleware must have a non-empty name") + bot.logger.Warnln("middleware must have a non-empty name") + continue } bot.middlewares = append(bot.middlewares, m) bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) @@ -446,12 +485,13 @@ func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { // // Example: // -// bot.AddRunner(&cleanupRunner) +// bot.AddRunner(cleanupRunner) // -// Panics if runner has a nil name. +// Runners with an empty name are skipped with a warning. func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { if runner.name == "" { - panic("laniakea: runner must have a non-empty name") + bot.logger.Warnln("runner must have a non-empty name") + return bot } bot.runners = append(bot.runners, runner) bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) @@ -498,6 +538,14 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { // return db.QueryLogger() // }) func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { + if !bot.hasDBContext { + bot.logger.Warnln("database context is not set; skipping database logger writer") + return bot + } + if isNilValue(bot.dbContext) { + bot.logger.Warnln("database context is nil; skipping database logger writer") + return bot + } w := writer(bot.dbContext) bot.logger.AddWriter(w) if bot.RequestLogger != nil { @@ -536,17 +584,21 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { // go bot.RunWithContext(ctx) // // ... later ... // cancel() // triggers graceful shutdown -// _ = bot.Close(context.Background()) -func (bot *Bot[T]) RunWithContext(ctx context.Context) { +// _ = bot.Close() +// +// A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun. +func (bot *Bot[T]) RunWithContext(ctx context.Context) error { if len(bot.prefixes) == 0 { - bot.logger.Errorln("no prefixes defined") - return + return ErrNoPrefixes } if len(bot.plugins) == 0 { - bot.logger.Errorln("no plugins defined") - return + return ErrNoPlugins } + if err := bot.beginRun(); err != nil { + return err + } + defer bot.finishRun() bot.ExecRunners(ctx) @@ -560,6 +612,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { } close(bot.updateQueue) }() + retryDelay := time.Duration(0) for { select { case <-ctx.Done(): @@ -571,8 +624,19 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { return } bot.logger.Errorln("failed to fetch updates:", err) + retryDelay = nextPollRetryDelay(retryDelay) + timer := time.NewTimer(retryDelay) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return + case <-timer.C: + } continue } + retryDelay = 0 for _, update := range updates { u := update // copy loop variable to avoid race condition @@ -597,6 +661,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { pool.Stop() // Wait for all tasks to complete and stop the pool bot.runnerOnceWG.Wait() bot.runnerBgWG.Wait() + return nil } // Run starts the bot using a background context. @@ -605,8 +670,62 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { // Use this for simple bots where graceful shutdown is not required. // // For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully. -func (bot *Bot[T]) Run() { - bot.RunWithContext(context.Background()) +func (bot *Bot[T]) Run() error { + return bot.RunWithContext(context.Background()) +} + +func (bot *Bot[T]) beginRun() error { + bot.runStateMu.Lock() + defer bot.runStateMu.Unlock() + if bot.running || bot.ran { + return ErrBotAlreadyRun + } + bot.running = true + bot.ran = true + return nil +} + +func (bot *Bot[T]) finishRun() { + bot.runStateMu.Lock() + bot.running = false + bot.runStateMu.Unlock() +} + +func nextPollRetryDelay(prev time.Duration) time.Duration { + if prev <= 0 { + return time.Second + } + next := prev * 2 + if next > 30*time.Second { + return 30 * time.Second + } + return next +} + +func isNilValue[T any](v T) bool { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return true + } + switch rv.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return rv.IsNil() + default: + return false + } +} + +func shouldWarnOnValueDBContext[T any]() bool { + t := reflect.TypeFor[T]() + if t == reflect.TypeFor[NoDB]() { + return false + } + switch t.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan: + return false + default: + return true + } } func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { @@ -617,6 +736,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), skipAutoCmd: p.skipAutoCmd, logger: p.logger, + handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), onClose: p.onClose, } @@ -626,6 +746,9 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { for name, command := range p.payloads { cloned.payloads[name] = cloneCommand(command) } + for t, handler := range p.handlers { + cloned.handlers[t] = handler + } return cloned } diff --git a/bot_opts.go b/bot_opts.go index ae04a19..0ec97d6 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -11,7 +11,7 @@ import ( // BotOpts holds configuration options for initializing a Bot. // // Values are loaded from environment variables via LoadOptsFromEnv(). -// Use NewOpts() to create a zero-value struct and set fields manually. +// Use &BotOpts{} to create a value and set fields manually. type BotOpts struct { // Token is the Telegram bot token (required). Token string @@ -56,7 +56,7 @@ type BotOpts struct { // Use this to prioritize responsiveness over reliability. DropRLOverflow bool - // MaxWorkers is the maximum number of concurrency running update handlers. + // MaxWorkers is the maximum number of update handlers that may run concurrently. MaxWorkers int } @@ -75,20 +75,30 @@ type BotOpts struct { // - API_URL: custom API endpoint // - RATE_LIMIT: max requests per second (default: 30) // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow +// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32) // -// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined. +// Returns a populated BotOpts. +// NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing. func LoadOptsFromEnv() *BotOpts { rateLimit := 30 + maxWorkers := 32 + + stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES")) + updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes)) + for _, updateType := range stringUpdateTypes { + updateTypes = append(updateTypes, tgapi.UpdateType(updateType)) + } + if rl := os.Getenv("RATE_LIMIT"); rl != "" { if n, err := strconv.Atoi(rl); err == nil { rateLimit = n } } - stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES")) - updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes)) - for _, updateType := range stringUpdateTypes { - updateTypes = append(updateTypes, tgapi.UpdateType(updateType)) + if mw := os.Getenv("MAX_WORKERS"); mw != "" { + if n, err := strconv.Atoi(os.Getenv("MAX_WORKERS")); err == nil { + maxWorkers = n + } } return &BotOpts{ @@ -108,6 +118,8 @@ func LoadOptsFromEnv() *BotOpts { RateLimit: rateLimit, DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", + + MaxWorkers: maxWorkers, } } diff --git a/bot_test.go b/bot_test.go index 0d4d671..45db995 100644 --- a/bot_test.go +++ b/bot_test.go @@ -1,9 +1,12 @@ package laniakea import ( + "context" + "errors" "path/filepath" "reflect" "testing" + "time" "git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/slog" @@ -24,14 +27,14 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { bot := &Bot[NoDB]{logger: slog.CreateLogger()} plugin := NewPlugin[NoDB]("demo") - cmd := plugin.NewCommand(func(ctx *MsgContext, db *NoDB) {}, "start") - plugin.AddMiddleware(*NewMiddleware("base", func(ctx *MsgContext, db *NoDB) bool { return true })) + cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "start") + plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })) bot.AddPlugins(plugin) cmd.SetDescription("mutated after registration") - plugin.NewCommand(func(ctx *MsgContext, db *NoDB) {}, "late") - plugin.AddMiddleware(*NewMiddleware("late", func(ctx *MsgContext, db *NoDB) bool { return true })) + plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "late") + plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true })) registered := bot.plugins[0] if _, exists := registered.commands["late"]; exists { @@ -45,6 +48,20 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { } } +func TestAddPluginsSkipsNilPlugin(t *testing.T) { + bot := &Bot[NoDB]{logger: slog.CreateLogger()} + plugin := NewPlugin[NoDB]("demo") + + bot.AddPlugins(nil, plugin) + + if len(bot.plugins) != 1 { + t.Fatalf("expected exactly one registered plugin, got %d", len(bot.plugins)) + } + if bot.plugins[0].name != "demo" { + t.Fatalf("unexpected plugin name: %q", bot.plugins[0].name) + } +} + func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { bot := &Bot[NoDB]{} @@ -68,3 +85,117 @@ func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { t.Fatalf("failed to close main logger: %v", err) } } + +func TestNextPollRetryDelay(t *testing.T) { + tests := []struct { + name string + prev time.Duration + want time.Duration + }{ + {name: "initial", prev: 0, want: time.Second}, + {name: "double", prev: 2 * time.Second, want: 4 * time.Second}, + {name: "cap", prev: 20 * time.Second, want: 30 * time.Second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := nextPollRetryDelay(tt.prev); got != tt.want { + t.Fatalf("nextPollRetryDelay(%s) = %s, want %s", tt.prev, got, tt.want) + } + }) + } +} + +func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsUnset(t *testing.T) { + bot := &Bot[NoDB]{logger: slog.CreateLogger()} + called := false + + bot.AddDatabaseLoggerWriter(func(db NoDB) slog.LoggerWriter { + called = true + return nil + }) + + if called { + t.Fatal("expected database logger writer to be skipped when db context is unset") + } +} + +func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsNil(t *testing.T) { + type testDB struct{} + + bot := &Bot[*testDB]{logger: slog.CreateLogger()} + var db *testDB + bot.DatabaseContext(db) + + called := false + bot.AddDatabaseLoggerWriter(func(db *testDB) slog.LoggerWriter { + called = true + return nil + }) + + if called { + t.Fatal("expected database logger writer to be skipped when db context is nil") + } +} + +func TestShouldWarnOnValueDBContext(t *testing.T) { + type testDB struct{} + type dbIface interface{ Ping() error } + + tests := []struct { + name string + got bool + want bool + }{ + {name: "NoDB", got: shouldWarnOnValueDBContext[NoDB](), want: false}, + {name: "pointer", got: shouldWarnOnValueDBContext[*testDB](), want: false}, + {name: "interface", got: shouldWarnOnValueDBContext[dbIface](), want: false}, + {name: "map", got: shouldWarnOnValueDBContext[map[string]int](), want: false}, + {name: "struct", got: shouldWarnOnValueDBContext[testDB](), want: true}, + {name: "int", got: shouldWarnOnValueDBContext[int](), want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("shouldWarnOnValueDBContext = %v, want %v", tt.got, tt.want) + } + }) + } +} + +func TestDatabaseContextMarksValueWarningOnce(t *testing.T) { + type testDB struct{} + + bot := &Bot[testDB]{logger: slog.CreateLogger()} + bot.DatabaseContext(testDB{}) + if !bot.warnedValueDB { + t.Fatal("expected value-typed database context to mark warning state") + } + + ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()} + ptrBot.DatabaseContext(&testDB{}) + if ptrBot.warnedValueDB { + t.Fatal("did not expect pointer-typed database context to mark warning state") + } +} + +func TestRunWithContextRejectsSecondRun(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoDB]{{name: "demo"}}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + } + + if err := bot.RunWithContext(ctx); err != nil { + t.Fatalf("first RunWithContext returned error: %v", err) + } + if err := bot.RunWithContext(ctx); !errors.Is(err, ErrBotAlreadyRun) { + t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err) + } +} diff --git a/cmd_generator.go b/cmd_generator.go index 4658e88..2bf524a 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -4,13 +4,14 @@ import ( "errors" "fmt" "regexp" + "sort" "strings" "git.nix13.pw/scuroneko/laniakea/tgapi" ) // CmdRegexp matches command names allowed for Telegram command registration. -var CmdRegexp = regexp.MustCompile("^[_a-z0-9]+$") +var CmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$") // ErrTooManyCommands is returned when the total number of registered commands // exceeds Telegram's limit of 100 bot commands per bot. @@ -20,7 +21,7 @@ var CmdRegexp = regexp.MustCompile("^[_a-z0-9]+$") // bot initialization. var ErrTooManyCommands = errors.New("too many commands. max 100") -// generateBotCommand builds a BotCommand description with generated usage text. +// Internal helper to build a BotCommand description with generated usage text. func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { desc := "" if len(cmd.description) > 0 { @@ -44,13 +45,20 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { return tgapi.BotCommand{Command: cmd.command, Description: usage} } -// checkCmdRegex reports whether cmd matches CmdRegexp. +// Internal helper to validate Telegram command names. func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) } -// gatherCommandsForPlugin collects non-skipped, valid commands from one plugin. +// Internal helper to collect non-skipped, valid commands from one plugin. func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) - for _, cmd := range pl.commands { + names := make([]string, 0, len(pl.commands)) + for name := range pl.commands { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + cmd := pl.commands[name] if cmd.skipAutoCmd { continue } @@ -62,9 +70,7 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { return commands } -// gatherCommands collects all commands from all plugins -// and converts them into tgapi.BotCommand objects. -// See gatherCommandsForPlugin. +// Internal helper to collect all auto-generated commands from registered plugins. func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, pl := range bot.plugins { diff --git a/cmd_generator_test.go b/cmd_generator_test.go index c2130aa..468316f 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -4,6 +4,7 @@ import ( "errors" "io" "net/http" + "reflect" "strconv" "strings" "sync/atomic" @@ -43,7 +44,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { }() plugin := NewPlugin[NoDB]("overflow") - exec := func(ctx *MsgContext, db *NoDB) {} + exec := func(ctx *MsgContext, db NoDB) {} for i := 0; i < 101; i++ { plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) } @@ -62,3 +63,23 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { t.Fatalf("expected no HTTP calls before limit validation, got %d", calls.Load()) } } + +func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { + plugin := NewPlugin[NoDB]("sorted") + exec := func(ctx *MsgContext, db NoDB) {} + + plugin.AddCommand(NewCommand(exec, "zeta")) + plugin.AddCommand(NewCommand(exec, "alpha")) + plugin.AddCommand(NewCommand(exec, "mid")) + + commands := gatherCommandsForPlugin(*plugin) + got := make([]string, 0, len(commands)) + for _, cmd := range commands { + got = append(got, cmd.Command) + } + + want := []string{"alpha", "mid", "zeta"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected command order: got %v want %v", got, want) + } +} diff --git a/doc.go b/doc.go index a19a1ec..9a6dcee 100644 --- a/doc.go +++ b/doc.go @@ -1,55 +1,33 @@ /* Package laniakea provides a modular, extensible framework for building scalable Telegram bots. -It offers a fluent API for configuration and separates concerns through several core concepts: +Core concepts: - - Bot: The central instance managing API communication, update processing, logging, - rate limiting, and dependency injection. Created via NewBot[T]. - - - Plugins: Organize commands and payloads into reusable units. - A plugin can have multiple commands and shared middlewares. - - - Commands: Named bot commands with descriptions, argument validation, and - execution logic. Automatically registrable across different chat scopes. - - - Middleware: Functions that intercept and modify updates before they reach plugins. - Useful for authentication, logging, validation, etc. Return false to stop processing. - - - MsgContext: Provides access to the incoming update and convenient methods for - responding, editing, deleting, and translating messages. Includes built-in rate limiting - and error handling. ⚠️ MarkdownV2 methods require manual escaping via EscapeMarkdownV2(). - - - InlineKeyboard: A fluent builder for constructing inline keyboards with styled buttons, - icons, URLs, and structured callback data (JSON or Base64). - - - DraftProvider: Manages ephemeral, multi-step message drafts with automatic ID generation - (random or linear). Drafts can be built incrementally and flushed atomically. - - - L10n: Simple key-based localization system with fallback language support. - - - Runners: Background goroutines for periodic tasks or one‑off initialization, - with configurable timeouts and async execution. - - - RateLimiting & Logging: Built‑in rate limiter (respects Telegram's retry_after) - and structured logging (JSON stdout + optional file output) with request‑level tracing. - - - Dependency Injection: Pass any custom database context (e.g., *sql.DB) to all handlers - via the type parameter T in Bot[T]. + - Bot manages Telegram API access, update processing, logging, rate limiting, and dependency injection. + - Plugins group commands, payloads, and non-command update handlers behind shared middleware. + - MsgContext provides access to the current update and reply/edit/delete helpers. + - InlineKeyboard builds callback-driven keyboards and structured payloads. + - DraftProvider accumulates multi-step replies before sending them. + - L10n stores key-based translations with fallback behavior. + - Runners execute startup or background tasks alongside the polling loop. Example usage: - bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()). - DatabaseContext(&myDB). + bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv()) + if err != nil { + return err + } + bot.DatabaseContext(myDB). AddUpdateType(tgapi.UpdateTypeMessage). AddPrefixes("/", "!"). AddPlugins(&startPlugin, &helpPlugin). - AddMiddleware(&authMiddleware, &logMiddleware). - AddRunner(&cleanupRunner). + AddMiddleware(authMiddleware, logMiddleware). + AddRunner(cleanupRunner). AddL10n(l10n.New()) - bot.Run() + return bot.Run() -All public methods are safe for concurrent use unless stated otherwise. -Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset). +Configure bots, plugins, and localization before starting Run or RunWithContext. +Runtime accessors are safe for concurrent use unless stated otherwise. */ package laniakea diff --git a/drafts.go b/drafts.go index 168adca..2de42de 100644 --- a/drafts.go +++ b/drafts.go @@ -12,7 +12,7 @@ import ( // ErrDraftChatIDZero is returned when a draft is used without setting a chat ID. var ErrDraftChatIDZero = errors.New("zero draft chat ID") -// draftIdGenerator defines an interface for generating unique draft IDs. +// Interface for generating unique draft IDs. type draftIdGenerator interface { // Next returns the next unique draft ID. Next() uint64 @@ -239,7 +239,7 @@ func (d *Draft) Flush() error { return err } -// push is the internal helper for Push(). It updates the server draft via SendMessageDraft. +// Internal helper for Push that updates the server-side draft. func (d *Draft) push(text string) error { if d.chatID == 0 { return ErrDraftChatIDZero diff --git a/handler.go b/handler.go index b605482..b328217 100644 --- a/handler.go +++ b/handler.go @@ -22,37 +22,47 @@ func (bot *Bot[T]) handle(u *tgapi.Update) { ctx := &MsgContext{ Update: *u, Api: bot.api, + Logger: bot.logger, errorTemplate: bot.errorTemplate, l10n: bot.l10n, draftProvider: bot.draftProvider, payloadType: bot.payloadType, } + bot.prepareUpdateCtx(u, ctx) + for _, middleware := range bot.middlewares { if !middleware.Execute(ctx, bot.dbContext) { return } } - if u.CallbackQuery != nil { - bot.handleCallback(u, ctx) - } else { + switch u.Type { + case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost: bot.handleMessage(u, ctx) + case tgapi.UpdateTypeCallbackQuery: + bot.handleCallback(u, ctx) + default: + bot.handleUpdate(u, ctx) } } func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { - if update.Message == nil { - return - } - if update.Message.From == nil { + var msg *tgapi.Message + if update.Message != nil { + msg = update.Message + } else if update.ChannelPost != nil { + msg = update.ChannelPost + } else { return } var text string - if len(update.Message.Text) > 0 { - text = update.Message.Text + if len(msg.Text) > 0 { + text = msg.Text + } else if len(msg.Caption) > 0 { + text = msg.Caption } else { - text = update.Message.Caption + return } text = strings.TrimSpace(text) @@ -60,10 +70,9 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { if !hasPrefix { return } + ctx.Prefix = prefix - ctx.FromID = update.Message.From.ID - ctx.From = update.Message.From - ctx.Msg = update.Message + ctx.Update = *update // Убираем префикс text = strings.TrimSpace(text[len(prefix):]) @@ -94,9 +103,8 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { ctx.Text = args ctx.Args = strings.Fields(args) // Убирает лишние пробелы - ctx.Logger = plugin.logger - if ctx.Logger == nil { - ctx.Logger = bot.logger + if plugin.logger != nil { + ctx.Logger = plugin.logger } if !plugin.executeMiddlewares(ctx, bot.dbContext) { return @@ -114,16 +122,6 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { return } - ctx.FromID = update.CallbackQuery.From.ID - ctx.From = &update.CallbackQuery.From - if update.CallbackQuery.Message != nil { - ctx.Msg = update.CallbackQuery.Message - ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID - } - if update.CallbackQuery.InlineMessageID != nil { - ctx.InlineMsgId = *update.CallbackQuery.InlineMessageID - } - ctx.CallbackQueryId = update.CallbackQuery.ID ctx.Args = data.Args for _, plugin := range bot.plugins { @@ -144,6 +142,134 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { } } +func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { + for _, plugin := range bot.plugins { + handler, ok := plugin.handlers[u.Type] + if !ok { + continue + } + + pluginCtx := cloneMsgContext(ctx) + if plugin.logger != nil { + pluginCtx.Logger = plugin.logger + } + if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) { + continue + } + handler(pluginCtx, bot.dbContext) + } +} + +func cloneMsgContext(src *MsgContext) *MsgContext { + cloned := *src + if src.Args != nil { + cloned.Args = append([]string(nil), src.Args...) + } + return &cloned +} + +func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { + var from *tgapi.User + switch u.Type { + case tgapi.UpdateTypeMessage: + if u.Message != nil { + ctx.Msg = u.Message + } + case tgapi.UpdateTypeEditedMessage: + if u.EditedMessage != nil { + ctx.Msg = u.EditedMessage + } + case tgapi.UpdateTypeChannelPost: + if u.ChannelPost != nil { + ctx.Msg = u.ChannelPost + } + case tgapi.UpdateTypeEditedChannelPost: + if u.EditedChannelPost != nil { + ctx.Msg = u.EditedChannelPost + } + case tgapi.UpdateTypeBusinessMessage: + if u.BusinessMessage != nil { + ctx.Msg = u.BusinessMessage + } + case tgapi.UpdateTypeEditedBusinessMessage: + if u.EditedBusinessMessage != nil { + ctx.Msg = u.EditedBusinessMessage + } + case tgapi.UpdateTypeInlineQuery: + if u.InlineQuery != nil { + from = &u.InlineQuery.From + } + case tgapi.UpdateTypeChosenInlineResult: + if u.ChosenInlineResult != nil { + from = &u.ChosenInlineResult.From + } + case tgapi.UpdateTypeCallbackQuery: + if u.CallbackQuery != nil { + if u.CallbackQuery.Message != nil { + ctx.Msg = u.CallbackQuery.Message + ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID + } + if u.CallbackQuery.InlineMessageID != nil { + ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID + } + ctx.CallbackQueryId = u.CallbackQuery.ID + from = &u.CallbackQuery.From + } + case tgapi.UpdateTypeShippingQuery: + if u.ShippingQuery != nil { + from = &u.ShippingQuery.From + } + case tgapi.UpdateTypePreCheckoutQuery: + if u.PreCheckoutQuery != nil { + from = &u.PreCheckoutQuery.From + } + case tgapi.UpdateTypePurchasedPaidMedia: + if u.PurchasedPaidMedia != nil { + from = &u.PurchasedPaidMedia.From + } + case tgapi.UpdateTypeMyChatMember: + if u.MyChatMember != nil { + from = &u.MyChatMember.From + } + case tgapi.UpdateTypeChatMember: + if u.ChatMember != nil { + from = &u.ChatMember.From + } + case tgapi.UpdateTypeChatJoinRequest: + if u.ChatJoinRequest != nil { + from = &u.ChatJoinRequest.From + } + case tgapi.UpdateTypeBusinessConnection: + if u.BusinessConnection != nil { + from = &u.BusinessConnection.User + } + case tgapi.UpdateTypePollAnswer: + if u.PollAnswer != nil { + from = &u.PollAnswer.User + } + case tgapi.UpdateTypeMessageReaction: + if u.MessageReaction != nil { + from = u.MessageReaction.User + } + case tgapi.UpdateTypeChatBoost: + if u.ChatBoost != nil { + from = &u.ChatBoost.Boost.Source.User + } + case tgapi.UpdateTypeRemovedChatBoost: + if u.RemovedChatBoost != nil { + from = &u.RemovedChatBoost.Source.User + } + } + if ctx.Msg != nil && from == nil { + from = ctx.Msg.From + } + if from != nil { + ctx.From = from + ctx.FromID = from.ID + } + ctx.Update = *u +} + func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { for _, prefix := range bot.prefixes { if prefix == "" { diff --git a/handler_test.go b/handler_test.go index 789dae5..8d91e6b 100644 --- a/handler_test.go +++ b/handler_test.go @@ -1,6 +1,11 @@ package laniakea -import "testing" +import ( + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/slog" +) func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { bot := &Bot[NoDB]{prefixes: []string{"", "/"}} @@ -12,3 +17,222 @@ func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { t.Fatalf("unexpected prefix result: prefix=%q ok=%v", prefix, ok) } } + +func TestBotMiddlewareReceivesLogger(t *testing.T) { + logger := slog.CreateLogger() + called := false + + bot := &Bot[NoDB]{ + logger: logger, + middlewares: []Middleware[NoDB]{ + NewMiddleware("logger-check", func(ctx *MsgContext, db NoDB) bool { + called = true + if ctx.Logger != logger { + t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger) + } + return true + }), + }, + } + + bot.handle(&tgapi.Update{ + UpdateID: 1, + Type: tgapi.UpdateTypePoll, + Poll: &tgapi.Poll{ + ID: "poll", + Question: "question", + }, + }) + + if !called { + t.Fatal("expected bot middleware to be called") + } +} + +func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { + plugin := NewPlugin[NoDB]("test") + handler := func(ctx *MsgContext, db NoDB) {} + + for _, updateType := range []tgapi.UpdateType{ + tgapi.UpdateTypeMessage, + tgapi.UpdateTypeChannelPost, + tgapi.UpdateTypeCallbackQuery, + } { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("AddUpdateHandler(%q) panicked: %v", updateType, r) + } + }() + plugin.AddUpdateHandler(updateType, handler) + }() + + if _, ok := plugin.handlers[updateType]; ok { + t.Fatalf("reserved update type %q must not be registered", updateType) + } + } +} + +func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { + tests := []struct { + name string + update *tgapi.Update + wantID int64 + }{ + { + name: "inline query", + update: &tgapi.Update{ + UpdateID: 1, + Type: tgapi.UpdateTypeInlineQuery, + InlineQuery: &tgapi.InlineQuery{ + ID: "iq", + From: tgapi.User{ID: 41}, + Query: "ping", + }, + }, + wantID: 41, + }, + { + name: "chosen inline result", + update: &tgapi.Update{ + UpdateID: 2, + Type: tgapi.UpdateTypeChosenInlineResult, + ChosenInlineResult: &tgapi.ChosenInlineResult{ + ResultID: "res", + From: tgapi.User{ID: 77}, + Query: "pong", + }, + }, + wantID: 77, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + called := false + plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) { + called = true + if ctx.Update.UpdateID != tt.update.UpdateID { + t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID) + } + if ctx.From == nil { + t.Fatal("expected ctx.From to be populated") + } + if ctx.FromID != tt.wantID { + t.Fatalf("unexpected FromID: got %d want %d", ctx.FromID, tt.wantID) + } + if ctx.From.ID != tt.wantID { + t.Fatalf("unexpected ctx.From.ID: got %d want %d", ctx.From.ID, tt.wantID) + } + if ctx.Msg != nil { + t.Fatalf("did not expect message context for %s", tt.name) + } + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + bot.handle(tt.update) + + if !called { + t.Fatalf("expected update handler for %s to be called", tt.name) + } + }) + } +} + +func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { + firstCalled := false + secondCalled := false + + first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) { + firstCalled = true + if ctx.FromID != 41 { + t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID) + } + ctx.From = nil + ctx.FromID = 999 + ctx.Text = "mutated" + ctx.Args = []string{"mutated"} + }) + second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) { + secondCalled = true + if ctx.From == nil { + t.Fatal("expected ctx.From to remain populated for second handler") + } + if ctx.FromID != 41 { + t.Fatalf("unexpected FromID in second handler: got %d want 41", ctx.FromID) + } + if ctx.Text != "" { + t.Fatalf("unexpected leaked Text in second handler: %q", ctx.Text) + } + if len(ctx.Args) != 0 { + t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args) + } + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + plugins: []Plugin[NoDB]{ + clonePlugin(first), + clonePlugin(second), + }, + } + + bot.handle(&tgapi.Update{ + UpdateID: 3, + Type: tgapi.UpdateTypeInlineQuery, + InlineQuery: &tgapi.InlineQuery{ + ID: "iq", + From: tgapi.User{ID: 41}, + Query: "ping", + }, + }) + + if !firstCalled || !secondCalled { + t.Fatalf("expected both handlers to be called, got first=%v second=%v", firstCalled, secondCalled) + } +} + +func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { + called := false + plugin := NewPlugin[NoDB]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoDB) { + called = true + if ctx.Msg == nil { + t.Fatal("expected message context") + } + if ctx.Msg.Chat == nil || ctx.Msg.Chat.ID != -1001 { + t.Fatalf("unexpected chat context: %#v", ctx.Msg.Chat) + } + if ctx.From != nil { + t.Fatalf("expected ctx.From to stay nil for sender_chat updates, got %#v", ctx.From) + } + if ctx.FromID != 0 { + t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID) + } + }, "ping") + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + bot.handle(&tgapi.Update{ + UpdateID: 10, + Type: tgapi.UpdateTypeChannelPost, + ChannelPost: &tgapi.Message{ + MessageID: 55, + Text: "/ping", + SenderChat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, + Chat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, + }, + }) + + if !called { + t.Fatal("expected channel post command handler to be called") + } +} diff --git a/keyboard.go b/keyboard.go index 3ea98f6..1ce82ab 100644 --- a/keyboard.go +++ b/keyboard.go @@ -7,13 +7,12 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) -// ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary are predefined -// Telegram keyboard button styles for visual feedback. -// -// These values map directly to Telegram Bot API's InlineKeyboardButton style field. const ( - ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger" + // ButtonStyleDanger marks a destructive inline keyboard action. + ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger" + // ButtonStyleSuccess marks a confirmatory inline keyboard action. ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success" + // ButtonStylePrimary marks a primary inline keyboard action. ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary" ) @@ -83,8 +82,7 @@ func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) In return b } -// build converts the builder state into a tgapi.InlineKeyboardButton. -// This method is typically called internally by InlineKeyboard.AddButton(). +// Internal helper that converts the builder state into a Telegram button. func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { return tgapi.InlineKeyboardButton{ Text: b.text, @@ -146,8 +144,7 @@ func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard { return in } -// append adds a button to the current line. If the line is full, it auto-flushes. -// This is an internal helper used by other builder methods. +// Internal helper that appends a button and auto-flushes a full row. func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { if in.CurrentLine.Len() == in.maxRow { in.AddLine() @@ -235,12 +232,12 @@ type CallbackData struct { // (int, string, bool, float64) but may not serialize complex structs meaningfully. // // Use this to build callback payloads for bot command routing. -func NewCallbackData(command string, args ...any) *CallbackData { +func NewCallbackData(command string, args ...any) CallbackData { stringArgs := make([]string, len(args)) for i, arg := range args { stringArgs[i] = fmt.Sprint(arg) } - return &CallbackData{ + return CallbackData{ Command: command, Args: stringArgs, } @@ -253,8 +250,8 @@ func NewCallbackData(command string, args ...any) *CallbackData { // // This fallback ensures the bot receives a valid JSON payload even if internal // errors occur — avoiding "invalid callback_data" errors from Telegram. -func (d *CallbackData) ToJson() string { - data, err := encodeJsonPayload(*d) +func (d CallbackData) ToJson() string { + data, err := encodeJsonPayload(d) if err != nil { // Fallback: return minimal valid JSON to avoid Telegram API rejection return `{"cmd":""}` @@ -264,8 +261,8 @@ 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) +func (d CallbackData) ToBase64() string { + s, err := encodeBase64Payload(d) if err != nil { return `` } @@ -275,7 +272,7 @@ func (d *CallbackData) ToBase64() string { // Encode serializes the CallbackData according to the specified payload type. // Supported types: BotPayloadJson and BotPayloadBase64. // 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() diff --git a/keyboard_test.go b/keyboard_test.go new file mode 100644 index 0000000..3c39a95 --- /dev/null +++ b/keyboard_test.go @@ -0,0 +1,44 @@ +package laniakea + +import ( + "strings" + "testing" +) + +func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) { + kb := NewInlineKeyboardJson(2). + AddCallbackButton("A", "cmd", 1). + AddCallbackButton("B", "cmd", 2). + AddCallbackButton("C", "cmd", 3) + + markup := kb.Get() + if got := len(markup.InlineKeyboard); got != 2 { + t.Fatalf("unexpected row count: %d", got) + } + if got := len(markup.InlineKeyboard[0]); got != 2 { + t.Fatalf("unexpected first row size: %d", got) + } + if got := len(markup.InlineKeyboard[1]); got != 1 { + t.Fatalf("unexpected second row size: %d", got) + } + if !strings.Contains(markup.InlineKeyboard[0][0].CallbackData, `"cmd":"cmd"`) { + t.Fatalf("expected JSON callback payload, got %q", markup.InlineKeyboard[0][0].CallbackData) + } +} + +func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { + kb := NewInlineKeyboardBase64(3). + AddButton( + NewInlineKbButton("Docs"). + SetStyle(ButtonStylePrimary). + SetUrl("https://example.test"), + ) + + button := kb.Get().InlineKeyboard[0][0] + if button.Style != ButtonStylePrimary { + t.Fatalf("unexpected style: %q", button.Style) + } + if button.URL != "https://example.test" { + t.Fatalf("unexpected url: %q", button.URL) + } +} diff --git a/l10n.go b/l10n.go index 9f2268e..77c5092 100644 --- a/l10n.go +++ b/l10n.go @@ -1,21 +1,18 @@ package laniakea -// DictEntry represents a single localized entry with language-to-text mappings. -// Example: {"ru": "Привет", "en": "Hello"}. +import "sync" + +// DictEntry maps language codes to translated strings. type DictEntry map[string]string -// L10n is a localization manager that maps keys to language-specific strings. +// L10n stores translations with a configurable fallback language and is safe for concurrent use. type L10n struct { - entries map[string]DictEntry // Map of translation keys to language dictionaries - fallbackLang string // Language code to use when requested language is missing + mu sync.RWMutex + entries map[string]DictEntry + fallbackLang string } -// NewL10n creates a new L10n instance with the specified fallback language. -// The fallback language is used when a requested language is not available -// for a given key. -// -// Example: NewL10n("en") will return "Hello" for key "greeting" if "ru" is requested -// but no "ru" entry exists. +// NewL10n creates a localization store with the given fallback language. func NewL10n(fallbackLanguage string) *L10n { return &L10n{ entries: make(map[string]DictEntry), @@ -23,54 +20,52 @@ func NewL10n(fallbackLanguage string) *L10n { } } -// AddDictEntry adds a new translation entry for the given key. -// The value must be a DictEntry mapping language codes (e.g., "en", "ru") to their translated strings. -// -// If a key already exists, it is overwritten. -// -// Returns the L10n instance for method chaining. +// AddDictEntry stores translations for key. func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n { - l.entries[key] = value + l.mu.Lock() + defer l.mu.Unlock() + if l.entries == nil { + l.entries = make(map[string]DictEntry) + } + l.entries[key] = cloneDictEntry(value) return l } // GetFallbackLanguage returns the currently configured fallback language code. func (l *L10n) GetFallbackLanguage() string { + l.mu.RLock() + defer l.mu.RUnlock() return l.fallbackLang } -// Translate retrieves the translation for the given key and language. -// -// Behavior: -// - If the key exists and the language has a translation → returns the translation -// - If the key exists but the language is missing → returns the fallback language's value -// - If the key does not exist → returns the key string itself (as fallback) -// -// Example: -// -// l.AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Привет"}) -// l.Translate("en", "greeting") → "Hello" -// l.Translate("es", "greeting") → "Hello" (fallback to "en") -// l.Translate("en", "unknown") → "unknown" (key not found) -// -// This behavior ensures that missing translations do not break UI or logs — -// instead, the original key is displayed, making it easy to identify gaps. +// Translate returns the translation for key in lang, falling back to the configured language or the key itself. func (l *L10n) Translate(lang, key string) string { + l.mu.RLock() + defer l.mu.RUnlock() + entries, exists := l.entries[key] if !exists { - return key // Return key as fallback when translation is missing + return key } - // Try requested language if translation, ok := entries[lang]; ok { return translation } - // Fall back to configured fallback language if fallback, ok := entries[l.fallbackLang]; ok { return fallback } - // If fallback language is also missing, return the key return key } + +func cloneDictEntry(src DictEntry) DictEntry { + if src == nil { + return nil + } + cloned := make(DictEntry, len(src)) + for lang, text := range src { + cloned[lang] = text + } + return cloned +} diff --git a/l10n_test.go b/l10n_test.go new file mode 100644 index 0000000..b2fd1a6 --- /dev/null +++ b/l10n_test.go @@ -0,0 +1,77 @@ +package laniakea + +import ( + "fmt" + "sync" + "testing" +) + +func TestL10nTranslateUsesFallbackAndKey(t *testing.T) { + l10n := NewL10n("en"). + AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Privet"}). + AddDictEntry("partial", DictEntry{"ru": "Tolko ru"}) + + tests := []struct { + name string + lang string + key string + want string + }{ + {name: "exact match", lang: "ru", key: "greeting", want: "Privet"}, + {name: "fallback language", lang: "es", key: "greeting", want: "Hello"}, + {name: "missing fallback returns key", lang: "en", key: "partial", want: "partial"}, + {name: "unknown key returns key", lang: "en", key: "unknown", want: "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := l10n.Translate(tt.lang, tt.key); got != tt.want { + t.Fatalf("unexpected translation: got %q want %q", got, tt.want) + } + }) + } +} + +func TestL10nAddDictEntryCopiesInput(t *testing.T) { + l10n := NewL10n("en") + entry := DictEntry{"en": "Hello"} + + l10n.AddDictEntry("greeting", entry) + entry["en"] = "Mutated" + + if got := l10n.Translate("en", "greeting"); got != "Hello" { + t.Fatalf("unexpected translation after external mutation: got %q", got) + } +} + +func TestL10nZeroValueIsUsable(t *testing.T) { + var l10n L10n + + l10n.AddDictEntry("greeting", DictEntry{"en": "Hello"}) + + if got := l10n.Translate("en", "greeting"); got != "Hello" { + t.Fatalf("unexpected translation from zero-value l10n: got %q", got) + } +} + +func TestL10nConcurrentAccess(t *testing.T) { + l10n := NewL10n("en") + l10n.AddDictEntry("base", DictEntry{"en": "Hello"}) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < 100; j++ { + l10n.AddDictEntry(fmt.Sprintf("key-%d-%d", i, j), DictEntry{"en": "value"}) + _ = l10n.Translate("en", "base") + } + }(i) + } + wg.Wait() + + if got := l10n.Translate("en", "base"); got != "Hello" { + t.Fatalf("unexpected translation after concurrent access: got %q", got) + } +} diff --git a/msg_context.go b/msg_context.go index 43e640c..d3e6cf3 100644 --- a/msg_context.go +++ b/msg_context.go @@ -46,8 +46,7 @@ type AnswerMessage struct { 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. +// 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 { params := tgapi.EditMessageTextP{ Text: text, @@ -94,8 +93,7 @@ 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. +// 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("Can't edit non-callback update message") @@ -128,8 +126,7 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb 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. +// Internal helper for media-caption edits. func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { params := tgapi.EditMessageCaptionP{ Caption: text, @@ -187,8 +184,7 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo 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. +// 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("Can't answer message without a message") @@ -255,7 +251,7 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) * return ctx.answer(text, keyboard, tgapi.ParseMDV2) } -// answerPhoto sends a photo with optional caption and keyboard. +// 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("Can't answer message without a message") @@ -323,7 +319,7 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) } -// delete removes a message by ID. +// Internal helper that deletes a message by ID. func (ctx *MsgContext) delete(messageId int) { if messageId == 0 { ctx.Logger.Errorln("Can't delete message: message ID zero") @@ -354,8 +350,7 @@ func (ctx *MsgContext) CallbackDelete() { ctx.delete(ctx.CallbackMsgId) } -// answerCallbackQuery sends a response to a callback query (optional text/alert/url). -// Does nothing if CallbackQueryId is empty. +// 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 @@ -399,10 +394,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { } } -// 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. +// Internal helper that formats, sends, and logs an error. func (ctx *MsgContext) error(err error) { text := fmt.Sprintf(ctx.errorTemplate, err.Error()) diff --git a/plugins.go b/plugins.go index 793ef23..627ba03 100644 --- a/plugins.go +++ b/plugins.go @@ -5,10 +5,12 @@ import ( "regexp" "git.nix13.pw/scuroneko/extypes" + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/laniakea/utils" "git.nix13.pw/scuroneko/slog" ) -// CommandValueType defines the expected type of a command argument. +// CommandValueType defines the expected type of command argument. type CommandValueType string const ( @@ -50,12 +52,12 @@ type CommandArg struct { // NewCommandArg creates a new CommandArg with the given text and type. // Uses a default regex based on the type (string or int). // For CommandValueAnyType, no validation is performed. -func NewCommandArg(text string) *CommandArg { - return &CommandArg{CommandValueAnyType, text, CommandRegexString, false} +func NewCommandArg(text string) CommandArg { + return CommandArg{CommandValueAnyType, text, CommandRegexString, false} } // SetValueType sets expected value type and switches built-in validation regexp. -func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { +func (c CommandArg) SetValueType(t CommandValueType) CommandArg { regex := CommandRegexString switch t { case CommandValueIntType: @@ -72,14 +74,14 @@ func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { // SetRequired marks this argument as required. // Returns the receiver for method chaining. -func (c *CommandArg) SetRequired() *CommandArg { +func (c CommandArg) SetRequired() CommandArg { c.required = true return c } // CommandExecutor is the function type that executes a command. // It receives the message context and a database context (generic). -type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext *T) +type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) // Command represents a bot command with arguments, description, and executor. // Can be registered in a Plugin and optionally skipped from auto-generation. @@ -123,9 +125,7 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] { return c } -// validateArgs checks if the provided arguments match the command's requirements. -// Returns ErrCmdArgCountMismatch if too few arguments are provided. -// Returns ErrCmdArgRegexpMismatch if any argument fails regex validation. +// Internal helper that validates provided command arguments. func (c *Command[T]) validateArgs(args []string) error { for i := range c.args.Len() { if i >= len(args) && c.args.Get(i).required { @@ -164,6 +164,8 @@ type Plugin[T DbContext] struct { skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help logger *slog.Logger + handlers map[tgapi.UpdateType]CommandExecutor[T] + onClose func() error } @@ -176,6 +178,7 @@ func NewPlugin[T DbContext](name string) *Plugin[T] { middlewares: make(extypes.Slice[Middleware[T]], 0), skipAutoCmd: false, logger: nil, + handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), } } @@ -209,6 +212,24 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ... return cmd } +// AddUpdateHandler registers a handler for a non-command update type. +// Message, channel post, and callback query updates stay on the command/payload flow. +func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] { + switch t { + case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery: + if p.logger == nil { + logger := utils.CreateLogger(p.name, utils.GetLoggerLevel()) + logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t) + _ = logger.Close() + return p + } + p.logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t) + return p + } + p.handlers[t] = handler + return p +} + // AddMiddleware adds a middleware to the plugin's global middleware chain. // Middlewares are executed before any command or payload. func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] { @@ -267,10 +288,8 @@ func (p *Plugin[T]) Close() error { return errors.Join(e...) } -// executeCmd finds and executes a command by its trigger string. -// Validates arguments and runs middlewares before executor. -// On error, sends an error message to the user via ctx.error(). -func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { +// Internal helper that validates and executes a command handler. +func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) { command, exists := p.commands[cmd] if !exists { ctx.error(errors.New("command not found")) @@ -284,19 +303,17 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { // Run command-specific middlewares for _, m := range command.middlewares { - if !m.Execute(ctx, dbContext) { + if !m.Execute(ctx, db) { return } } // Execute command - command.exec(ctx, dbContext) + command.exec(ctx, db) } -// executePayload finds and executes a payload by its callback_data string. -// Validates arguments and runs middlewares before executor. -// On error, sends an error message to the user via ctx.error(). -func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) { +// Internal helper that validates and executes a payload handler. +func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) { command, exists := p.payloads[payload] if !exists { ctx.error(errors.New("payload not found")) @@ -310,18 +327,17 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T // Run command-specific middlewares for _, m := range command.middlewares { - if !m.Execute(ctx, dbContext) { + if !m.Execute(ctx, db) { return } } // Execute payload - command.exec(ctx, dbContext) + command.exec(ctx, db) } -// executeMiddlewares runs all plugin middlewares in order. -// Returns false if any middleware returns false (blocks execution). -func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { +// Internal helper that runs plugin middlewares in order. +func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool { for _, m := range p.middlewares { if !m.Execute(ctx, db) { return false @@ -333,7 +349,7 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { // MiddlewareExecutor is the function type for middleware logic. // Returns true to continue execution, false to block it. // If async, return value is ignored. -type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db *T) bool +type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db T) bool // Middleware represents a reusable execution interceptor. // Can be synchronous (blocking) or asynchronous (non-blocking). @@ -345,19 +361,19 @@ type Middleware[T DbContext] struct { } // NewMiddleware creates a new synchronous middleware. -func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] { - return &Middleware[T]{name, executor, 0, false} +func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) Middleware[T] { + return Middleware[T]{name, executor, 0, false} } // SetOrder sets the execution order (currently ignored). -func (m *Middleware[T]) SetOrder(order int) *Middleware[T] { +func (m Middleware[T]) SetOrder(order int) Middleware[T] { m.order = order return m } // SetAsync marks the middleware to run asynchronously. // Execution continues regardless of its return value. -func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { +func (m Middleware[T]) SetAsync(async bool) Middleware[T] { m.async = async return m } @@ -365,7 +381,7 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { // Execute runs the middleware. // If async, runs in a goroutine and returns true immediately. // Otherwise, returns the result of the executor. -func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool { +func (m Middleware[T]) Execute(ctx *MsgContext, db T) bool { if m.async { ctx := *ctx // copy context to avoid race condition go func(ctx MsgContext) { diff --git a/plugins_test.go b/plugins_test.go index 7c7507d..8b95147 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -6,7 +6,7 @@ import ( ) func TestValidateArgsRequiresFullMatch(t *testing.T) { - intCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "int", *NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) + intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) if err := intCmd.validateArgs([]string{"123"}); err != nil { t.Fatalf("expected valid integer argument, got %v", err) } @@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) } - boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "bool", *NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) + boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) if err := boolCmd.validateArgs([]string{"false"}); err != nil { t.Fatalf("expected valid bool argument, got %v", err) } @@ -25,10 +25,10 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { cmd := NewCommand[NoDB]( - func(ctx *MsgContext, db *NoDB) {}, + func(ctx *MsgContext, db NoDB) {}, "mixed", - *NewCommandArg("optional"), - *NewCommandArg("required").SetRequired(), + NewCommandArg("optional"), + NewCommandArg("required").SetRequired(), ) if err := cmd.validateArgs([]string{"only-optional"}); !errors.Is(err, ErrCmdArgCountMismatch) { diff --git a/runners.go b/runners.go index d64d3ab..dd55246 100644 --- a/runners.go +++ b/runners.go @@ -33,8 +33,8 @@ type Runner[T DbContext] struct { // // Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. // DO NOT call builder methods concurrently or after Execute(). -func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { - return &Runner[T]{ +func NewRunner[T DbContext](name string, fn RunnerFn[T]) Runner[T] { + return Runner[T]{ name: name, fn: fn, async: true, // Default: run asynchronously @@ -45,7 +45,7 @@ func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { // Onetime sets whether the runner executes once or repeatedly. // If true, the runner runs only once. // If false, the runner runs in a loop with the configured timeout. -func (r *Runner[T]) Onetime(onetime bool) *Runner[T] { +func (r Runner[T]) Onetime(onetime bool) Runner[T] { r.onetime = onetime return r } @@ -55,7 +55,7 @@ func (r *Runner[T]) Onetime(onetime bool) *Runner[T] { // If false, the runner blocks the caller during execution. // // Note: If onetime=false and async=false, the runner will be skipped with a warning. -func (r *Runner[T]) Async(async bool) *Runner[T] { +func (r Runner[T]) Async(async bool) Runner[T] { r.async = async return r } @@ -69,7 +69,7 @@ func (r *Runner[T]) Async(async bool) *Runner[T] { // // A zero value (time.Duration(0)) is allowed but may trigger a warning // if used with a background (non-onetime) async runner. -func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { +func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] { r.timeout = timeout return r } diff --git a/runners_test.go b/runners_test.go new file mode 100644 index 0000000..7e17d77 --- /dev/null +++ b/runners_test.go @@ -0,0 +1,62 @@ +package laniakea + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "git.nix13.pw/scuroneko/slog" +) + +func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { + var calls atomic.Int32 + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + runners: []Runner[NoDB]{ + NewRunner("sync-once", func(*Bot[NoDB]) error { + calls.Add(1) + return nil + }).Onetime(true).Async(false), + }, + } + + bot.ExecRunners(context.Background()) + + if got := calls.Load(); got != 1 { + t.Fatalf("unexpected sync runner call count: %d", got) + } +} + +func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) { + var calls atomic.Int32 + triggered := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + runners: []Runner[NoDB]{ + NewRunner("background", func(*Bot[NoDB]) error { + if calls.Add(1) == 1 { + triggered <- struct{}{} + } + return nil + }).Timeout(5 * time.Millisecond), + }, + } + + bot.ExecRunners(ctx) + + select { + case <-triggered: + case <-time.After(time.Second): + t.Fatal("background runner did not execute") + } + + cancel() + bot.runnerBgWG.Wait() + + if calls.Load() == 0 { + t.Fatal("expected background runner to be called at least once") + } +} diff --git a/tgapi/api.go b/tgapi/api.go index c251218..7a22dcb 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -124,6 +124,9 @@ func NewAPI(opts *APIOpts) *API { // See https://core.telegram.org/bots/api func (api *API) Close() error { api.pool.stop() + if api.client != nil { + api.client.CloseIdleConnections() + } return api.logger.Close() } @@ -149,37 +152,29 @@ type ApiResponse[R any] struct { Parameters *ResponseParameters `json:"parameters,omitempty"` } -// TelegramRequest is an internal helper struct. -// DO NOT USE NewRequest or NewRequestWithChatID — they are unsafe and discouraged. -// Instead, use explicit methods like SendMessage, GetUpdates, etc. +// TelegramRequest is a low-level Telegram API request wrapper. // -// Why? Because using generics with arbitrary types P and R leads to: -// - No compile-time validation of parameters -// - No IDE autocompletion -// - Runtime panics on malformed JSON -// - Hard-to-debug errors -// -// Recommended: Define specific methods for each Telegram method (see below). +// Prefer method-specific helpers such as SendMessage or GetUpdates. TelegramRequest +// bypasses method-specific parameter types and convenience helpers, so callers are +// responsible for using the correct method name and compatible request and response types. +// In that sense it is an unsafe escape hatch compared with the typed API surface. type TelegramRequest[R, P any] struct { method string params P chatId int64 } -// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID. +// NewRequest creates a low-level TelegramRequest with no associated chat ID. func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { return TelegramRequest[R, P]{method, params, 0} } -// NewRequestWithChatID creates an untyped TelegramRequest with an associated chat ID. +// NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID. // The chat ID is used for per-chat rate limiting. func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { return TelegramRequest[R, P]{method, params, chatId} } -// doRequest performs a single HTTP request to Telegram API. -// Handles rate limiting, retries on 429, and parses responses. -// Must be called within a worker pool context if using DoWithContext. func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { var zero R reqData, err := json.Marshal(r.params) @@ -296,15 +291,13 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) { return r.DoWithContext(context.Background(), api) } -// readBody reads and limits response body to prevent memory exhaustion. -// Telegram responses are typically small (<1MB), but we cap at 10MB. +// Internal helper that reads and caps a Telegram response body. func readBody(body io.ReadCloser) ([]byte, error) { reader := io.LimitReader(body, 10<<20) // 10 MB return io.ReadAll(reader) } -// parseBody unmarshals a Telegram API response into a typed ApiResponse. -// Only returns an error on malformed JSON; non-OK responses are left for the caller to handle. +// Internal helper that parses a typed Telegram API response body. func parseBody[R any](data []byte) (ApiResponse[R], error) { var resp ApiResponse[R] err := json.Unmarshal(data, &resp) diff --git a/tgapi/api_test.go b/tgapi/api_test.go index 5dc4491..5873223 100644 --- a/tgapi/api_test.go +++ b/tgapi/api_test.go @@ -13,6 +13,15 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } +type closingTransport struct { + roundTripFunc + closed bool +} + +func (t *closingTransport) CloseIdleConnections() { + t.closed = true +} + func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { var gotPath string var gotAcceptEncoding string @@ -54,3 +63,28 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding) } } + +func TestAPICloseClosesIdleConnections(t *testing.T) { + transport := &closingTransport{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test"}}`)), + }, nil + }, + } + + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(&http.Client{Transport: transport}), + ) + + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + if !transport.closed { + t.Fatal("expected Close to close idle HTTP connections") + } +} diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 078d1d0..790cf1a 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -241,7 +241,7 @@ type SendVoiceP struct { // SendVoice sends a voice note. // See https://core.telegram.org/bots/api#sendvoice -func (api *API) SendVoice(params *SendVoiceP) (Message, error) { +func (api *API) SendVoice(params SendVoiceP) (Message, error) { req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) return req.Do(api) } @@ -249,7 +249,7 @@ func (api *API) SendVoice(params *SendVoiceP) (Message, error) { // SendVoiceWithContext is the context-aware variant of SendVoice. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvoice -func (api *API) SendVoiceWithContext(ctx context.Context, params *SendVoiceP) (Message, error) { +func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoiceP) (Message, error) { req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) return req.DoWithContext(ctx, api) } diff --git a/tgapi/attachments_types.go b/tgapi/attachments_types.go index b0d2a89..c442a15 100644 --- a/tgapi/attachments_types.go +++ b/tgapi/attachments_types.go @@ -55,12 +55,12 @@ type InputPaidMedia struct { Type InputPaidMediaType `json:"type"` Media string `json:"media"` - Cover string `json:"cover"` - StartTimestamp int64 `json:"start_timestamp"` - Width int `json:"width"` - Height int `json:"height"` - Duration int `json:"duration"` - SupportsStreaming bool `json:"supports_streaming"` + Cover *string `json:"cover,omitempty"` + StartTimestamp *int64 `json:"start_timestamp,omitempty"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` + Duration *int `json:"duration,omitempty"` + SupportsStreaming *bool `json:"supports_streaming,omitempty"` } // PhotoSize represents one size of a photo or a file/sticker thumbnail. diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index 8531613..a22afd2 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -267,21 +267,21 @@ func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChat // GetChatMenuButtonP holds parameters for the getChatMenuButton method. // See https://core.telegram.org/bots/api#getchatmenubutton type GetChatMenuButtonP struct { - ChatID int64 `json:"chat_id"` + ChatID int64 `json:"chat_id,omitempty"` } // GetChatMenuButton returns the current menu button for the given chat. // See https://core.telegram.org/bots/api#getchatmenubutton -func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) { - req := NewRequest[BaseMenuButton]("getChatMenuButton", params) +func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (MenuButton, error) { + req := NewRequest[MenuButton]("getChatMenuButton", params) return req.Do(api) } // GetChatMenuButtonWithContext is the context-aware variant of GetChatMenuButton. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatmenubutton -func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (BaseMenuButton, error) { - req := NewRequest[BaseMenuButton]("getChatMenuButton", params) +func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (MenuButton, error) { + req := NewRequest[MenuButton]("getChatMenuButton", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/bot_types.go b/tgapi/bot_types.go index de660ee..fefd834 100644 --- a/tgapi/bot_types.go +++ b/tgapi/bot_types.go @@ -54,7 +54,9 @@ type BotShortDescription struct { type InputProfilePhotoType string const ( - InputProfilePhotoStaticType InputProfilePhotoType = "static" + // InputProfilePhotoStaticType identifies a static profile photo input. + InputProfilePhotoStaticType InputProfilePhotoType = "static" + // InputProfilePhotoAnimatedType identifies an animated profile photo input. InputProfilePhotoAnimatedType InputProfilePhotoType = "animated" ) @@ -75,17 +77,20 @@ type InputProfilePhoto struct { type MenuButtonType string const ( + // MenuButtonCommandsType identifies a commands menu button. MenuButtonCommandsType MenuButtonType = "commands" - MenuButtonWebAppType MenuButtonType = "web_app" - MenuButtonDefaultType MenuButtonType = "default" + // MenuButtonWebAppType identifies a web app menu button. + MenuButtonWebAppType MenuButtonType = "web_app" + // MenuButtonDefaultType identifies Telegram's default menu button. + MenuButtonDefaultType MenuButtonType = "default" ) -// BaseMenuButton represents a menu button. +// MenuButton represents a menu button. // See https://core.telegram.org/bots/api#menubutton -type BaseMenuButton struct { +type MenuButton struct { Type MenuButtonType `json:"type"` // WebApp fields (for web_app button) - Text string `json:"text"` - WebApp WebAppInfo `json:"web_app"` + Text *string `json:"text"` + WebApp *WebAppInfo `json:"web_app"` } diff --git a/tgapi/business_types.go b/tgapi/business_types.go index 56a5f9f..e667413 100644 --- a/tgapi/business_types.go +++ b/tgapi/business_types.go @@ -72,7 +72,9 @@ type BusinessMessagesDeleted struct { type InputStoryContentType string const ( + // InputStoryContentPhotoType identifies photo story content. InputStoryContentPhotoType InputStoryContentType = "photo" + // InputStoryContentVideoType identifies video story content. InputStoryContentVideoType InputStoryContentType = "video" ) @@ -106,10 +108,15 @@ type StoryAreaPosition struct { type StoryAreaTypeType string const ( - StoryAreaTypeLocationType StoryAreaTypeType = "location" - StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction" - StoryAreaTypeLinkType StoryAreaTypeType = "link" - StoryAreaTypeWeatherType StoryAreaTypeType = "weather" + // StoryAreaTypeLocationType identifies a location story area. + StoryAreaTypeLocationType StoryAreaTypeType = "location" + // StoryAreaTypeReactionType identifies a suggested reaction story area. + StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction" + // StoryAreaTypeLinkType identifies a link story area. + StoryAreaTypeLinkType StoryAreaTypeType = "link" + // StoryAreaTypeWeatherType identifies a weather story area. + StoryAreaTypeWeatherType StoryAreaTypeType = "weather" + // StoryAreaTypeUniqueGiftType identifies a unique gift story area. StoryAreaTypeUniqueGiftType StoryAreaTypeType = "unique_gift" ) diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index a1d795b..2bdda56 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -17,10 +17,14 @@ type Chat struct { type ChatType string const ( - ChatTypePrivate ChatType = "private" - ChatTypeGroup ChatType = "group" + // ChatTypePrivate identifies a private chat. + ChatTypePrivate ChatType = "private" + // ChatTypeGroup identifies a basic group chat. + ChatTypeGroup ChatType = "group" + // ChatTypeSupergroup identifies a supergroup chat. ChatTypeSupergroup ChatType = "supergroup" - ChatTypeChannel ChatType = "channel" + // ChatTypeChannel identifies a channel chat. + ChatTypeChannel ChatType = "channel" ) // ChatFullInfo contains full information about a chat. @@ -143,12 +147,18 @@ type ChatInviteLink struct { type ChatMemberStatusType string const ( - ChatMemberStatusOwner ChatMemberStatusType = "owner" + // ChatMemberStatusOwner identifies a chat owner. + ChatMemberStatusOwner ChatMemberStatusType = "owner" + // ChatMemberStatusAdministrator identifies a chat administrator. ChatMemberStatusAdministrator ChatMemberStatusType = "administrator" - ChatMemberStatusMember ChatMemberStatusType = "member" - ChatMemberStatusRestricted ChatMemberStatusType = "restricted" - ChatMemberStatusLeft ChatMemberStatusType = "left" - ChatMemberStatusBanned ChatMemberStatusType = "kicked" + // ChatMemberStatusMember identifies a regular member. + ChatMemberStatusMember ChatMemberStatusType = "member" + // ChatMemberStatusRestricted identifies a restricted member. + ChatMemberStatusRestricted ChatMemberStatusType = "restricted" + // ChatMemberStatusLeft identifies a user who left the chat. + ChatMemberStatusLeft ChatMemberStatusType = "left" + // ChatMemberStatusBanned identifies a banned user. + ChatMemberStatusBanned ChatMemberStatusType = "kicked" ) // ChatMember contains information about one member of a chat. diff --git a/tgapi/errors.go b/tgapi/errors.go index a7e83da..6c142ae 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -2,7 +2,14 @@ package tgapi import "errors" +// ErrRateLimit reports that a request exceeded the configured rate limiter. var ErrRateLimit = errors.New("rate limit exceeded") + +// ErrPoolUnexpected reports an unexpected result type returned from the worker pool. var ErrPoolUnexpected = errors.New("unexpected response from pool") + +// ErrPoolQueueFull reports that the internal request queue is full. var ErrPoolQueueFull = errors.New("worker pool queue full") + +// ErrPoolStopped reports that a request was submitted after the worker pool stopped. var ErrPoolStopped = errors.New("worker pool stopped") diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 6ef5fe9..237b156 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -45,9 +45,9 @@ type Message struct { Text string `json:"text"` - Photo extypes.Slice[*PhotoSize] `json:"photo,omitempty"` - Caption string `json:"caption,omitempty"` - CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` + Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"` + Caption string `json:"caption,omitempty"` + CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` Date int `json:"date"` EditDate int `json:"edit_date"` @@ -77,26 +77,46 @@ type MaybeInaccessibleMessage interface{ Message | InaccessibleMessage } type MessageEntityType string const ( - MessageEntityMention MessageEntityType = "mention" - MessageEntityHashtag MessageEntityType = "hashtag" - MessageEntityCashtag MessageEntityType = "cashtag" - MessageEntityBotCommand MessageEntityType = "bot_command" - MessageEntityUrl MessageEntityType = "url" - MessageEntityEmail MessageEntityType = "email" - MessageEntityPhoneNumber MessageEntityType = "phone_number" - MessageEntityBold MessageEntityType = "bold" - MessageEntityItalic MessageEntityType = "italic" - MessageEntityUnderline MessageEntityType = "underline" - MessageEntityStrike MessageEntityType = "strikethrough" - MessageEntitySpoiler MessageEntityType = "spoiler" - MessageEntityBlockquote MessageEntityType = "blockquote" + // MessageEntityMention identifies an @mention entity. + MessageEntityMention MessageEntityType = "mention" + // MessageEntityHashtag identifies a hashtag entity. + MessageEntityHashtag MessageEntityType = "hashtag" + // MessageEntityCashtag identifies a cashtag entity. + MessageEntityCashtag MessageEntityType = "cashtag" + // MessageEntityBotCommand identifies a bot command entity. + MessageEntityBotCommand MessageEntityType = "bot_command" + // MessageEntityUrl identifies a URL entity. + MessageEntityUrl MessageEntityType = "url" + // MessageEntityEmail identifies an email entity. + MessageEntityEmail MessageEntityType = "email" + // MessageEntityPhoneNumber identifies a phone number entity. + MessageEntityPhoneNumber MessageEntityType = "phone_number" + // MessageEntityBold identifies bold text. + MessageEntityBold MessageEntityType = "bold" + // MessageEntityItalic identifies italic text. + MessageEntityItalic MessageEntityType = "italic" + // MessageEntityUnderline identifies underlined text. + MessageEntityUnderline MessageEntityType = "underline" + // MessageEntityStrike identifies strikethrough text. + MessageEntityStrike MessageEntityType = "strikethrough" + // MessageEntitySpoiler identifies spoiler text. + MessageEntitySpoiler MessageEntityType = "spoiler" + // MessageEntityBlockquote identifies a blockquote entity. + MessageEntityBlockquote MessageEntityType = "blockquote" + // MessageEntityExpandableBlockquote identifies an expandable blockquote entity. MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote" - MessageEntityCode MessageEntityType = "code" - MessageEntityPre MessageEntityType = "pre" - MessageEntityTextLink MessageEntityType = "text_link" - MessageEntityTextMention MessageEntityType = "text_mention" - MessageEntityCustomEmoji MessageEntityType = "custom_emoji" - MessageEntityDateTime MessageEntityType = "date_time" + // MessageEntityCode identifies inline code. + MessageEntityCode MessageEntityType = "code" + // MessageEntityPre identifies a preformatted block. + MessageEntityPre MessageEntityType = "pre" + // MessageEntityTextLink identifies linked text. + MessageEntityTextLink MessageEntityType = "text_link" + // MessageEntityTextMention identifies a text mention. + MessageEntityTextMention MessageEntityType = "text_mention" + // MessageEntityCustomEmoji identifies a custom emoji entity. + MessageEntityCustomEmoji MessageEntityType = "custom_emoji" + // MessageEntityDateTime identifies a date-time entity. + MessageEntityDateTime MessageEntityType = "date_time" ) // MessageEntity represents one special entity in a text message. @@ -121,12 +141,12 @@ type ReplyParameters struct { MessageID int `json:"message_id"` ChatID int64 `json:"chat_id,omitempty"` - AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` - Quote string `json:"quote,omitempty"` - QuoteParsingMode string `json:"quote_parsing_mode,omitempty"` - QuoteEntities []*MessageEntity `json:"quote_entities,omitempty"` - QuotePosition int `json:"quote_position,omitempty"` - ChecklistTaskID int `json:"checklist_task_id,omitempty"` + AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` + Quote string `json:"quote,omitempty"` + QuoteParsingMode string `json:"quote_parsing_mode,omitempty"` + QuoteEntities []MessageEntity `json:"quote_entities,omitempty"` + QuotePosition int `json:"quote_position,omitempty"` + ChecklistTaskID int `json:"checklist_task_id,omitempty"` } // LinkPreviewOptions describes the options used for link preview generation. @@ -166,8 +186,11 @@ type InlineKeyboardMarkup struct { type KeyboardButtonStyle string const ( - KeyboardButtonStyleDanger KeyboardButtonStyle = "danger" + // KeyboardButtonStyleDanger marks a destructive keyboard button. + KeyboardButtonStyleDanger KeyboardButtonStyle = "danger" + // KeyboardButtonStyleSuccess marks a confirmatory keyboard button. KeyboardButtonStyleSuccess KeyboardButtonStyle = "success" + // KeyboardButtonStylePrimary marks a primary keyboard button. KeyboardButtonStylePrimary KeyboardButtonStyle = "primary" ) @@ -255,32 +278,34 @@ type CallbackQuery struct { // InputPollOption contains information about one answer option in a poll to be sent. // See https://core.telegram.org/bots/api#inputpolloption type InputPollOption struct { - Text string `json:"text"` - TextParseMode ParseMode `json:"text_parse_mode,omitempty"` - TextEntities []*MessageEntity `json:"text_entities,omitempty"` + Text string `json:"text"` + TextParseMode ParseMode `json:"text_parse_mode,omitempty"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` } // PollType represents the type of a poll. type PollType string const ( + // PollTypeRegular identifies a regular poll. PollTypeRegular PollType = "regular" - PollTypeQuiz PollType = "quiz" + // PollTypeQuiz identifies a quiz poll. + PollTypeQuiz PollType = "quiz" ) // InputChecklistTask describes a task in a checklist. type InputChecklistTask struct { - ID int `json:"id"` - Text string `json:"text"` - ParseMode ParseMode `json:"parse_mode,omitempty"` - TextEntities []*MessageEntity `json:"text_entities,omitempty"` + ID int `json:"id"` + Text string `json:"text"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` } // InputChecklist represents a checklist to be sent. type InputChecklist struct { Title string `json:"title"` ParseMode ParseMode `json:"parse_mode,omitempty"` - TitleEntities []*MessageEntity `json:"title_entities,omitempty"` + TitleEntities []MessageEntity `json:"title_entities,omitempty"` Tasks []InputChecklistTask `json:"tasks"` OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"` OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"` @@ -290,14 +315,23 @@ type InputChecklist struct { type ChatActionType string const ( - ChatActionTyping ChatActionType = "typing" - ChatActionUploadPhoto ChatActionType = "upload_photo" - ChatActionUploadVideo ChatActionType = "upload_video" - ChatActionUploadVoice ChatActionType = "upload_voice" - ChatActionUploadDocument ChatActionType = "upload_document" - ChatActionChooseSticker ChatActionType = "choose_sticker" - ChatActionFindLocation ChatActionType = "find_location" + // ChatActionTyping tells Telegram the bot is typing. + ChatActionTyping ChatActionType = "typing" + // ChatActionUploadPhoto tells Telegram the bot is uploading a photo. + ChatActionUploadPhoto ChatActionType = "upload_photo" + // ChatActionUploadVideo tells Telegram the bot is uploading a video. + ChatActionUploadVideo ChatActionType = "upload_video" + // ChatActionUploadVoice tells Telegram the bot is uploading a voice message. + ChatActionUploadVoice ChatActionType = "upload_voice" + // ChatActionUploadDocument tells Telegram the bot is uploading a document. + ChatActionUploadDocument ChatActionType = "upload_document" + // ChatActionChooseSticker tells Telegram the bot is choosing a sticker. + ChatActionChooseSticker ChatActionType = "choose_sticker" + // ChatActionFindLocation tells Telegram the bot is finding a location. + ChatActionFindLocation ChatActionType = "find_location" + // ChatActionUploadVideoNote tells Telegram the bot is uploading a video note. ChatActionUploadVideoNote ChatActionType = "upload_video_note" + // ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote. ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote ) diff --git a/tgapi/methods.go b/tgapi/methods.go index 948575d..d9789ae 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -170,6 +170,7 @@ func (api *API) GetFileWithContext(ctx context.Context, params GetFileP) (File, // GetFileByLink downloads a file from Telegram's file server using the provided file link. // The link is usually obtained from File.FilePath. +// For large files, prefer OpenFileByLink or OpenFileByLinkWithContext to stream the response body. // See https://core.telegram.org/bots/api#file func (api *API) GetFileByLink(link string) ([]byte, error) { return api.getFileByLink(context.Background(), link) @@ -177,12 +178,38 @@ func (api *API) GetFileByLink(link string) ([]byte, error) { // GetFileByLinkWithContext is the context-aware variant of GetFileByLink. // It executes the same request but uses ctx for cancellation and deadlines. +// For large files, prefer OpenFileByLinkWithContext to stream the response body. // See https://core.telegram.org/bots/api#file func (api *API) GetFileByLinkWithContext(ctx context.Context, link string) ([]byte, error) { return api.getFileByLink(ctx, link) } +// OpenFileByLink opens a streaming response body for a file hosted on Telegram's file server. +// The caller must close the returned ReadCloser. +// See https://core.telegram.org/bots/api#file +func (api *API) OpenFileByLink(link string) (io.ReadCloser, error) { + return api.openFileByLink(context.Background(), link) +} + +// OpenFileByLinkWithContext is the context-aware variant of OpenFileByLink. +// The caller must close the returned ReadCloser. +// See https://core.telegram.org/bots/api#file +func (api *API) OpenFileByLinkWithContext(ctx context.Context, link string) (io.ReadCloser, error) { + return api.openFileByLink(ctx, link) +} + func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error) { + body, err := api.openFileByLink(ctx, link) + if err != nil { + return nil, err + } + defer func() { + _ = body.Close() + }() + return io.ReadAll(body) +} + +func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser, error) { methodPrefix := "" if api.useTestServer { methodPrefix = "/test" @@ -199,15 +226,15 @@ func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error) if err != nil { return nil, err } - defer func() { - _ = res.Body.Close() - }() if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + defer func() { + _ = res.Body.Close() + }() body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10)) if readErr != nil { return nil, fmt.Errorf("unexpected status %d", res.StatusCode) } return nil, fmt.Errorf("unexpected status %d: %s", res.StatusCode, string(body)) } - return io.ReadAll(res.Body) + return res.Body, nil } diff --git a/tgapi/methods_test.go b/tgapi/methods_test.go index 86f1aba..dce8c98 100644 --- a/tgapi/methods_test.go +++ b/tgapi/methods_test.go @@ -44,6 +44,44 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) { } } +func TestOpenFileByLinkStreamsResponseBody(t *testing.T) { + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("streamed payload")), + }, nil + }), + }), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + body, err := api.OpenFileByLink("files/report.txt") + if err != nil { + t.Fatalf("OpenFileByLink returned error: %v", err) + } + defer func() { + if err := body.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + data, err := io.ReadAll(body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + if string(data) != "streamed payload" { + t.Fatalf("unexpected payload: %q", string(data)) + } +} + func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) { client := &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { diff --git a/tgapi/pool.go b/tgapi/pool.go index 8c15fa4..3078eb4 100644 --- a/tgapi/pool.go +++ b/tgapi/pool.go @@ -5,44 +5,35 @@ import ( "sync" ) -// workerPool — приватная структура, управляющая пулом воркеров. -// Внешний код не может создавать или напрямую взаимодействовать с этой структурой. -// Используется только через экспортируемые методы newWorkerPool, start, stop, submit. type workerPool struct { - taskCh chan requestEnvelope // канал для принятия задач (буферизованный) - queueSize int // максимальный размер очереди - workers int // количество воркеров (горутин) - wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке - quit chan struct{} // канал для сигнала остановки - stopOnce sync.Once // гарантирует идемпотентную остановку пула - started bool // флаг, указывающий, запущен ли пул - stopped bool // флаг, указывающий, что пул остановлен - startedMu sync.Mutex // мьютекс для безопасного доступа к started + taskCh chan requestEnvelope + queueSize int + workers int + wg sync.WaitGroup + quit chan struct{} + stopOnce sync.Once + started bool + stopped bool + startedMu sync.Mutex } -// requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата. -// Используется только внутри пакета для передачи задач воркерам. type requestEnvelope struct { - ctx context.Context // контекст конкретной задачи - doFunc func(context.Context) (any, error) // функция, выполняющая запрос - resultCh chan requestResult // канал, через который воркер вернёт результат + ctx context.Context + doFunc func(context.Context) (any, error) + resultCh chan requestResult } -// requestResult — приватная структура, представляющая результат выполнения задачи. -// Внешний код получает его через канал, но не знает структуры — только через <-chan requestResult. type requestResult struct { - value any // значение, возвращённое задачей - err error // ошибка, если возникла + value any + err error } -// newWorkerPool создаёт новый пул воркеров с заданным количеством горутин и размером очереди. -// Это единственный способ создать workerPool — внешний код не может создать его напрямую. func newWorkerPool(workers int, queueSize int) *workerPool { if workers <= 0 { - workers = 1 // защита от некорректных значений + workers = 1 } if queueSize <= 0 { - queueSize = 100 // разумный дефолт + queueSize = 100 } return &workerPool{ @@ -53,43 +44,32 @@ func newWorkerPool(workers int, queueSize int) *workerPool { } } -// start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди. -// Метод идемпотентен: если пул уже запущен — ничего не делает. -// Должен вызываться перед первым вызовом submit. func (p *workerPool) start() { p.startedMu.Lock() defer p.startedMu.Unlock() if p.started { - return // уже запущен — ничего не делаем + return } p.started = true - // Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле for i := 0; i < p.workers; i++ { p.wg.Add(1) - go p.worker() // запускаем горутину + go p.worker() } } -// stop останавливает пул воркеров. -// Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач. -// Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта. func (p *workerPool) stop() { p.stopOnce.Do(func() { p.startedMu.Lock() p.stopped = true p.started = false - close(p.quit) // сигнал для всех воркеров — выйти из цикла + close(p.quit) p.startedMu.Unlock() - p.wg.Wait() // ждём, пока все воркеры завершатся + p.wg.Wait() }) } -// submit отправляет задачу в очередь и возвращает канал, через который будет получен результат. -// Если очередь переполнена — возвращает ErrPoolQueueFull. -// Канал результата имеет буфер 1, чтобы не блокировать воркера при записи. -// Контекст используется для отмены задачи, если клиент отменил запрос до отправки. func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) { p.startedMu.Lock() if p.stopped || !p.started { @@ -97,55 +77,39 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, return nil, ErrPoolStopped } - // Проверяем, не превышена ли очередь if len(p.taskCh) >= p.queueSize { p.startedMu.Unlock() return nil, ErrPoolQueueFull } - // Создаём канал для результата — буферизованный, чтобы не блокировать воркера resultCh := make(chan requestResult, 1) - // Создаём обёртку задачи envelope := requestEnvelope{ ctx: ctx, doFunc: do, resultCh: resultCh, } - // Пытаемся отправить задачу в очередь select { case <-ctx.Done(): p.startedMu.Unlock() - // Клиент отменил операцию до отправки — возвращаем ошибку отмены return nil, ctx.Err() case p.taskCh <- envelope: p.startedMu.Unlock() - // Успешно отправлено — возвращаем канал для чтения результата return resultCh, nil default: p.startedMu.Unlock() - // Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай return nil, ErrPoolQueueFull } } -// worker — приватная горутина, выполняющая задачи из очереди. -// Каждый воркер работает в бесконечном цикле, пока не получит сигнал остановки. -// При получении задачи: -// - вызывает doFunc с контекстом -// - записывает результат в resultCh -// - закрывает канал, чтобы клиент мог прочитать и завершить -// -// После закрытия quit-канала — воркер завершает работу. func (p *workerPool) worker() { - defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины + defer p.wg.Done() for { select { case <-p.quit: - // Получен сигнал остановки — дренируем очередь и выходим. - // После stop() новые задачи не принимаются. + // Drain queued work after stop. No new tasks are accepted. for { select { case envelope := <-p.taskCh: @@ -162,14 +126,10 @@ func (p *workerPool) worker() { } func (p *workerPool) executeEnvelope(envelope requestEnvelope) { - // Выполняем задачу с переданным контекстом (клиентский или общий) value, err := envelope.doFunc(envelope.ctx) - - // Записываем результат в канал — не блокируем, т.к. буфер 1 envelope.resultCh <- requestResult{ value: value, err: err, } - // Закрываем канал — клиент знает, что результат пришёл и больше не будет close(envelope.resultCh) } diff --git a/tgapi/pool_test.go b/tgapi/pool_test.go new file mode 100644 index 0000000..130a8f5 --- /dev/null +++ b/tgapi/pool_test.go @@ -0,0 +1,62 @@ +package tgapi + +import ( + "context" + "errors" + "testing" +) + +func TestWorkerPoolSubmitAfterStop(t *testing.T) { + pool := newWorkerPool(1, 1) + pool.start() + pool.stop() + + if _, err := pool.submit(context.Background(), func(context.Context) (any, error) { + return nil, nil + }); !errors.Is(err, ErrPoolStopped) { + t.Fatalf("expected ErrPoolStopped, got %v", err) + } +} + +func TestWorkerPoolQueueFull(t *testing.T) { + pool := newWorkerPool(1, 1) + pool.start() + defer pool.stop() + + started := make(chan struct{}) + release := make(chan struct{}) + + firstResult, err := pool.submit(context.Background(), func(context.Context) (any, error) { + close(started) + <-release + return "first", nil + }) + if err != nil { + t.Fatalf("first submit returned error: %v", err) + } + <-started + + secondResult, err := pool.submit(context.Background(), func(context.Context) (any, error) { + return "second", nil + }) + if err != nil { + t.Fatalf("second submit returned error: %v", err) + } + + if _, err := pool.submit(context.Background(), func(context.Context) (any, error) { + return "third", nil + }); !errors.Is(err, ErrPoolQueueFull) { + t.Fatalf("expected ErrPoolQueueFull, got %v", err) + } + + close(release) + + first := <-firstResult + if first.err != nil || first.value != "first" { + t.Fatalf("unexpected first result: %+v", first) + } + second := <-secondResult + if second.err != nil || second.value != "second" { + t.Fatalf("unexpected second result: %+v", second) + } +} diff --git a/tgapi/types.go b/tgapi/types.go index d96632b..ba3bd22 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -6,6 +6,9 @@ import "encoding/json" type UpdateType string const ( + // UpdateTypeUnknown marks an update whose payload does not match a known Telegram update kind. + UpdateTypeUnknown UpdateType = "unknown" + // UpdateTypeMessage is a regular message update. UpdateTypeMessage UpdateType = "message" // UpdateTypeEditedMessage is an edited message update. @@ -27,8 +30,6 @@ const ( UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message" // UpdateTypeDeletedBusinessMessages is a deleted business messages update. UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages" - // UpdateTypeDeletedBusinessMessage is kept as a backward-compatible alias. - UpdateTypeDeletedBusinessMessage UpdateType = UpdateTypeDeletedBusinessMessages // UpdateTypeInlineQuery is an inline query update. UpdateTypeInlineQuery UpdateType = "inline_query" @@ -61,6 +62,8 @@ const ( // Update represents an incoming update from Telegram. // See https://core.telegram.org/bots/api#update type Update struct { + Type UpdateType `json:"-"` + UpdateID int `json:"update_id"` Message *Message `json:"message,omitempty"` EditedMessage *Message `json:"edited_message,omitempty"` @@ -71,7 +74,6 @@ type Update struct { BusinessMessage *Message `json:"business_message,omitempty"` EditedBusinessMessage *Message `json:"edited_business_message,omitempty"` DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"` - DeletedBusinessMessage *BusinessMessagesDeleted `json:"-"` MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` @@ -91,33 +93,72 @@ type Update struct { RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` } -func (u *Update) syncDeletedBusinessMessages() { - if u.DeletedBusinessMessages != nil { - u.DeletedBusinessMessage = u.DeletedBusinessMessages - return - } - if u.DeletedBusinessMessage != nil { - u.DeletedBusinessMessages = u.DeletedBusinessMessage - } -} - -// UnmarshalJSON keeps the deprecated DeletedBusinessMessage alias in sync. +// UnmarshalJSON decodes an update and derives its Type from the populated payload field. func (u *Update) UnmarshalJSON(data []byte) error { - type alias Update - var aux alias + type Alias Update + + var aux Alias if err := json.Unmarshal(data, &aux); err != nil { return err } - *u = Update(aux) - u.syncDeletedBusinessMessages() - return nil -} -// MarshalJSON emits the canonical deleted_business_messages field. -func (u Update) MarshalJSON() ([]byte, error) { - u.syncDeletedBusinessMessages() - type alias Update - return json.Marshal(alias(u)) + *u = Update(aux) + + switch { + case u.Message != nil: + u.Type = UpdateTypeMessage + case u.EditedMessage != nil: + u.Type = UpdateTypeEditedMessage + case u.ChannelPost != nil: + u.Type = UpdateTypeChannelPost + case u.EditedChannelPost != nil: + u.Type = UpdateTypeEditedChannelPost + + case u.BusinessConnection != nil: + u.Type = UpdateTypeBusinessConnection + case u.BusinessMessage != nil: + u.Type = UpdateTypeBusinessMessage + case u.EditedBusinessMessage != nil: + u.Type = UpdateTypeEditedBusinessMessage + case u.DeletedBusinessMessages != nil: + u.Type = UpdateTypeDeletedBusinessMessages + case u.MessageReaction != nil: + u.Type = UpdateTypeMessageReaction + case u.MessageReactionCount != nil: + u.Type = UpdateTypeMessageReactionCount + + case u.InlineQuery != nil: + u.Type = UpdateTypeInlineQuery + case u.ChosenInlineResult != nil: + u.Type = UpdateTypeChosenInlineResult + case u.CallbackQuery != nil: + u.Type = UpdateTypeCallbackQuery + case u.ShippingQuery != nil: + u.Type = UpdateTypeShippingQuery + case u.PreCheckoutQuery != nil: + u.Type = UpdateTypePreCheckoutQuery + case u.PurchasedPaidMedia != nil: + u.Type = UpdateTypePurchasedPaidMedia + + case u.Poll != nil: + u.Type = UpdateTypePoll + case u.PollAnswer != nil: + u.Type = UpdateTypePollAnswer + case u.MyChatMember != nil: + u.Type = UpdateTypeMyChatMember + case u.ChatMember != nil: + u.Type = UpdateTypeChatMember + case u.ChatJoinRequest != nil: + u.Type = UpdateTypeChatJoinRequest + case u.ChatBoost != nil: + u.Type = UpdateTypeChatBoost + case u.RemovedChatBoost != nil: + u.Type = UpdateTypeRemovedChatBoost + default: + u.Type = UpdateTypeUnknown + } + + return nil } // InlineQuery represents an incoming inline query. @@ -351,19 +392,19 @@ type GiftBackground struct { // Gift represents a gift that can be sent. type Gift struct { - ID string `json:"id"` - Sticker Sticker `json:"sticker"` - StarCount int `json:"star_count"` - UpdateStarCount *int `json:"update_star_count,omitempty"` - IsPremium *bool `json:"is_premium,omitempty"` - HasColors *bool `json:"has_colors,omitempty"` - TotalCount *int `json:"total_count,omitempty"` - RemainingCount *int `json:"remaining_count,omitempty"` - PersonalTotalCount *int `json:"personal_total_count,omitempty"` - PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"` - Background GiftBackground `json:"background,omitempty"` - UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"` - PublisherChat *Chat `json:"publisher_chat,omitempty"` + ID string `json:"id"` + Sticker Sticker `json:"sticker"` + StarCount int `json:"star_count"` + UpdateStarCount *int `json:"update_star_count,omitempty"` + IsPremium *bool `json:"is_premium,omitempty"` + HasColors *bool `json:"has_colors,omitempty"` + TotalCount *int `json:"total_count,omitempty"` + RemainingCount *int `json:"remaining_count,omitempty"` + PersonalTotalCount *int `json:"personal_total_count,omitempty"` + PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"` + Background *GiftBackground `json:"background,omitempty"` + UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"` + PublisherChat *Chat `json:"publisher_chat,omitempty"` } // Gifts represents a list of gifts. @@ -375,8 +416,10 @@ type Gifts struct { type OwnedGiftType string const ( + // OwnedGiftRegularType identifies a regular owned gift. OwnedGiftRegularType OwnedGiftType = "regular" - OwnedGiftUniqueType OwnedGiftType = "unique" + // OwnedGiftUniqueType identifies a unique owned gift. + OwnedGiftUniqueType OwnedGiftType = "unique" ) // OwnedGift represents a gift owned by a user or chat. @@ -388,7 +431,7 @@ type OwnedGift struct { // Fields specific to "regular" type Gift Gift `json:"gift"` - SenderUser User `json:"sender_user,omitempty"` + SenderUser *User `json:"sender_user,omitempty"` Text string `json:"text,omitempty"` Entities []MessageEntity `json:"entities,omitempty"` IsPrivate *bool `json:"is_private,omitempty"` diff --git a/tgapi/types_test.go b/tgapi/types_test.go index 5b18efc..bbdada5 100644 --- a/tgapi/types_test.go +++ b/tgapi/types_test.go @@ -6,41 +6,66 @@ import ( "testing" ) -func TestUpdateDeletedBusinessMessagesUnmarshalSetsAlias(t *testing.T) { - var update Update - err := json.Unmarshal([]byte(`{ - "update_id": 1, - "deleted_business_messages": { - "business_connection_id": "conn", - "chat": {"id": 42, "type": "private"}, - "message_ids": [3, 5] - } - }`), &update) - if err != nil { - t.Fatalf("Unmarshal returned error: %v", err) +func TestUpdateUnmarshalSetsType(t *testing.T) { + tests := []struct { + name string + body string + want UpdateType + }{ + { + name: "deleted business messages", + body: `{ + "update_id": 1, + "deleted_business_messages": { + "business_connection_id": "conn", + "chat": {"id": 42, "type": "private"}, + "message_ids": [3, 5] + } + }`, + want: UpdateTypeDeletedBusinessMessages, + }, + { + name: "callback query", + body: `{ + "update_id": 2, + "callback_query": { + "id": "cb", + "from": {"id": 1, "is_bot": false, "first_name": "Test"}, + "chat_instance": "instance", + "data": "payload" + } + }`, + want: UpdateTypeCallbackQuery, + }, + { + name: "unknown", + body: `{"update_id":3}`, + want: UpdateTypeUnknown, + }, } - if update.DeletedBusinessMessages == nil { - t.Fatal("expected DeletedBusinessMessages to be populated") - } - if update.DeletedBusinessMessage == nil { - t.Fatal("expected deprecated DeletedBusinessMessage alias to be populated") - } - if update.DeletedBusinessMessages != update.DeletedBusinessMessage { - t.Fatal("expected deleted business message fields to share the same payload") - } - if got := update.DeletedBusinessMessages.MessageIDs; len(got) != 2 || got[0] != 3 || got[1] != 5 { - t.Fatalf("unexpected message ids: %v", got) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var update Update + if err := json.Unmarshal([]byte(tt.body), &update); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if update.Type != tt.want { + t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want) + } + }) } } -func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) { +func TestUpdateMarshalOmitsSyntheticTypeField(t *testing.T) { update := Update{ UpdateID: 1, - DeletedBusinessMessage: &BusinessMessagesDeleted{ - BusinessConnectionID: "conn", - Chat: Chat{ID: 42, Type: string(ChatTypePrivate)}, - MessageIDs: []int{7}, + Type: UpdateTypeCallbackQuery, + CallbackQuery: &CallbackQuery{ + ID: "cb", + From: User{ID: 1, FirstName: "Test"}, + ChatInstance: "instance", + Data: "payload", }, } @@ -50,11 +75,8 @@ func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) { } got := string(data) - if !strings.Contains(got, `"deleted_business_messages"`) { - t.Fatalf("expected canonical deleted_business_messages field, got %s", got) - } - if strings.Contains(got, `"deleted_business_message"`) { - t.Fatalf("unexpected singular deleted_business_message field, got %s", got) + if strings.Contains(got, `"type"`) { + t.Fatalf("unexpected synthetic type field, got %s", got) } } @@ -66,4 +88,7 @@ func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) { if update.ShippingQuery != nil { t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery) } + if update.Type != UpdateTypeUnknown { + t.Fatalf("expected UpdateTypeUnknown, got %q", update.Type) + } } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 7e87723..9692179 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -82,8 +82,12 @@ func (u *Uploader) Close() error { return u.logger.Close() } // See https://core.telegram.org/bots/api func (u *Uploader) GetLogger() *slog.Logger { return u.logger } -// UploaderRequest is a multipart file upload request to the Telegram API. -// Use NewUploaderRequest or NewUploaderRequestWithChatID to construct one. +// UploaderRequest is a low-level multipart upload request wrapper. +// +// Prefer method-specific helpers such as SendPhoto or SetWebhook. UploaderRequest +// is intended for advanced use cases where callers manage the method name, files, +// and request/response types themselves. In that sense it is an unsafe escape +// hatch compared with the typed uploader API. type UploaderRequest[R, P any] struct { method string files []UploaderFile @@ -91,16 +95,17 @@ type UploaderRequest[R, P any] struct { chatId int64 } -// NewUploaderRequest creates a new multipart upload request with no associated chat ID. +// NewUploaderRequest creates a low-level multipart upload request with no associated chat ID. func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0} } -// NewUploaderRequestWithChatID creates a new multipart upload request with an associated chat ID. +// NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID. // The chat ID is used for per-chat rate limiting. func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] { return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId} } + func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { var zero R @@ -205,8 +210,7 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) { return r.DoWithContext(context.Background(), up) } -// prepareMultipart builds a multipart form body from the given files and params. -// Params are encoded via utils.Encode. The writer boundary is finalized before returning. +// Internal helper that builds a finalized multipart body from files and params. func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { buf := bytes.NewBuffer(nil) w := multipart.NewWriter(buf) @@ -239,8 +243,7 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str return buf, w.FormDataContentType(), nil } -// uploaderTypeByExt infers the Telegram upload field name from a file extension. -// Falls back to UploaderDocumentType for unrecognized extensions. +// Internal helper that infers an upload field name from a file extension. func uploaderTypeByExt(filename string) UploaderFileType { ext := strings.ToLower(filepath.Ext(filename)) switch ext { diff --git a/utils.go b/utils.go index 7127692..7e94ce2 100644 --- a/utils.go +++ b/utils.go @@ -53,11 +53,15 @@ func EscapePunctuation(s string) string { return s } -// Version constants mirror values from the internal utils/version package. const ( + // VersionString re-exports the module version string. VersionString = utils.VersionString - VersionMajor = utils.VersionMajor - VersionMinor = utils.VersionMinor - VersionPatch = utils.VersionPatch - VersionBeta = utils.VersionBeta + // VersionMajor re-exports the module major version. + VersionMajor = utils.VersionMajor + // VersionMinor re-exports the module minor version. + VersionMinor = utils.VersionMinor + // VersionPatch re-exports the module patch version. + VersionPatch = utils.VersionPatch + // VersionBeta re-exports the module prerelease counter. + VersionBeta = utils.VersionBeta ) diff --git a/utils/limiter.go b/utils/limiter.go index 67e05cd..ed72897 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -9,6 +9,7 @@ import ( "golang.org/x/time/rate" ) +// ErrDropOverflow is returned when drop mode rejects a rate-limited request. var ErrDropOverflow = errors.New("drop overflow limit") // RateLimiter implements per-chat and global rate limiting with optional blocking. @@ -102,7 +103,7 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { return chatLimiter.Wait(ctx) } -// getGlobalLimiter returns the global limiter safely under read lock. +// Internal helper that returns the global limiter under read lock. func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter { rl.globalMu.RLock() defer rl.globalMu.RUnlock() @@ -190,8 +191,7 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6 return nil } -// waitForGlobalUnlock blocks until global cooldown expires or context is done. -// Does not check token bucket — only cooldown. +// Internal helper that waits for the global cooldown to expire. func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { rl.globalMu.RLock() until := rl.globalLockUntil @@ -209,8 +209,7 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { } } -// waitForChatUnlock blocks until the specified chat's cooldown expires or context is done. -// Does not check token bucket — only cooldown. +// Internal helper that waits for a chat-specific cooldown to expire. func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { rl.chatMu.RLock() until, ok := rl.chatLocks[chatID] @@ -228,8 +227,7 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro } } -// getChatLimiter returns the rate limiter for the given chat, creating it if needed. -// Uses 1 request per second with burst of 1 — conservative for per-user limits. +// Internal helper that returns or creates a per-chat limiter. func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { rl.chatMu.Lock() defer rl.chatMu.Unlock() diff --git a/utils/multipart.go b/utils/multipart.go index 4ee0bfe..9e02476 100644 --- a/utils/multipart.go +++ b/utils/multipart.go @@ -3,7 +3,6 @@ package utils import ( "encoding/json" "fmt" - "io" "mime/multipart" "reflect" "slices" @@ -110,6 +109,6 @@ func writeMultipartValue(w *multipart.Writer, fieldName string, value []byte) er if err != nil { return err } - _, err = io.Copy(fw, strings.NewReader(string(value))) + _, err = fw.Write(value) return err } diff --git a/utils/version.go b/utils/version.go index ac6675f..f2e6a5f 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,14 @@ package utils const ( - VersionString = "1.0.0-rc.9" - VersionMajor = 1 - VersionMinor = 0 - VersionPatch = 0 - VersionBeta = 9 + // VersionString is the module version string. + VersionString = "1.0.0-rc.10" + // VersionMajor is the module major version. + VersionMajor = 1 + // VersionMinor is the module minor version. + VersionMinor = 0 + // VersionPatch is the module patch version. + VersionPatch = 0 + // VersionBeta is the prerelease counter for the current version. + VersionBeta = 10 ) -- 2.54.0 From 5d3199dc21089fc8e1a056391e9356eb9d9e1272 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 25 Mar 2026 18:20:20 +0300 Subject: [PATCH 57/99] fix(tgapi): accept string chat boost IDs --- CHANGELOG.md | 7 ++++++- tgapi/chat_types.go | 2 +- tgapi/types_test.go | 24 +++++++++++++++++++++++- utils/version.go | 4 ++-- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e409a..714e5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog -## Unreleased +## v1.0.0-rc.11 + +### Fixed +- `chat_boost` update decoding now accepts string `boost_id` values, matching the current Telegram Bot API schema and preventing polling failures on boosted-chat updates. + +## v1.0.0-rc.10 ### Added - `Plugin.AddUpdateHandler` for routing non-command Telegram updates by `tgapi.UpdateType`. diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index 2bdda56..d8d263e 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -224,7 +224,7 @@ type ChatBoostSource struct { // ChatBoost represents a boost added to a chat. // See https://core.telegram.org/bots/api#chatboost type ChatBoost struct { - BoostID int `json:"boost_id"` + BoostID string `json:"boost_id"` AddDate int `json:"add_date"` ExpirationDate int `json:"expiration_date"` Source ChatBoostSource `json:"source"` diff --git a/tgapi/types_test.go b/tgapi/types_test.go index bbdada5..6a72d98 100644 --- a/tgapi/types_test.go +++ b/tgapi/types_test.go @@ -37,9 +37,28 @@ func TestUpdateUnmarshalSetsType(t *testing.T) { }`, want: UpdateTypeCallbackQuery, }, + { + name: "chat boost", + body: `{ + "update_id": 3, + "chat_boost": { + "chat": {"id": -1001, "type": "supergroup", "title": "Boosted"}, + "boost": { + "boost_id": "boost-1", + "add_date": 1735689600, + "expiration_date": 1738291600, + "source": { + "source": "premium", + "user": {"id": 1, "is_bot": false, "first_name": "Test"} + } + } + } + }`, + want: UpdateTypeChatBoost, + }, { name: "unknown", - body: `{"update_id":3}`, + body: `{"update_id":4}`, want: UpdateTypeUnknown, }, } @@ -53,6 +72,9 @@ func TestUpdateUnmarshalSetsType(t *testing.T) { if update.Type != tt.want { t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want) } + if tt.want == UpdateTypeChatBoost && update.ChatBoost.Boost.BoostID != "boost-1" { + t.Fatalf("unexpected boost id: got %q want %q", update.ChatBoost.Boost.BoostID, "boost-1") + } }) } } diff --git a/utils/version.go b/utils/version.go index f2e6a5f..81a081e 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,7 +2,7 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0-rc.10" + VersionString = "1.0.0-rc.11" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. @@ -10,5 +10,5 @@ const ( // VersionPatch is the module patch version. VersionPatch = 0 // VersionBeta is the prerelease counter for the current version. - VersionBeta = 10 + VersionBeta = 11 ) -- 2.54.0 From 945b8240e639c67c8ef2ac44f460ff5173aff9c7 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 26 Mar 2026 18:15:06 +0300 Subject: [PATCH 58/99] wip: add long message helpers and payload type controls - add centralized message validation errors - switch command handlers to return error - add explicit long plain-text reply helpers - support strict payload type policy with debug logging - document versioning and changelog workflow --- AGENTS.md | 24 +++++++ CHANGELOG.md | 23 +++++++ README.md | 17 +++-- README_RU.md | 17 +++-- bot.go | 59 ++++++++++------- bot_opts.go | 17 ++++- bot_opts_test.go | 9 +++ bot_test.go | 20 +++++- cmd_generator_test.go | 4 +- drafts.go | 10 +-- drafts_test.go | 19 ++++++ errors.go | 59 +++++++++++++++++ handler.go | 43 +++++++++++-- handler_test.go | 14 ++-- keyboard.go | 12 +++- keyboard_test.go | 53 +++++++++++++++ msg_context.go | 98 ++++++++++++++++++++++++---- msg_context_test.go | 145 ++++++++++++++++++++++++++++++++++++++++++ plugins.go | 11 +++- plugins_test.go | 6 +- split.go | 44 +++++++++++++ utils/version.go | 4 +- 22 files changed, 631 insertions(+), 77 deletions(-) create mode 100644 errors.go create mode 100644 split.go diff --git a/AGENTS.md b/AGENTS.md index 6dc01d2..a1af97c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,30 @@ Before finalizing changes, run the relevant project checks when available: Prefer the repository’s documented commands. If multiple choices exist, use the most standard and least destructive ones first. +## Versioning and changelog +- After every code or documentation change, update `CHANGELOG.md`. +- Add changes only to the section for the next version after the latest published git tag. +- The agent must check the latest published tag, `CHANGELOG.md`, and `utils/version.go` before editing the changelog. +- The agent must verify that the target changelog version matches the version declared in `utils/version.go`. +- If the latest published tag is, for example, `v1.0.0`, and `CHANGELOG.md` does not yet contain the next version section, the agent must stop and ask the user which version the change belongs to: + 1. `v1.0.1` + 2. `v1.1.0` + 3. `v2.0.0` +- The agent must not guess the next version when that section is missing. +- If the user-selected version does not match `utils/version.go`, the agent must warn about the mismatch and require the version file to be updated before proceeding. +- Changelog entries must describe all user-visible behavior changes made in the turn, including API additions, fixes, behavior changes, and breaking changes. + +## Breaking changes policy +- The agent must detect potential breaking changes before editing public APIs. +- Breaking changes are forbidden unless the selected target version is a new major version. +- If the requested change is breaking and the user did not bump the major version, the agent must stop and warn that the change is not allowed under the current version. +- In that case, the agent must offer only these options: + 1. do not make the breaking change; + 2. introduce a backward-compatible alternative such as a new method, function, type, or struct, but only if that keeps the codebase reasonably small and clear; + 3. bump the major version and then apply the breaking change. +- Prefer additive compatibility over signature changes when the additive option is small and maintainable. +- Example: if a method like `ctx.answer(...)` needs an extra parameter, the agent must either require a major-version bump or add a new method that keeps the old method working. + ## Output format For repo-wide review tasks, structure the result as: diff --git a/CHANGELOG.md b/CHANGELOG.md index 714e5fd..a4a826e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## v1.0.0-rc.12 + +### Added +- `AnswerLong(...)`, `AnswerLongf(...)`, `KeyboardLong(...)`, and `SplitMessageText(...)` for explicit plain-text splitting of long replies without changing the semantics of existing single-message helpers. +- Centralized library-level validation errors in `errors.go`, including `ErrEmptyMessage`, `ErrMessageTooLong`, `ErrCaptionTooLong`, and context/target validation sentinels. +- `Bot.GetPayloadType()`, `InlineKeyboard.GetPayloadType()`, and optional strict payload decoding via `BotOpts.StrictPayloadType` / `Bot.SetStrictPayloadType(...)`. + +### Changed +- `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors. +- README and README_RU examples now use the new handler signature and document the long-message helpers. +- `AGENTS.md` now requires every change to be recorded in `CHANGELOG.md`, enforces version alignment with `utils/version.go`, and blocks breaking changes without a major-version bump. +- Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides. +- Version constants were bumped to `v1.0.0-rc.12`. + +### Fixed +- Message and caption validation now runs before Telegram API calls, rejecting empty messages, oversized message text, and oversized captions with stable sentinel errors. +- Draft flushing and draft updates now reject oversized messages before sending invalid requests. +- Callback payload decoding now optionally enforces strict type matching, while the default tolerant mode logs Base64-to-JSON decoding in debug mode and still accepts keyboard-local payload overrides. + +### Breaking Changes +- `CommandExecutor[T]` changed from `func(ctx *MsgContext, db T)` to `func(ctx *MsgContext, db T) error`. +- `Plugin.NewCommand(...)`, `Plugin.NewPayload(...)`, and `Plugin.AddUpdateHandler(...)` now require handlers with the new error-returning signature. + ## v1.0.0-rc.11 ### Fixed diff --git a/README.md b/README.md index 7fcec12..501e882 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,11 @@ import ( // It receives two parameters: // - ctx: the message context (contains info about the message, sender, chat, etc.) // - db: your custom database context (here we use NoDB, a placeholder for no database) -func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) { +func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error { // Answer the user with the text they sent, without any command prefix. // ctx.Text contains the user's message with the command part stripped off. ctx.Answer(ctx.Text) // User input WITHOUT command + return nil } func main() { @@ -81,8 +82,9 @@ func main() { // 5. Add another command using an anonymous function (closure). // This command simply replies "Pong" when the user sends "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) { + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error { ctx.Answer("Pong") + return nil }, "ping")) // 6. Configure the bot with a custom error template and add the plugin. @@ -107,8 +109,8 @@ func main() { 1. `BotOpts`: Holds configuration like the API token. 2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it. 3. `NewPlugin`: Creates a logical group for commands and middlewares. -4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash). -5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T. +4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash). +5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T, and return an error for centralized error handling. 6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails. @@ -128,9 +130,10 @@ bot.AddPlugins(plugin) A command is a function that handles a specific bot command (e.g., /start). ```go -func myHandler(ctx *laniakea.MsgContext, db *MyDB) { +func myHandler(ctx *laniakea.MsgContext, db *MyDB) error { // Access command arguments via ctx.Args ([]string) // Reply to the user: ctx.Answer("some text") + return nil } ``` @@ -139,8 +142,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) { Provides access to the incoming message and useful reply methods: - `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none. +- `AnswerLong(text string) []*AnswerMessage`: Splits long plain text into multiple messages. - `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping). - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard. +- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. - `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). @@ -223,7 +228,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. ## ⚙️ Advanced Configuration -- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. +- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard. - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Localization**: `L10n` is safe for concurrent use once attached to the bot. - **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow. diff --git a/README_RU.md b/README_RU.md index b7d3797..85579d6 100644 --- a/README_RU.md +++ b/README_RU.md @@ -53,10 +53,11 @@ import ( // Она получает два параметра: // - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) // - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку) -func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) { +func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error { // Отвечаем пользователю текстом, который он прислал, без префикса команды. // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды + return nil } func main() { @@ -82,8 +83,9 @@ func main() { // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) { + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error { ctx.Answer("Pong") + return nil }, "ping")) // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. @@ -108,8 +110,8 @@ func main() { 1. `BotOpts`: Содержит конфигурацию, например, токен API. 2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. -4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша). -5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T. +4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша). +5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки. 6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. @@ -129,9 +131,10 @@ bot.AddPlugins(plugin) Команда — это функция, которая обрабатывает конкретную команду бота (например, /start). ```go -func myHandler(ctx *laniakea.MsgContext, db *MyDB) { +func myHandler(ctx *laniakea.MsgContext, db *MyDB) error { // Доступ к аргументам команды через ctx.Args ([]string) // Ответ пользователю: ctx.Answer("какой-то текст") + return nil } ``` @@ -139,8 +142,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) { Предоставляет доступ к входящему сообщению и полезные методы для ответа: - `Answer(text string)`: Отправляет сообщение с parse_mode none. +- `AnswerLong(text string) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений. - `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне). - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой. +- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. - `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). @@ -220,7 +225,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. ## ⚙️ Расширенная настройка -- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. +- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры. - **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. - **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. diff --git a/bot.go b/bot.go index 651bd05..b76ad37 100644 --- a/bot.go +++ b/bot.go @@ -77,12 +77,13 @@ var ( // Runtime accessors are safe for concurrent use. Configure the bot before Run. // A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session. type Bot[T DbContext] struct { - token string - debug bool - errorTemplate string - username string - payloadType BotPayloadType - maxWorkers int + token string + debug bool + errorTemplate string + username string + payloadType BotPayloadType + strictPayloadType bool + maxWorkers int logger *slog.Logger // Main bot logger (JSON stdout + optional file) RequestLogger *slog.Logger // Optional request-level API logging @@ -156,22 +157,23 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { } bot := &Bot[T]{ - updateOffset: 0, - errorTemplate: "%s", - payloadType: BotPayloadBase64, - maxWorkers: workers, - updateQueue: updateQueue, - api: api, - uploader: uploader, - debug: opts.Debug, - prefixes: prefixes, - token: opts.Token, - plugins: make([]Plugin[T], 0), - updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...), - runners: make([]Runner[T], 0), - extraLoggers: make([]*slog.Logger, 0), - l10n: &L10n{}, - draftProvider: NewRandomDraftProvider(api), + updateOffset: 0, + errorTemplate: "%s", + payloadType: BotPayloadBase64, + strictPayloadType: opts.StrictPayloadType, + maxWorkers: workers, + updateQueue: updateQueue, + api: api, + uploader: uploader, + debug: opts.Debug, + prefixes: prefixes, + token: opts.Token, + plugins: make([]Plugin[T], 0), + updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...), + runners: make([]Runner[T], 0), + extraLoggers: make([]*slog.Logger, 0), + l10n: &L10n{}, + draftProvider: NewRandomDraftProvider(api), } // Add API and Uploader loggers to extraLoggers for unified output @@ -356,14 +358,25 @@ func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { return bot } -// SetPayloadType sets the payload encoding type used for callback data. +// SetPayloadType sets the default payload encoding type used for callback data. // JSON stores payload as a string: `{"cmd":"command","args":[...]}`. // Base64 stores the same JSON encoded as a Base64URL string. +// InlineKeyboard.SetPayloadType may override this value for an individual keyboard. func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { bot.payloadType = t return bot } +// GetPayloadType returns the bot's default callback payload encoding type. +func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType } + +// SetStrictPayloadType enables or disables strict callback payload decoding. +// When enabled, callback payloads must match the bot's default payload type. +func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] { + bot.strictPayloadType = strict + return bot +} + // AddUpdateType adds one or more update types to the list. // Does not overwrite existing types. func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { diff --git a/bot_opts.go b/bot_opts.go index 0ec97d6..d10aba8 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -56,6 +56,10 @@ type BotOpts struct { // Use this to prioritize responsiveness over reliability. DropRLOverflow bool + // StrictPayloadType disables callback payload fallback decoding. + // When enabled, the bot accepts only the configured default payload type. + StrictPayloadType bool + // MaxWorkers is the maximum number of update handlers that may run concurrently. MaxWorkers int } @@ -75,6 +79,7 @@ type BotOpts struct { // - API_URL: custom API endpoint // - RATE_LIMIT: max requests per second (default: 30) // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow +// - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format // - MAX_WORKERS: maximum number of concurrent update handlers (default: 32) // // Returns a populated BotOpts. @@ -116,8 +121,9 @@ func LoadOptsFromEnv() *BotOpts { UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", APIUrl: os.Getenv("API_URL"), - RateLimit: rateLimit, - DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", + RateLimit: rateLimit, + DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", + StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", MaxWorkers: maxWorkers, } @@ -208,6 +214,13 @@ func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts { return opts } +// SetStrictPayloadType enables or disables strict callback payload decoding. +// When enabled, the bot accepts only the configured default payload type. +func (opts *BotOpts) SetStrictPayloadType(strict bool) *BotOpts { + opts.StrictPayloadType = strict + return opts +} + // SetMaxWorkers sets the maximum number of concurrent update handlers. // Must be called before NewBot, as the value is captured during bot creation. // diff --git a/bot_opts_test.go b/bot_opts_test.go index 9f3c1be..4c5c7ed 100644 --- a/bot_opts_test.go +++ b/bot_opts_test.go @@ -45,3 +45,12 @@ func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) { t.Fatalf("unexpected prefixes: got %v want %v", got, want) } } + +func TestLoadOptsFromEnvReadsStrictPayloadType(t *testing.T) { + t.Setenv("STRICT_PAYLOAD_TYPE", "true") + + opts := LoadOptsFromEnv() + if !opts.StrictPayloadType { + t.Fatal("expected StrictPayloadType to be enabled") + } +} diff --git a/bot_test.go b/bot_test.go index 45db995..8795daa 100644 --- a/bot_test.go +++ b/bot_test.go @@ -27,13 +27,13 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { bot := &Bot[NoDB]{logger: slog.CreateLogger()} plugin := NewPlugin[NoDB]("demo") - cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "start") + cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "start") plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })) bot.AddPlugins(plugin) cmd.SetDescription("mutated after registration") - plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "late") + plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "late") plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true })) registered := bot.plugins[0] @@ -48,6 +48,22 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { } } +func TestBotPayloadTypeConfiguration(t *testing.T) { + bot := &Bot[NoDB]{payloadType: BotPayloadBase64} + + if got := bot.GetPayloadType(); got != BotPayloadBase64 { + t.Fatalf("unexpected initial payload type: %q", got) + } + bot.SetPayloadType(BotPayloadJson) + if got := bot.GetPayloadType(); got != BotPayloadJson { + t.Fatalf("unexpected updated payload type: %q", got) + } + bot.SetStrictPayloadType(true) + if !bot.strictPayloadType { + t.Fatal("expected strict payload type to be enabled") + } +} + func TestAddPluginsSkipsNilPlugin(t *testing.T) { bot := &Bot[NoDB]{logger: slog.CreateLogger()} plugin := NewPlugin[NoDB]("demo") diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 468316f..bbbca03 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -44,7 +44,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { }() plugin := NewPlugin[NoDB]("overflow") - exec := func(ctx *MsgContext, db NoDB) {} + exec := func(ctx *MsgContext, db NoDB) error { return nil } for i := 0; i < 101; i++ { plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) } @@ -66,7 +66,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { plugin := NewPlugin[NoDB]("sorted") - exec := func(ctx *MsgContext, db NoDB) {} + exec := func(ctx *MsgContext, db NoDB) error { return nil } plugin.AddCommand(NewCommand(exec, "zeta")) plugin.AddCommand(NewCommand(exec, "alpha")) diff --git a/drafts.go b/drafts.go index 2de42de..1e1c89a 100644 --- a/drafts.go +++ b/drafts.go @@ -1,7 +1,6 @@ package laniakea import ( - "errors" "math/rand/v2" "sync" "sync/atomic" @@ -9,9 +8,6 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) -// ErrDraftChatIDZero is returned when a draft is used without setting a chat ID. -var ErrDraftChatIDZero = errors.New("zero draft chat ID") - // Interface for generating unique draft IDs. type draftIdGenerator interface { // Next returns the next unique draft ID. @@ -221,6 +217,9 @@ func (d *Draft) Flush() error { if d.chatID == 0 { return ErrDraftChatIDZero } + if err := validateMessageText(d.Message); err != nil { + return err + } params := tgapi.SendMessageP{ ChatID: d.chatID, @@ -245,6 +244,9 @@ func (d *Draft) push(text string) error { return ErrDraftChatIDZero } d.Message += text + if err := validateMessageText(d.Message); err != nil { + return err + } params := tgapi.SendMessageDraftP{ ChatID: d.chatID, DraftID: d.ID, diff --git a/drafts_test.go b/drafts_test.go index 5e10497..887e19a 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -1,6 +1,8 @@ package laniakea import ( + "errors" + "strings" "testing" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -34,3 +36,20 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { t.Fatalf("unexpected chat id: %d", draft.chatID) } } + +func TestDraftFlushRejectsLongMessage(t *testing.T) { + draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0) + draft.Message = strings.Repeat("a", maxMessageTextLen+1) + + if err := draft.Flush(); !errors.Is(err, ErrMessageTooLong) { + t.Fatalf("expected ErrMessageTooLong, got %v", err) + } +} + +func TestDraftPushRejectsLongMessage(t *testing.T) { + draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0) + + if err := draft.Push(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) { + t.Fatalf("expected ErrMessageTooLong, got %v", err) + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..09d5425 --- /dev/null +++ b/errors.go @@ -0,0 +1,59 @@ +package laniakea + +import ( + "errors" + "fmt" + "unicode/utf8" +) + +const ( + maxMessageTextLen = 4096 + maxMessageCaptionLen = 1024 +) + +var ( + // ErrEmptyMessage reports that a required message text is empty. + ErrEmptyMessage = errors.New("empty message") + // ErrMessageTooLong reports that a message exceeds Telegram's text limit. + ErrMessageTooLong = errors.New("message too long") + // ErrCaptionTooLong reports that a caption exceeds Telegram's caption limit. + ErrCaptionTooLong = errors.New("caption too long") + // ErrMessageSplitImpossible reports that automatic message splitting cannot preserve semantics. + ErrMessageSplitImpossible = errors.New("message split is impossible") + // ErrPayloadTypeMismatch reports that callback payload encoding does not match bot policy. + ErrPayloadTypeMismatch = errors.New("payload type mismatch") + // ErrDraftChatIDZero reports that a draft has no target chat ID. + ErrDraftChatIDZero = errors.New("zero draft chat ID") + // ErrMessageContextNil reports that an operation requires ctx.Msg but none is set. + ErrMessageContextNil = errors.New("message context is nil") + // ErrEditTargetMissing reports that an edit operation has no message target. + ErrEditTargetMissing = errors.New("edit target is missing") + // ErrCallbackMessageMissing reports that a callback operation has no callback message target. + ErrCallbackMessageMissing = errors.New("callback message is missing") + // ErrDraftProviderNil reports that draft creation was requested without a draft provider. + ErrDraftProviderNil = errors.New("draft provider is nil") + // ErrAPIIsNil reports that an operation requires an API client but none is set. + ErrAPIIsNil = errors.New("api is nil") + // ErrMessageIDZero reports that an operation requires a non-zero message ID. + ErrMessageIDZero = errors.New("message ID is zero") +) + +func validateMessageText(text string) error { + length := utf8.RuneCountInString(text) + switch { + case length == 0: + return ErrEmptyMessage + case length > maxMessageTextLen: + return fmt.Errorf("%w: got %d, limit %d", ErrMessageTooLong, length, maxMessageTextLen) + default: + return nil + } +} + +func validateCaptionText(text string) error { + length := utf8.RuneCountInString(text) + if length > maxMessageCaptionLen { + return fmt.Errorf("%w: got %d, limit %d", ErrCaptionTooLong, length, maxMessageCaptionLen) + } + return nil +} diff --git a/handler.go b/handler.go index b328217..90f0126 100644 --- a/handler.go +++ b/handler.go @@ -156,7 +156,9 @@ func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) { continue } - handler(pluginCtx, bot.dbContext) + if err := handler(pluginCtx, bot.dbContext); err != nil { + pluginCtx.error(err) + } } } @@ -320,19 +322,48 @@ func decodeBase64Payload(s string) (CallbackData, error) { } return decodeJsonPayload(string(b)) } -func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) { +func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) { switch payloadType { case BotPayloadBase64: - return decodeBase64Payload(s) + 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 case BotPayloadJson: - return decodeJsonPayload(s) + 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 CallbackData{}, ErrInvalidPayloadType + return CallbackData{}, "", ErrInvalidPayloadType } // func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) { // return encodePayload(bot.payloadType, d) // } func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { - return decodePayload(bot.payloadType, s) + data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType) + if err != nil { + return CallbackData{}, err + } + if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil { + bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson()) + } + return data, nil } diff --git a/handler_test.go b/handler_test.go index 8d91e6b..485f656 100644 --- a/handler_test.go +++ b/handler_test.go @@ -51,7 +51,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) { func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { plugin := NewPlugin[NoDB]("test") - handler := func(ctx *MsgContext, db NoDB) {} + handler := func(ctx *MsgContext, db NoDB) error { return nil } for _, updateType := range []tgapi.UpdateType{ tgapi.UpdateTypeMessage, @@ -110,7 +110,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { called := false - plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) { + plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) error { called = true if ctx.Update.UpdateID != tt.update.UpdateID { t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID) @@ -127,6 +127,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { if ctx.Msg != nil { t.Fatalf("did not expect message context for %s", tt.name) } + return nil }) bot := &Bot[NoDB]{ @@ -147,7 +148,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { firstCalled := false secondCalled := false - first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) { + first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error { firstCalled = true if ctx.FromID != 41 { t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID) @@ -156,8 +157,9 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { ctx.FromID = 999 ctx.Text = "mutated" ctx.Args = []string{"mutated"} + return nil }) - second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) { + second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error { secondCalled = true if ctx.From == nil { t.Fatal("expected ctx.From to remain populated for second handler") @@ -171,6 +173,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { if len(ctx.Args) != 0 { t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args) } + return nil }) bot := &Bot[NoDB]{ @@ -199,7 +202,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { called := false plugin := NewPlugin[NoDB]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoDB) { + plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { called = true if ctx.Msg == nil { t.Fatal("expected message context") @@ -213,6 +216,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { if ctx.FromID != 0 { t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID) } + return nil }, "ping") bot := &Bot[NoDB]{ diff --git a/keyboard.go b/keyboard.go index 1ce82ab..ab19c67 100644 --- a/keyboard.go +++ b/keyboard.go @@ -136,14 +136,22 @@ func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard { } } -// SetPayloadType sets the serialization format for callback data added via +// SetPayloadType sets the keyboard-local serialization format for callback data added via // AddCallbackButton and AddCallbackButtonStyle methods. -// It should be one of BotPayloadJson or BotPayloadBase64. +// It overrides the bot's default payload type for this keyboard only. func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard { in.payloadType = t return in } +// GetPayloadType returns the keyboard-local callback payload encoding type. +func (in *InlineKeyboard) GetPayloadType() BotPayloadType { return in.payloadType } + +func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard { + in.maxRow = maxRow + return in +} + // Internal helper that appends a button and auto-flushes a full row. func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { if in.CurrentLine.Len() == in.maxRow { diff --git a/keyboard_test.go b/keyboard_test.go index 3c39a95..c9157d6 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -1,6 +1,8 @@ package laniakea import ( + "errors" + "reflect" "strings" "testing" ) @@ -42,3 +44,54 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { t.Fatalf("unexpected url: %q", button.URL) } } + +func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) { + kb := NewInlineKeyboardJson(2) + if got := kb.GetPayloadType(); got != BotPayloadJson { + t.Fatalf("unexpected initial payload type: %q", got) + } + kb.SetPayloadType(BotPayloadBase64) + if got := kb.GetPayloadType(); got != BotPayloadBase64 { + t.Fatalf("unexpected updated payload type: %q", got) + } +} + +func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.T) { + kb := NewInlineKeyboardBase64(1). + AddCallbackButton("A", "cmd", 1, "two") + + got, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, false) + if err != nil { + t.Fatalf("decodePayload returned error: %v", err) + } + + want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected payload: got %#v want %#v", got, want) + } +} + +func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) { + kb := NewInlineKeyboardJson(1). + AddCallbackButton("A", "cmd", 1, "two") + + got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false) + if err != nil { + t.Fatalf("decodePayload returned error: %v", err) + } + + want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected payload: got %#v want %#v", got, want) + } +} + +func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) { + kb := NewInlineKeyboardBase64(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) + } +} diff --git a/msg_context.go b/msg_context.go index d3e6cf3..eeeae9f 100644 --- a/msg_context.go +++ b/msg_context.go @@ -2,6 +2,7 @@ package laniakea import ( "context" + "errors" "fmt" "time" @@ -48,6 +49,10 @@ type AnswerMessage struct { // 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, @@ -59,7 +64,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard case ctx.InlineMsgId != "": params.InlineMessageID = ctx.InlineMsgId default: - ctx.Logger.Errorln("Can't edit message: no valid message target") + ctx.Logger.Errorln(ErrEditTargetMissing) return nil } if keyboard != nil { @@ -96,7 +101,7 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { // 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("Can't edit non-callback update message") + ctx.Logger.Errorln(ErrCallbackMessageMissing) return nil } return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode) @@ -128,6 +133,10 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb // 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, @@ -139,7 +148,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo case ctx.InlineMsgId != "": params.InlineMessageID = ctx.InlineMsgId default: - ctx.Logger.Errorln("Can't edit caption: no valid message target") + ctx.Logger.Errorln(ErrEditTargetMissing) return nil } if kb != nil { @@ -187,7 +196,11 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo // 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("Can't answer message without a message") + ctx.Logger.Errorln(ErrMessageContextNil) + return nil + } + if err := validateMessageText(text); err != nil { + ctx.Logger.Errorln(err) return nil } params := tgapi.SendMessageP{ @@ -220,6 +233,14 @@ 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. @@ -232,6 +253,11 @@ 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. @@ -244,6 +270,13 @@ 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. @@ -251,10 +284,53 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) * 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("Can't answer message without a message") + ctx.Logger.Errorln(ErrMessageContextNil) + return nil + } + if err := validateCaptionText(text); err != nil { + ctx.Logger.Errorln(err) return nil } params := tgapi.SendPhotoP{ @@ -322,11 +398,11 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an // Internal helper that deletes a message by ID. func (ctx *MsgContext) delete(messageId int) { if messageId == 0 { - ctx.Logger.Errorln("Can't delete message: message ID zero") + ctx.Logger.Errorln(ErrMessageIDZero) return } if ctx.Msg == nil { - ctx.Logger.Errorln("Can't delete message: no chat message context") + ctx.Logger.Errorln(ErrMessageContextNil) return } _, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{ @@ -344,7 +420,7 @@ 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("Can't delete callback message: no callback message ID") + ctx.Logger.Errorln(ErrCallbackMessageMissing) return } ctx.delete(ctx.CallbackMsgId) @@ -411,15 +487,15 @@ func (ctx *MsgContext) Error(err error) { ctx.error(err) } func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { if ctx.Msg == nil { - ctx.Logger.Errorln("can't create draft: ctx.Msg is nil") + ctx.Logger.Errorln(ErrMessageContextNil) return nil } if ctx.Api == nil { - ctx.Logger.Errorln("can't create draft: ctx.Api is nil") + ctx.Logger.Errorln(ErrAPIIsNil) return nil } if ctx.draftProvider == nil { - ctx.Logger.Errorln("can't create draft: ctx.draftProvider is nil") + ctx.Logger.Errorln(ErrDraftProviderNil) return nil } diff --git a/msg_context_test.go b/msg_context_test.go index 5c5c38d..299fa42 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -2,8 +2,10 @@ package laniakea import ( "encoding/json" + "errors" "io" "net/http" + "reflect" "strings" "testing" @@ -62,3 +64,146 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { t.Fatalf("unexpected direct_messages_topic_id: %v", got) } } + +func TestAnswerRejectsEmptyMessage(t *testing.T) { + ctx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Logger: slog.CreateLogger(), + } + + if answer := ctx.Answer(""); answer != nil { + t.Fatal("expected nil answer for empty message") + } +} + +func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatal("unexpected HTTP request") + return nil, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Logger: slog.CreateLogger(), + } + + if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil { + t.Fatal("expected nil answer for long message") + } +} + +func TestValidateMessageText(t *testing.T) { + if err := validateMessageText(""); !errors.Is(err, ErrEmptyMessage) { + t.Fatalf("expected ErrEmptyMessage, got %v", err) + } + if err := validateMessageText(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) { + t.Fatalf("expected ErrMessageTooLong, got %v", err) + } + if err := validateMessageText("ok"); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} + +func TestValidateCaptionText(t *testing.T) { + if err := validateCaptionText(strings.Repeat("a", maxMessageCaptionLen+1)); !errors.Is(err, ErrCaptionTooLong) { + t.Fatalf("expected ErrCaptionTooLong, got %v", err) + } + if err := validateCaptionText(""); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} + +func TestSplitMessageTextPreservesContent(t *testing.T) { + text := "alpha beta\n" + strings.Repeat("x", maxMessageTextLen) + " omega" + + parts := SplitMessageText(text) + if len(parts) < 2 { + t.Fatalf("expected multiple parts, got %d", len(parts)) + } + + for i, part := range parts { + if got := len([]rune(part)); got > maxMessageTextLen { + t.Fatalf("part %d exceeded limit: %d", i, got) + } + } + + if got := strings.Join(parts, ""); got != text { + t.Fatalf("split/join mismatch: got %q want %q", got, text) + } +} + +func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { + var requests []map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + requests = append(requests, got) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Logger: slog.CreateLogger(), + } + kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd") + text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32) + + messages := ctx.KeyboardLong(text, kb) + if got := len(messages); got != 2 { + t.Fatalf("expected 2 sent messages, got %d", got) + } + if got := len(requests); got != 2 { + t.Fatalf("expected 2 requests, got %d", got) + } + if _, ok := requests[0]["reply_markup"]; ok { + t.Fatal("did not expect keyboard on first chunk") + } + if _, ok := requests[1]["reply_markup"]; !ok { + t.Fatal("expected keyboard on final chunk") + } + + gotTexts := []string{requests[0]["text"].(string), requests[1]["text"].(string)} + wantTexts := SplitMessageText(text) + if !reflect.DeepEqual(gotTexts, wantTexts) { + t.Fatalf("unexpected chunk texts: got %q want %q", gotTexts, wantTexts) + } +} diff --git a/plugins.go b/plugins.go index 627ba03..656663c 100644 --- a/plugins.go +++ b/plugins.go @@ -81,7 +81,8 @@ func (c CommandArg) SetRequired() CommandArg { // CommandExecutor is the function type that executes a command. // It receives the message context and a database context (generic). -type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) +// Returning a non-nil error routes it through the bot's error handler. +type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) error // Command represents a bot command with arguments, description, and executor. // Can be registered in a Plugin and optionally skipped from auto-generation. @@ -309,7 +310,9 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) { } // Execute command - command.exec(ctx, db) + if err := command.exec(ctx, db); err != nil { + ctx.error(err) + } } // Internal helper that validates and executes a payload handler. @@ -333,7 +336,9 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) { } // Execute payload - command.exec(ctx, db) + if err := command.exec(ctx, db); err != nil { + ctx.error(err) + } } // Internal helper that runs plugin middlewares in order. diff --git a/plugins_test.go b/plugins_test.go index 8b95147..d2d44c5 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -6,7 +6,7 @@ import ( ) func TestValidateArgsRequiresFullMatch(t *testing.T) { - intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) + intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) if err := intCmd.validateArgs([]string{"123"}); err != nil { t.Fatalf("expected valid integer argument, got %v", err) } @@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) } - boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) + boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) if err := boolCmd.validateArgs([]string{"false"}); err != nil { t.Fatalf("expected valid bool argument, got %v", err) } @@ -25,7 +25,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { cmd := NewCommand[NoDB]( - func(ctx *MsgContext, db NoDB) {}, + func(ctx *MsgContext, db NoDB) error { return nil }, "mixed", NewCommandArg("optional"), NewCommandArg("required").SetRequired(), diff --git a/split.go b/split.go new file mode 100644 index 0000000..95896f6 --- /dev/null +++ b/split.go @@ -0,0 +1,44 @@ +package laniakea + +// SplitMessageText splits plain text into Telegram-safe message chunks. +// +// The function preserves the original text exactly: concatenating all returned +// chunks reconstructs text byte-for-byte. It prefers splitting at newlines or +// spaces within the Telegram message limit and falls back to hard rune-based +// splits when no separator is available. +func SplitMessageText(text string) []string { + return splitTextByLimit(text, maxMessageTextLen) +} + +func splitTextByLimit(text string, limit int) []string { + if text == "" { + return nil + } + + runes := []rune(text) + chunks := make([]string, 0, len(runes)/limit+1) + + for start := 0; start < len(runes); { + end := start + limit + if end >= len(runes) { + chunks = append(chunks, string(runes[start:])) + break + } + + splitAt := -1 + for i := end - 1; i > start; i-- { + if runes[i] == '\n' || runes[i] == ' ' { + splitAt = i + 1 + break + } + } + if splitAt == -1 { + splitAt = end + } + + chunks = append(chunks, string(runes[start:splitAt])) + start = splitAt + } + + return chunks +} diff --git a/utils/version.go b/utils/version.go index 81a081e..fb41955 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,7 +2,7 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0-rc.11" + VersionString = "1.0.0-rc.12" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. @@ -10,5 +10,5 @@ const ( // VersionPatch is the module patch version. VersionPatch = 0 // VersionBeta is the prerelease counter for the current version. - VersionBeta = 11 + VersionBeta = 12 ) -- 2.54.0 From 8618397bc15848fcad1339783428c44d07c11a1b Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 26 Mar 2026 18:35:35 +0300 Subject: [PATCH 59/99] wip: expand docs and wiki structure add wiki links to README and README_RU fill Start here pages for setup, commands/plugins, and MsgContext add wiki page-priority tracker and local wiki AGENTS rules --- .gitignore | 1 + AGENTS.md | 10 ++++++++++ CHANGELOG.md | 5 +++++ README.md | 4 ++++ README_RU.md | 4 ++++ 5 files changed, 24 insertions(+) diff --git a/.gitignore b/.gitignore index f26b8e6..3baab3b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ test/ +.wiki/ diff --git a/AGENTS.md b/AGENTS.md index a1af97c..c40597f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,16 @@ Prefer the repository’s documented commands. If multiple choices exist, use th - Prefer additive compatibility over signature changes when the additive option is small and maintainable. - Example: if a method like `ctx.answer(...)` needs an extra parameter, the agent must either require a major-version bump or add a new method that keeps the old method working. +## Commit message format +- When the user asks for a commit message, the agent must produce it in this format: + 1. a short summary line; + 2. up to three additional lines with only the most important changes; + 3. each additional line must start on its own new line. +- The agent must output the commit message as a plain multiline block that the user can copy directly. +- Do not collapse the lines into a paragraph, bullet list, or wrapped prose explanation. +- Keep commit text concise and high-signal. +- Do not turn commit messages into changelogs. + ## Output format For repo-wide review tasks, structure the result as: diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a826e..ae5b957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,13 @@ ### Changed - `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors. - README and README_RU examples now use the new handler signature and document the long-message helpers. +- README and README_RU now link to the project wiki, and the wiki now includes a page-priority tracker while content is being filled in. - `AGENTS.md` now requires every change to be recorded in `CHANGELOG.md`, enforces version alignment with `utils/version.go`, and blocks breaking changes without a major-version bump. +- `AGENTS.md` now also defines a short commit-message format: one summary line plus up to three high-signal detail lines. +- `AGENTS.md` now explicitly requires each commit-message detail line to be placed on its own new line. +- `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting. - Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides. +- The wiki `Start here` section now has substantive first-pass documentation for setup, commands/plugins, and `MsgContext`. - Version constants were bumped to `v1.0.0-rc.12`. ### Fixed diff --git a/README.md b/README.md index 501e882..2350721 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s [На русском](README_RU.md) +[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) + --- ## ✨ Features @@ -247,6 +249,8 @@ This project is licensed under the GNU General Public License v3.0 — see the [ ## 📚 Learn More [GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) +[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) + [Telegram Bot API](https://core.telegram.org/bots/api) ✅ Built with ❤️ by scuroneko diff --git a/README_RU.md b/README_RU.md index 85579d6..dbefd88 100644 --- a/README_RU.md +++ b/README_RU.md @@ -10,6 +10,8 @@ [English](README.md) +[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) + --- ## ✨ Возможности @@ -243,6 +245,8 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { ## 📚 Дополнительная информация [GoDoc Laniakea](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) +[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) + [Telegram Bot API](https://core.telegram.org/bots/api) ✅ Создано с ❤️ scuroneko -- 2.54.0 From 0ee0917af55362d51c87bcfbf0e3f4e11bee9286 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 26 Mar 2026 23:06:05 +0300 Subject: [PATCH 60/99] Update repo agent documentation rules Require paired English and Russian docs when expanding project documentation Clarify changelog handling for main repo changes and keep rc.12 notes aligned Ignore local editor and Codex config directories in git --- .gitignore | 4 +++- AGENTS.md | 9 ++++++++- CHANGELOG.md | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3baab3b..88b114b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea/ -test/ .wiki/ +.vscode/ +test/ +.codex/ diff --git a/AGENTS.md b/AGENTS.md index c40597f..8deb1aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,12 @@ Review the codebase with focus on: - When feasible, make small, high-confidence improvements directly. - When uncertain, state confidence level and evidence. +## Documentation languages +- When creating or expanding project documentation, generate and maintain both English and Russian versions in the same turn whenever reasonably possible. +- For wiki pages, prefer paired pages such as `Page.md` and `Page-RU.md`. +- Keep English and Russian pages aligned in structure, major examples, and user-facing guidance. +- If only one language can be updated safely in the current turn, explicitly say which language is lagging and why. + ## Go review expectations Check for: - bugs, fragile logic, invalid assumptions, nil handling issues, resource leaks; @@ -77,7 +83,8 @@ Before finalizing changes, run the relevant project checks when available: Prefer the repository’s documented commands. If multiple choices exist, use the most standard and least destructive ones first. ## Versioning and changelog -- After every code or documentation change, update `CHANGELOG.md`. +- After every code or documentation change in the main repository, update `CHANGELOG.md`. +- Changes made only inside the `.wiki/` repository do not require a `CHANGELOG.md` update. - Add changes only to the section for the next version after the latest published git tag. - The agent must check the latest published tag, `CHANGELOG.md`, and `utils/version.go` before editing the changelog. - The agent must verify that the target changelog version matches the version declared in `utils/version.go`. diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5b957..3ae8d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,8 @@ - `AGENTS.md` now also defines a short commit-message format: one summary line plus up to three high-signal detail lines. - `AGENTS.md` now explicitly requires each commit-message detail line to be placed on its own new line. - `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting. +- `AGENTS.md` now requires new or expanded project documentation to be maintained in both English and Russian whenever reasonably possible. - Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides. -- The wiki `Start here` section now has substantive first-pass documentation for setup, commands/plugins, and `MsgContext`. - Version constants were bumped to `v1.0.0-rc.12`. ### Fixed -- 2.54.0 From 68e7529f1674021b1d0f39fa592107379f5c5053 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 26 Mar 2026 23:27:54 +0300 Subject: [PATCH 61/99] Add framework backlog and signed-commit policy Document scene modal-chat flow and other missing core concepts in TODO.md Require agent-created commits to be GPG-signed and fail fast if signing cannot complete --- AGENTS.md | 5 ++ CHANGELOG.md | 2 + TODO.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 TODO.md diff --git a/AGENTS.md b/AGENTS.md index 8deb1aa..8d591ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,11 @@ Prefer the repository’s documented commands. If multiple choices exist, use th - Keep commit text concise and high-signal. - Do not turn commit messages into changelogs. +## Commit signing +- All commits created by the agent must be GPG-signed. +- If commit signing or pushing requires leaving the sandbox, the agent must request escalation explicitly before running the command. +- If a signed commit cannot be created successfully, the agent must report the failure clearly and stop instead of creating an unsigned fallback commit. + ## Output format For repo-wide review tasks, structure the result as: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae8d46..b371f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - `AGENTS.md` now explicitly requires each commit-message detail line to be placed on its own new line. - `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting. - `AGENTS.md` now requires new or expanded project documentation to be maintained in both English and Russian whenever reasonably possible. +- `AGENTS.md` now requires all agent-created commits to be GPG-signed and to fail fast instead of falling back to unsigned commits when signing cannot be completed. +- Added `TODO.md` to track missing framework-level concepts, with detailed notes for scenes, typed handler input, and request-scoped cancellation. - Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides. - Version constants were bumped to `v1.0.0-rc.12`. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..61cce1e --- /dev/null +++ b/TODO.md @@ -0,0 +1,136 @@ +# TODO + +This file tracks framework-level backlog items that are about missing concepts in the library itself, not just missing documentation. + +## High-Priority Core Concepts + +### 1. Conversation / Scene Model + +Current state: +- The framework is strong at handling a single update through commands, payloads, middleware, and update handlers. +- It already has useful lower-level building blocks such as `MsgContext`, drafts, payload routing, plugins, and update handlers. +- It does not yet provide a first-class concept for long-lived user interaction flows. + +Why this matters: +- Many Telegram bots quickly move beyond isolated commands and need stateful multi-step flows. +- Real bots often need concepts like "wait for the user's next message", "user is currently on step 3 of 5", or "button press moves the user to the next scene state". +- Without a scene model, library users end up building their own mini-framework on top of Laniakea. + +What is missing: +- A way to route updates to an active scene before normal command routing. +- A way to persist conversation state per user or per chat. +- A way to describe steps and transitions without hand-rolling state machines around middleware and storage. +- A way to enter, continue, cancel, and complete a conversation flow explicitly. +- A way to support modal chat flows where the user is "inside" a scene and ordinary text is treated as scene input until an explicit escape command exits the mode. + +Possible API direction: +- `Scene`, `Step`, and `SessionStore` concepts. +- `bot.AddScene(...)` or a dedicated scene registry. +- `ctx.Scene()`, `ctx.NextStep(...)`, `ctx.ExitScene()`, or similar state-transition helpers. +- Routing rule: active scene first, then normal command/payload flow if no scene claims the update. +- Storage-backed per-user or per-chat state with a clean interface for custom persistence. +- Scene-local escape and passthrough commands, so flows like `/startrp` can put a user into a dedicated chat mode where most messages go straight to the scene, while commands like `/exit` or a small whitelist still retain special meaning. + +Important design constraints: +- This should be additive and optional. +- It should not replace plugins, commands, or handlers as the normal framework entry points. +- It should work with existing middleware and `MsgContext` instead of introducing a second incompatible execution model. + +Practical target: +- Make stateful bot flows a first-class, framework-supported pattern instead of a userland convention. +- Cover both step-based forms and mode-based chat flows without forcing users to build custom routing layers around active sessions. + +### 2. Typed Handler Input Model + +Current state: +- Commands and payloads currently expose parsed text through `ctx.Text` and `ctx.Args`. +- `CommandArg` provides basic argument validation and shape checks. +- Handlers still do most non-trivial parsing manually. + +Why this matters: +- As bots grow, handlers often start with repetitive `ctx.Args` parsing boilerplate. +- Validation logic tends to spread across handlers instead of living in one predictable binding layer. +- The current model is simple and honest, but it does not help enough once commands become more structured. + +What is missing: +- A first-class way to bind command or payload arguments into a typed Go value. +- A framework-level pattern for conversion errors and validation errors beyond raw string handling. +- A low-friction way to move from positional arguments to a structured input object. + +Possible API direction: +- A lightweight binding API such as `ctx.BindArgs(&input)`. +- Or explicit typed command registration such as `NewCommandTyped(...)`. +- Positional mapping into structs, optional fields, basic conversion support, and integration with current validation flow. +- Unified binding and validation failures routed through the current centralized error path. + +Example of the kind of user code this should enable: + +```go +type BanInput struct { + UserID int + Reason string +} + +func ban(ctx *laniakea.MsgContext, db *App) error { + var input BanInput + if err := ctx.BindArgs(&input); err != nil { + return err + } + + return db.Ban(input.UserID, input.Reason) +} +``` + +Important design constraints: +- Avoid a reflection-heavy, magical subsystem. +- Keep the current `ctx.Args` model as the minimal baseline. +- Treat typed binding as an ergonomic layer on top of the current command model, not a replacement for it. + +Practical target: +- Remove repetitive parsing boilerplate while preserving the framework's explicit, Go-like feel. + +### 3. Request Context / Cancellation Model + +Current state: +- `RunWithContext(...)` controls bot runtime lifecycle and graceful shutdown. +- `tgapi` already supports context-aware methods. +- Regular handlers do not receive a first-class request-scoped `context.Context`. + +Why this matters: +- Handler business logic often needs cancellation-aware database calls, HTTP calls, or downstream service calls. +- The framework already has a good runtime cancellation story, but it does not flow naturally into user code inside handlers. +- In modern Go APIs, `context.Context` is a standard part of operational correctness. + +What is missing: +- A clean request-scoped context that follows each update through handler execution. +- A standard way for application code to stop work when the bot is shutting down or the update processing context is canceled. +- A direct bridge between bot lifecycle control and service-layer cancellation. + +Possible API direction: +- Prefer a non-breaking approach by exposing context through `MsgContext`, for example `ctx.Context()`. +- Build the context from the update-processing lifecycle so it is meaningful during graceful shutdown. +- Make it natural to pass that context into database methods, HTTP clients, and `tgapi.WithContext(...)` calls. + +Why this should probably not be a signature change: +- Changing handler signatures to accept `context.Context` directly would be a public breaking change. +- A `MsgContext` accessor would preserve compatibility while still giving handlers an idiomatic Go cancellation path. + +Practical target: +- Let handler code participate naturally in cancellation and graceful shutdown without forcing users to invent their own context plumbing. + +## Secondary Backlog + +- Webhook runtime model: the library has a solid polling model, but no first-class webhook execution model at the framework level. +- Service layer and dependency graph model: `DatabaseContext(T)` is intentionally minimal, but there is no stronger framework concept for application services or scoped dependencies. +- User-facing vs internal error model: the framework has a unified error flow, but it does not yet distinguish well between user-visible, internal-only, retryable, or silent errors. +- Authorization and policy model: middleware can implement auth and permissions, but there is no explicit framework concept for access policies, roles, or capability checks. +- Observability model: logging is strong, but metrics, tracing, and structured framework hooks are still missing as first-class concepts. +- Plugin composition contract: plugins are a good grouping unit, but there is no explicit model for plugin dependencies, shared capabilities, or composition contracts. +- Update schema contract: update handling exists, but there is no formal framework-level concept describing which `MsgContext` fields are guaranteed in which update kinds. +- Configuration freeze model: the framework already has real commit points like `AddPlugins(...)`, but this is still more of an implementation truth than an explicit top-level concept. + +## Suggested Priority + +1. Request context / cancellation model +2. Conversation / scene model +3. Typed handler input model -- 2.54.0 From 4f8d583b03ce3e633284c8c09f9074c8a63c8097 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 27 Mar 2026 16:05:20 +0300 Subject: [PATCH 62/99] wip: scene sessions --- AGENTS.md | 11 +++ CHANGELOG.md | 12 +++ TODO.md | 137 ++----------------------------- bot.go | 42 +++++++++- bot_scene.go | 61 ++++++++++++++ errors.go | 14 ++++ handler.go | 71 +++++++++------- handler_test.go | 91 ++++++++++++++++++++- msg_context.go | 180 ++++++++++++++++++++++++++++++++++++++-- msg_context_test.go | 101 +++++++++++++++++++++++ plugins.go | 14 ++++ scene.go | 195 ++++++++++++++++++++++++++++++++++++++++++++ scene_context.go | 23 ++++++ scene_handler.go | 130 +++++++++++++++++++++++++++++ 14 files changed, 909 insertions(+), 173 deletions(-) create mode 100644 bot_scene.go create mode 100644 scene.go create mode 100644 scene_context.go create mode 100644 scene_handler.go diff --git a/AGENTS.md b/AGENTS.md index 8d591ee..ade1677 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,12 @@ Review the codebase with focus on: - Keep English and Russian pages aligned in structure, major examples, and user-facing guidance. - If only one language can be updated safely in the current turn, explicitly say which language is lagging and why. +## Wiki and backlog workflow +- Treat the wiki as the primary place for large design ideas, architectural drafts, and framework backlog notes. +- If the agent identifies a substantial new concept or design direction, such as scenes, callback agents, a webhook model, or another framework-level abstraction, the agent must ask the user whether it should also formalize that idea as a draft wiki page. +- When the user agrees, prefer paired wiki pages such as `Page.md` and `Page-RU.md`, and clearly mark draft design pages with `DRAFT` when the API is not implemented or not yet stable. +- Keep `TODO.md`, the wiki backlog pages, and `CHANGELOG.md` aligned when framework-level items move between planned and completed states. + ## Go review expectations Check for: - bugs, fragile logic, invalid assumptions, nil handling issues, resource leaks; @@ -95,6 +101,11 @@ Prefer the repository’s documented commands. If multiple choices exist, use th - The agent must not guess the next version when that section is missing. - If the user-selected version does not match `utils/version.go`, the agent must warn about the mismatch and require the version file to be updated before proceeding. - Changelog entries must describe all user-visible behavior changes made in the turn, including API additions, fixes, behavior changes, and breaking changes. +- When a framework backlog item recorded in `TODO.md` is completed, the agent must also update the backlog status using the existing format: + 1. move the completed item into the top of the `Done` section; + 2. replace the numbered backlog label with a version tag, for example `1. Scene Model` becomes `[v2.0.0] Scene Model`; + 3. keep the item title and descriptive notes aligned with the corresponding `CHANGELOG.md` entry. +- The agent must treat `TODO.md` and `CHANGELOG.md` as linked records: a completed backlog item should not be left in one file as done and in the other as still pending or undocumented. ## Breaking changes policy - The agent must detect potential breaking changes before editing public APIs. diff --git a/CHANGELOG.md b/CHANGELOG.md index b371f70..ae55230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - `AnswerLong(...)`, `AnswerLongf(...)`, `KeyboardLong(...)`, and `SplitMessageText(...)` for explicit plain-text splitting of long replies without changing the semantics of existing single-message helpers. - Centralized library-level validation errors in `errors.go`, including `ErrEmptyMessage`, `ErrMessageTooLong`, `ErrCaptionTooLong`, and context/target validation sentinels. - `Bot.GetPayloadType()`, `InlineKeyboard.GetPayloadType()`, and optional strict payload decoding via `BotOpts.StrictPayloadType` / `Bot.SetStrictPayloadType(...)`. +- `MsgContext.BindArgs(...)` for binding positional command arguments into exported struct fields. +- Binding sentinels `ErrBindArgsTargetNotPointer`, `ErrBindArgsTargetNotStruct`, `ErrBindArgsUnsupportedFieldType`, and `ErrBindArgsConversion`. ### Changed - `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors. @@ -17,19 +19,29 @@ - `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting. - `AGENTS.md` now requires new or expanded project documentation to be maintained in both English and Russian whenever reasonably possible. - `AGENTS.md` now requires all agent-created commits to be GPG-signed and to fail fast instead of falling back to unsigned commits when signing cannot be completed. +- `AGENTS.md` now also links the wiki backlog flow more tightly to `TODO.md` and `CHANGELOG.md`, requiring draft-wiki confirmation for large new ideas and synchronized completion records for backlog items. - Added `TODO.md` to track missing framework-level concepts, with detailed notes for scenes, typed handler input, and request-scoped cancellation. - Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides. +- `MsgContext.Context()` now safely falls back to `context.Background()` when no request-scoped context is attached. +- `MsgContext` reply, edit, callback, delete, action, and draft-limiter paths now use the context accessor instead of reaching into raw internal state. +- `TODO.md` is now a short pointer file, while the detailed framework backlog lives in the wiki as `Framework-Backlog` / `Framework-Backlog-RU`. - Version constants were bumped to `v1.0.0-rc.12`. ### Fixed - Message and caption validation now runs before Telegram API calls, rejecting empty messages, oversized message text, and oversized captions with stable sentinel errors. - Draft flushing and draft updates now reject oversized messages before sending invalid requests. - Callback payload decoding now optionally enforces strict type matching, while the default tolerant mode logs Base64-to-JSON decoding in debug mode and still accepts keyboard-local payload overrides. +- Positional argument binding now leaves missing trailing struct fields at zero values, joins the remaining arguments into the final string field, and returns clearer binding errors. +- Request-scoped contexts are now created per update handler execution and safely reused through `MsgContext.Context()` even for manually constructed test contexts. +- Command and payload handlers now have regression coverage for end-to-end typed argument binding through the normal routing path. ### Breaking Changes - `CommandExecutor[T]` changed from `func(ctx *MsgContext, db T)` to `func(ctx *MsgContext, db T) error`. - `Plugin.NewCommand(...)`, `Plugin.NewPayload(...)`, and `Plugin.AddUpdateHandler(...)` now require handlers with the new error-returning signature. +### Tests +- Added regression tests for `MsgContext.BindArgs(...)`, including scalar conversion, tail-string binding, zero-value trailing fields, invalid targets, unsupported field types, and end-to-end command/payload binding. + ## v1.0.0-rc.11 ### Fixed diff --git a/TODO.md b/TODO.md index 61cce1e..1038fc4 100644 --- a/TODO.md +++ b/TODO.md @@ -1,136 +1,17 @@ # TODO -This file tracks framework-level backlog items that are about missing concepts in the library itself, not just missing documentation. +The framework backlog has moved to the wiki. -## High-Priority Core Concepts +Primary page: -### 1. Conversation / Scene Model +- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog -Current state: -- The framework is strong at handling a single update through commands, payloads, middleware, and update handlers. -- It already has useful lower-level building blocks such as `MsgContext`, drafts, payload routing, plugins, and update handlers. -- It does not yet provide a first-class concept for long-lived user interaction flows. +Russian page: -Why this matters: -- Many Telegram bots quickly move beyond isolated commands and need stateful multi-step flows. -- Real bots often need concepts like "wait for the user's next message", "user is currently on step 3 of 5", or "button press moves the user to the next scene state". -- Without a scene model, library users end up building their own mini-framework on top of Laniakea. +- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog-RU -What is missing: -- A way to route updates to an active scene before normal command routing. -- A way to persist conversation state per user or per chat. -- A way to describe steps and transitions without hand-rolling state machines around middleware and storage. -- A way to enter, continue, cancel, and complete a conversation flow explicitly. -- A way to support modal chat flows where the user is "inside" a scene and ordinary text is treated as scene input until an explicit escape command exits the mode. +Current high-priority status: -Possible API direction: -- `Scene`, `Step`, and `SessionStore` concepts. -- `bot.AddScene(...)` or a dedicated scene registry. -- `ctx.Scene()`, `ctx.NextStep(...)`, `ctx.ExitScene()`, or similar state-transition helpers. -- Routing rule: active scene first, then normal command/payload flow if no scene claims the update. -- Storage-backed per-user or per-chat state with a clean interface for custom persistence. -- Scene-local escape and passthrough commands, so flows like `/startrp` can put a user into a dedicated chat mode where most messages go straight to the scene, while commands like `/exit` or a small whitelist still retain special meaning. - -Important design constraints: -- This should be additive and optional. -- It should not replace plugins, commands, or handlers as the normal framework entry points. -- It should work with existing middleware and `MsgContext` instead of introducing a second incompatible execution model. - -Practical target: -- Make stateful bot flows a first-class, framework-supported pattern instead of a userland convention. -- Cover both step-based forms and mode-based chat flows without forcing users to build custom routing layers around active sessions. - -### 2. Typed Handler Input Model - -Current state: -- Commands and payloads currently expose parsed text through `ctx.Text` and `ctx.Args`. -- `CommandArg` provides basic argument validation and shape checks. -- Handlers still do most non-trivial parsing manually. - -Why this matters: -- As bots grow, handlers often start with repetitive `ctx.Args` parsing boilerplate. -- Validation logic tends to spread across handlers instead of living in one predictable binding layer. -- The current model is simple and honest, but it does not help enough once commands become more structured. - -What is missing: -- A first-class way to bind command or payload arguments into a typed Go value. -- A framework-level pattern for conversion errors and validation errors beyond raw string handling. -- A low-friction way to move from positional arguments to a structured input object. - -Possible API direction: -- A lightweight binding API such as `ctx.BindArgs(&input)`. -- Or explicit typed command registration such as `NewCommandTyped(...)`. -- Positional mapping into structs, optional fields, basic conversion support, and integration with current validation flow. -- Unified binding and validation failures routed through the current centralized error path. - -Example of the kind of user code this should enable: - -```go -type BanInput struct { - UserID int - Reason string -} - -func ban(ctx *laniakea.MsgContext, db *App) error { - var input BanInput - if err := ctx.BindArgs(&input); err != nil { - return err - } - - return db.Ban(input.UserID, input.Reason) -} -``` - -Important design constraints: -- Avoid a reflection-heavy, magical subsystem. -- Keep the current `ctx.Args` model as the minimal baseline. -- Treat typed binding as an ergonomic layer on top of the current command model, not a replacement for it. - -Practical target: -- Remove repetitive parsing boilerplate while preserving the framework's explicit, Go-like feel. - -### 3. Request Context / Cancellation Model - -Current state: -- `RunWithContext(...)` controls bot runtime lifecycle and graceful shutdown. -- `tgapi` already supports context-aware methods. -- Regular handlers do not receive a first-class request-scoped `context.Context`. - -Why this matters: -- Handler business logic often needs cancellation-aware database calls, HTTP calls, or downstream service calls. -- The framework already has a good runtime cancellation story, but it does not flow naturally into user code inside handlers. -- In modern Go APIs, `context.Context` is a standard part of operational correctness. - -What is missing: -- A clean request-scoped context that follows each update through handler execution. -- A standard way for application code to stop work when the bot is shutting down or the update processing context is canceled. -- A direct bridge between bot lifecycle control and service-layer cancellation. - -Possible API direction: -- Prefer a non-breaking approach by exposing context through `MsgContext`, for example `ctx.Context()`. -- Build the context from the update-processing lifecycle so it is meaningful during graceful shutdown. -- Make it natural to pass that context into database methods, HTTP clients, and `tgapi.WithContext(...)` calls. - -Why this should probably not be a signature change: -- Changing handler signatures to accept `context.Context` directly would be a public breaking change. -- A `MsgContext` accessor would preserve compatibility while still giving handlers an idiomatic Go cancellation path. - -Practical target: -- Let handler code participate naturally in cancellation and graceful shutdown without forcing users to invent their own context plumbing. - -## Secondary Backlog - -- Webhook runtime model: the library has a solid polling model, but no first-class webhook execution model at the framework level. -- Service layer and dependency graph model: `DatabaseContext(T)` is intentionally minimal, but there is no stronger framework concept for application services or scoped dependencies. -- User-facing vs internal error model: the framework has a unified error flow, but it does not yet distinguish well between user-visible, internal-only, retryable, or silent errors. -- Authorization and policy model: middleware can implement auth and permissions, but there is no explicit framework concept for access policies, roles, or capability checks. -- Observability model: logging is strong, but metrics, tracing, and structured framework hooks are still missing as first-class concepts. -- Plugin composition contract: plugins are a good grouping unit, but there is no explicit model for plugin dependencies, shared capabilities, or composition contracts. -- Update schema contract: update handling exists, but there is no formal framework-level concept describing which `MsgContext` fields are guaranteed in which update kinds. -- Configuration freeze model: the framework already has real commit points like `AddPlugins(...)`, but this is still more of an implementation truth than an explicit top-level concept. - -## Suggested Priority - -1. Request context / cancellation model -2. Conversation / scene model -3. Typed handler input model +- `1. Conversation / Scene Model`: not implemented yet. +- `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`. +- `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`. diff --git a/bot.go b/bot.go index b76ad37..00753cc 100644 --- a/bot.go +++ b/bot.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "maps" "reflect" + "slices" "sort" "strings" "sync" @@ -102,6 +104,9 @@ type Bot[T DbContext] struct { l10n *L10n // Localization manager draftProvider *DraftProvider // Draft message builder + sessionStore SessionStore // Session store for scene management + sceneScopePriority []SceneScope + updateOffsetMu sync.Mutex updateOffset int // Last processed update ID updateTypes []tgapi.UpdateType // Types of updates to fetch @@ -174,6 +179,9 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { extraLoggers: make([]*slog.Logger, 0), l10n: &L10n{}, draftProvider: NewRandomDraftProvider(api), + + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } // Add API and Uploader loggers to extraLoggers for unified output @@ -334,6 +342,34 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { bot.draftProvider = p return bot } +func (bot *Bot[T]) GetDraftProvider() *DraftProvider { + return bot.draftProvider +} + +func (bot *Bot[T]) SetSettionStore(store SessionStore) *Bot[T] { + bot.sessionStore = store + return bot +} +func (bot *Bot[T]) GetSessionStore() SessionStore { + return bot.sessionStore +} + +func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { + newPriority := make([]SceneScope, 0, 3) + for _, scope := range priority { + if slices.Index(newPriority, scope) >= 0 { + bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope)) + continue + } + newPriority = append(newPriority, scope) + } + if len(newPriority) == 0 || len(newPriority) > 3 { + bot.logger.Warnln("scene scope priority must have 1 to 3 scopes; ignoring invalid input") + return bot + } + bot.sceneScopePriority = append([]SceneScope(nil), newPriority...) + return bot +} // DatabaseContext injects a database context into the bot. // This context is accessible to plugins and middleware via GetDBContext(). @@ -668,7 +704,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { for update := range bot.updateQueue { u := update // capture loop variable pool.Submit(func() { - bot.handle(u) + bot.handle(ctx, u) }) } pool.Stop() // Wait for all tasks to complete and stop the pool @@ -759,9 +795,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { for name, command := range p.payloads { cloned.payloads[name] = cloneCommand(command) } - for t, handler := range p.handlers { - cloned.handlers[t] = handler - } + maps.Copy(cloned.handlers, p.handlers) return cloned } diff --git a/bot_scene.go b/bot_scene.go new file mode 100644 index 0000000..d8136eb --- /dev/null +++ b/bot_scene.go @@ -0,0 +1,61 @@ +package laniakea + +func (bot *Bot[T]) GetSession(key string) (SceneSession, error) { + return bot.sessionStore.Get(key) +} + +func (bot *Bot[T]) SetSession(key string, session SceneSession) error { + return bot.sessionStore.Set(key, session) +} + +func (bot *Bot[T]) DeleteSession(key string) error { + return bot.sessionStore.Delete(key) +} +func (bot *Bot[T]) FindScene(name string) (*sceneMeta, bool) { + for _, plugin := range bot.plugins { + scene, ok := plugin.scenes[name] + if !ok { + continue + } + + steps := make(map[string]struct{}, len(scene.steps)) + for step := range scene.steps { + steps[step] = struct{}{} + } + + return &sceneMeta{ + Name: scene.Name, + Scope: scene.Scope, + Entry: scene.Entry, + Steps: steps, + }, true + } + return nil, false +} + +func (bot *Bot[T]) FindSceneSession(ctx *MsgContext) (string, SceneSession, error) { + var zero SceneSession + if ctx.Msg == nil { + return "", zero, ErrMessageNil + } + + for _, scope := range bot.sceneScopePriority { + key, ok := buildSceneKey(scope, ctx) + if !ok { + continue + } + + session, err := bot.sessionStore.Get(key) + if err != nil { + return "", zero, err + } + if session.Scene != "" { + return key, session, nil + } + } + + return "", zero, ErrCantFindSession +} +func (bot *Bot[T]) BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { + return buildSceneKey(scope, ctx) +} diff --git a/errors.go b/errors.go index 09d5425..40c4cbb 100644 --- a/errors.go +++ b/errors.go @@ -24,6 +24,7 @@ var ( ErrPayloadTypeMismatch = errors.New("payload type mismatch") // ErrDraftChatIDZero reports that a draft has no target chat ID. ErrDraftChatIDZero = errors.New("zero draft chat ID") + ErrMessageNil = errors.New("message is nil") // ErrMessageContextNil reports that an operation requires ctx.Msg but none is set. ErrMessageContextNil = errors.New("message context is nil") // ErrEditTargetMissing reports that an edit operation has no message target. @@ -36,6 +37,19 @@ var ( ErrAPIIsNil = errors.New("api is nil") // ErrMessageIDZero reports that an operation requires a non-zero message ID. ErrMessageIDZero = errors.New("message ID is zero") + // ErrBindArgsTargetNotPointer reports that BindArgs received a nil or non-pointer destination. + ErrBindArgsTargetNotPointer = errors.New("bind args: dst must be a non-nil pointer") + // ErrBindArgsTargetNotStruct reports that BindArgs received a pointer to a non-struct value. + ErrBindArgsTargetNotStruct = errors.New("bind args: dst must point to a struct") + // ErrBindArgsUnsupportedFieldType reports that BindArgs encountered an unsupported field kind. + ErrBindArgsUnsupportedFieldType = errors.New("bind args: unsupported field type") + // ErrBindArgsConversion reports that BindArgs could not convert a string argument into a field type. + ErrBindArgsConversion = errors.New("bind args: conversion failed") + ErrCantFindSession = errors.New("can't find session for this context") + ErrSceneNotFound = errors.New("scene not found") + ErrSceneStepNotFound = errors.New("scene step not found") + ErrSceneCommandNotFound = errors.New("scene command not found") + ErrNotInScene = errors.New("not in scene") ) func validateMessageText(text string) error { diff --git a/handler.go b/handler.go index 90f0126..dd9bcd0 100644 --- a/handler.go +++ b/handler.go @@ -1,6 +1,7 @@ package laniakea import ( + "context" "encoding/base64" "encoding/json" "errors" @@ -13,36 +14,50 @@ import ( // ErrInvalidPayloadType is returned when callback payload encoding type is unknown. var ErrInvalidPayloadType = errors.New("invalid payload type") -func (bot *Bot[T]) handle(u *tgapi.Update) { +func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { defer func() { if r := recover(); r != nil { bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) } }() - ctx := &MsgContext{ + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + msgCtx := &MsgContext{ Update: *u, Api: bot.api, Logger: bot.logger, errorTemplate: bot.errorTemplate, l10n: bot.l10n, draftProvider: bot.draftProvider, + sceneRuntime: bot, payloadType: bot.payloadType, + ctx: ctx, } - bot.prepareUpdateCtx(u, ctx) + bot.prepareUpdateCtx(u, msgCtx) for _, middleware := range bot.middlewares { - if !middleware.Execute(ctx, bot.dbContext) { + if !middleware.Execute(msgCtx, bot.dbContext) { return } } + sceneHandled, err := bot.tryHandleScene(msgCtx) + if err != nil { + bot.logger.Errorln(err) + return + } + if sceneHandled { + return + } + switch u.Type { case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost: - bot.handleMessage(u, ctx) + bot.handleMessage(u, msgCtx) case tgapi.UpdateTypeCallbackQuery: - bot.handleCallback(u, ctx) + bot.handleCallback(u, msgCtx) default: - bot.handleUpdate(u, ctx) + bot.handleUpdate(u, msgCtx) } } @@ -65,30 +80,11 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { return } - text = strings.TrimSpace(text) - prefix, hasPrefix := bot.checkPrefixes(text) - if !hasPrefix { + prefix, cmd, args := bot.parseCommand(text) + if cmd == "" { return } - ctx.Prefix = prefix - ctx.Update = *update - - // Убираем префикс - text = strings.TrimSpace(text[len(prefix):]) - - // Извлекаем команду как первое слово - spaceIndex := strings.Index(text, " ") - var cmd string - var args string - - if spaceIndex == -1 { - cmd = text - args = "" - } else { - cmd = text[:spaceIndex] - args = strings.TrimSpace(text[spaceIndex:]) - } if strings.Contains(cmd, "@") { botUsername := bot.username @@ -269,12 +265,12 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { ctx.From = from ctx.FromID = from.ID } - ctx.Update = *u } func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { for _, prefix := range bot.prefixes { if prefix == "" { + bot.logger.Warnln("empty prefix is not allowed") continue } if strings.HasPrefix(text, prefix) { @@ -283,6 +279,23 @@ func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { } return "", false } +func (bot *Bot[T]) parseCommand(text string) (prefix, cmd, args string) { + if prefix, hasPrefix := bot.checkPrefixes(text); hasPrefix { + text = strings.TrimSpace(text[len(prefix):]) + spaceIndex := strings.Index(text, " ") + var cmd string + var args string + if spaceIndex == -1 { + cmd = text + args = "" + } else { + cmd = text[:spaceIndex] + args = strings.TrimSpace(text[spaceIndex:]) + } + return prefix, cmd, args + } + return "", "", "" +} func encodeJsonPayload(d CallbackData) (string, error) { b, err := json.Marshal(d) diff --git a/handler_test.go b/handler_test.go index 485f656..d5a60e9 100644 --- a/handler_test.go +++ b/handler_test.go @@ -1,6 +1,7 @@ package laniakea import ( + "context" "testing" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -35,7 +36,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) { }, } - bot.handle(&tgapi.Update{ + bot.handle(context.Background(), &tgapi.Update{ UpdateID: 1, Type: tgapi.UpdateTypePoll, Poll: &tgapi.Poll{ @@ -135,7 +136,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { plugins: []Plugin[NoDB]{clonePlugin(plugin)}, } - bot.handle(tt.update) + bot.handle(context.Background(), tt.update) if !called { t.Fatalf("expected update handler for %s to be called", tt.name) @@ -184,7 +185,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { }, } - bot.handle(&tgapi.Update{ + bot.handle(context.Background(), &tgapi.Update{ UpdateID: 3, Type: tgapi.UpdateTypeInlineQuery, InlineQuery: &tgapi.InlineQuery{ @@ -225,7 +226,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { plugins: []Plugin[NoDB]{clonePlugin(plugin)}, } - bot.handle(&tgapi.Update{ + bot.handle(context.Background(), &tgapi.Update{ UpdateID: 10, Type: tgapi.UpdateTypeChannelPost, ChannelPost: &tgapi.Message{ @@ -240,3 +241,85 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { t.Fatal("expected channel post command handler to be called") } } + +func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { + type banInput struct { + UserID int + Reason string + } + + var got banInput + plugin := NewPlugin[NoDB]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + return ctx.BindArgs(&got) + }, "ban", + NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(), + NewCommandArg("reason").SetRequired(), + ) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 11, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "/ban 42 too loud", + Chat: &tgapi.Chat{ID: 99, Type: string(tgapi.ChatTypePrivate)}, + }, + }) + + want := banInput{UserID: 42, Reason: "too loud"} + if got != want { + t.Fatalf("unexpected bound input: got %#v want %#v", got, want) + } +} + +func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { + type payloadInput struct { + ID int + Note string + } + + var got payloadInput + plugin := NewPlugin[NoDB]("test") + plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { + return ctx.BindArgs(&got) + }, "approve", + NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(), + NewCommandArg("note").SetRequired(), + ) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + data, err := encodeJsonPayload(CallbackData{ + Command: "approve", + Args: []string{"7", "looks", "good"}, + }) + if err != nil { + t.Fatalf("encodeJsonPayload returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 12, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-1", + Data: data, + From: tgapi.User{ID: 1}, + }, + }) + + want := payloadInput{ID: 7, Note: "looks good"} + if got != want { + t.Fatalf("unexpected bound payload input: got %#v want %#v", got, want) + } +} diff --git a/msg_context.go b/msg_context.go index eeeae9f..26f460b 100644 --- a/msg_context.go +++ b/msg_context.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "reflect" + "strconv" + "strings" "time" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -36,6 +39,9 @@ type MsgContext struct { l10n *L10n draftProvider *DraftProvider payloadType BotPayloadType + sceneRuntime sceneRuntime + + ctx context.Context } // AnswerMessage represents a message sent or edited via MsgContext. @@ -70,7 +76,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard if keyboard != nil { params.ReplyMarkup = keyboard.Get() } - msg, _, err := ctx.Api.EditMessageText(params) + msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -155,7 +161,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo params.ReplyMarkup = kb.Get() } - msg, _, err := ctx.Api.EditMessageCaption(params) + msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -218,7 +224,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID } - msg, err := ctx.Api.SendMessage(params) + msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -349,7 +355,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID) } - msg, err := ctx.Api.SendPhoto(params) + msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -405,7 +411,7 @@ func (ctx *MsgContext) delete(messageId int) { ctx.Logger.Errorln(ErrMessageContextNil) return } - _, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{ + _, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessageP{ ChatID: ctx.Msg.Chat.ID, MessageID: messageId, }) @@ -431,7 +437,7 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryId) == 0 { return } - _, err := ctx.Api.AnswerCallbackQuery(tgapi.AnswerCallbackQueryP{ + _, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQueryP{ CallbackQueryID: ctx.CallbackQueryId, Text: text, ShowAlert: showAlert, URL: url, }) @@ -464,7 +470,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { if ctx.Msg.MessageThreadID > 0 { params.MessageThreadID = ctx.Msg.MessageThreadID } - _, err := ctx.Api.SendChatAction(params) + _, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) } @@ -500,7 +506,7 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { } if ctx.Api.Limiter != nil { - c, cancel := context.WithTimeout(context.Background(), 5*time.Second) + 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) @@ -540,3 +546,161 @@ func (ctx *MsgContext) Translate(key string) string { 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 +} + +func (ctx *MsgContext) EnterScene(name string) error { + scene, ok := ctx.sceneRuntime.FindScene(name) + if !ok { + return ErrSceneNotFound + } + + key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + if !ok { + return ErrCantFindSession + } + + session := SceneSession{ + Scene: scene.Name, + Step: scene.Entry, + } + + return ctx.sceneRuntime.SetSession(key, session) +} +func (ctx *MsgContext) EnterSceneStep(name, step string) error { + 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) +} +func (ctx *MsgContext) ExitScene() error { + _, 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) +} diff --git a/msg_context_test.go b/msg_context_test.go index 299fa42..b7e9fa2 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -65,6 +65,107 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { } } +func TestBindArgsBindsScalarFields(t *testing.T) { + type input struct { + ID int + Active bool + Score float64 + Name string + } + + ctx := &MsgContext{Args: []string{"42", "true", "3.5", "Ada", "Lovelace"}} + var got input + + if err := ctx.BindArgs(&got); err != nil { + t.Fatalf("BindArgs returned error: %v", err) + } + + want := input{ + ID: 42, + Active: true, + Score: 3.5, + Name: "Ada Lovelace", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected bound value: got %#v want %#v", got, want) + } +} + +func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) { + type input struct { + ID int + Reason string + Admin bool + } + + ctx := &MsgContext{Args: []string{"7"}} + var got input + + if err := ctx.BindArgs(&got); err != nil { + t.Fatalf("BindArgs returned error: %v", err) + } + + if got.ID != 7 { + t.Fatalf("unexpected ID: got %d want 7", got.ID) + } + if got.Reason != "" { + t.Fatalf("expected zero-value Reason, got %q", got.Reason) + } + if got.Admin { + t.Fatal("expected zero-value Admin") + } +} + +func TestBindArgsRejectsInvalidTargets(t *testing.T) { + ctx := &MsgContext{Args: []string{"1"}} + + if err := ctx.BindArgs(nil); !errors.Is(err, ErrBindArgsTargetNotPointer) { + t.Fatalf("expected ErrBindArgsTargetNotPointer for nil target, got %v", err) + } + + var notStruct int + if err := ctx.BindArgs(¬Struct); !errors.Is(err, ErrBindArgsTargetNotStruct) { + t.Fatalf("expected ErrBindArgsTargetNotStruct for non-struct target, got %v", err) + } +} + +func TestBindArgsReportsConversionFailures(t *testing.T) { + type input struct { + ID int + } + + ctx := &MsgContext{Args: []string{"oops"}} + var got input + + err := ctx.BindArgs(&got) + if err == nil { + t.Fatal("expected BindArgs to fail") + } + if !errors.Is(err, ErrBindArgsConversion) { + t.Fatalf("expected ErrBindArgsConversion, got %v", err) + } + if !strings.Contains(err.Error(), "field ID") { + t.Fatalf("expected field name in error, got %v", err) + } +} + +func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) { + type input struct { + Tags []string + } + + ctx := &MsgContext{Args: []string{"tag"}} + var got input + + err := ctx.BindArgs(&got) + if err == nil { + t.Fatal("expected BindArgs to fail") + } + if !errors.Is(err, ErrBindArgsUnsupportedFieldType) { + t.Fatalf("expected ErrBindArgsUnsupportedFieldType, got %v", err) + } +} + func TestAnswerRejectsEmptyMessage(t *testing.T) { ctx := &MsgContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, diff --git a/plugins.go b/plugins.go index 656663c..a0c6834 100644 --- a/plugins.go +++ b/plugins.go @@ -161,6 +161,7 @@ type Plugin[T DbContext] struct { name string // Name of the plugin (e.g., "admin", "user") commands map[string]*Command[T] // Registered commands (triggered by message) payloads map[string]*Command[T] // Registered payloads (triggered by callback data) + scenes map[string]*Scene[T] // Optional scenes for multi-step interactions middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help logger *slog.Logger @@ -177,6 +178,7 @@ func NewPlugin[T DbContext](name string) *Plugin[T] { commands: make(map[string]*Command[T]), payloads: make(map[string]*Command[T]), middlewares: make(extypes.Slice[Middleware[T]], 0), + scenes: make(map[string]*Scene[T]), skipAutoCmd: false, logger: nil, handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), @@ -213,6 +215,18 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ... return cmd } +func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { + scene.PluginName = p.name + scene.setPluginName(p.name) + return p +} +func (p *Plugin[T]) NewScene(name string) *Scene[T] { + scene := NewScene[T](name) + scene.setPluginName(p.name) + p.AddScene(scene) + return scene +} + // AddUpdateHandler registers a handler for a non-command update type. // Message, channel post, and callback query updates stay on the command/payload flow. func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] { diff --git a/scene.go b/scene.go new file mode 100644 index 0000000..c9c545a --- /dev/null +++ b/scene.go @@ -0,0 +1,195 @@ +package laniakea + +import ( + "encoding/json" + "sync" +) + +type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error) +type Scene[T any] struct { + Name string + Scope SceneScope + Entry string // starting step + PluginName string + + steps map[string]SceneHandler[T] + commands map[string]SceneHandler[T] + message SceneHandler[T] +} + +func NewScene[T any](name string) *Scene[T] { + return &Scene[T]{ + Name: name, + Scope: SceneScopeUserChat, + Entry: "", + steps: make(map[string]SceneHandler[T]), + commands: make(map[string]SceneHandler[T]), + message: nil, + } +} +func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { + s.Scope = scope + return s +} +func (s *Scene[T]) SetEntry(step string) *Scene[T] { + s.Entry = step + return s +} +func (s *Scene[T]) setPluginName(name string) *Scene[T] { + s.PluginName = name + return s +} + +func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] { + s.steps[step] = handler + return s +} +func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] { + s.commands[cmd] = handler + return s +} +func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] { + s.message = handler + return s +} + +func (s *Scene[T]) executeCommand(cmd string, ctx *SceneContext, db T) (SceneResult, bool, error) { + handler, ok := s.commands[cmd] + if !ok { + return SceneResult{}, false, nil + } + result, err := handler(ctx, db) + return result, true, err +} +func (s *Scene[T]) executeStep(step string, ctx *SceneContext, db T) (SceneResult, bool, error) { + handler, ok := s.steps[step] + if !ok { + return SceneResult{}, false, nil + } + result, err := handler(ctx, db) + return result, true, err +} +func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, error) { + if s.message == nil { + return SceneResult{}, false, nil + } + result, err := s.message(ctx, db) + return result, true, err +} + +type SceneSession struct { + Scene string + Step string + Data []byte +} + +// SetData sets the session data. It is thread-safe and can be used to store any arbitrary data as a byte slice. +func (s *SceneSession) SetData(data []byte) { + s.Data = data +} + +// GetData retrieves the session data. It is thread-safe and returns the data as a byte slice. +func (s *SceneSession) GetData() []byte { + return s.Data +} +func (s *SceneSession) HasData() bool { + return len(s.Data) > 0 +} + +// ClearData clears the session data. It is thread-safe and sets the data to nil. +func (s *SceneSession) ClearData() { + s.Data = nil +} + +// BindData binds the session data to the provided struct. +func (s *SceneSession) BindData(v any) error { + if len(s.Data) == 0 { + return nil // No data to bind, return nil error + } + return json.Unmarshal(s.Data, v) +} + +// SaveData saves the provided struct as JSON in the session data. +func (s *SceneSession) SaveData(v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + s.Data = data + return nil +} + +type SessionStore interface { + Get(key string) (SceneSession, error) + Set(key string, session SceneSession) error + Delete(key string) error +} + +type MemorySessionStore struct { + store map[string]SceneSession + mu sync.RWMutex +} + +func NewMemorySessionStore() *MemorySessionStore { + return &MemorySessionStore{ + store: make(map[string]SceneSession), + } +} +func (s *MemorySessionStore) Get(key string) (SceneSession, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if session, ok := s.store[key]; ok { + return session, nil + } + return SceneSession{}, nil +} +func (s *MemorySessionStore) Set(key string, session SceneSession) error { + s.mu.Lock() + s.store[key] = session + s.mu.Unlock() + return nil +} +func (s *MemorySessionStore) Delete(key string) error { + s.mu.Lock() + delete(s.store, key) + s.mu.Unlock() + return nil +} + +type SceneResult struct { + Action SceneAction + Next string +} + +type SceneAction int + +const ( + SceneActionStay SceneAction = iota + SceneActionNext + SceneActionExit + SceneActionPass +) + +type SceneScope int + +const ( + SceneScopeUser SceneScope = iota + SceneScopeChat + SceneScopeUserChat +) + +type sceneRuntime interface { + FindScene(name string) (*sceneMeta, bool) + GetSession(key string) (SceneSession, error) + SetSession(key string, session SceneSession) error + DeleteSession(key string) error + BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) + FindSceneSession(ctx *MsgContext) (string, SceneSession, error) +} + +type sceneMeta struct { + Name string + Scope SceneScope + Entry string + Steps map[string]struct{} +} diff --git a/scene_context.go b/scene_context.go new file mode 100644 index 0000000..048a14c --- /dev/null +++ b/scene_context.go @@ -0,0 +1,23 @@ +package laniakea + +type SceneContext struct { + *MsgContext + sess SceneSession + key string +} + +func (ctx *SceneContext) Next(step string) SceneResult { + return SceneResult{ + Action: SceneActionNext, + Next: step, + } +} +func (ctx *SceneContext) Stay() SceneResult { + return SceneResult{Action: SceneActionStay} +} +func (ctx *SceneContext) Exit() SceneResult { + return SceneResult{Action: SceneActionExit} +} +func (ctx *SceneContext) Pass() SceneResult { + return SceneResult{Action: SceneActionPass} +} diff --git a/scene_handler.go b/scene_handler.go new file mode 100644 index 0000000..8fd5f95 --- /dev/null +++ b/scene_handler.go @@ -0,0 +1,130 @@ +package laniakea + +import ( + "errors" + "fmt" + "strings" +) + +func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { + key, session, err := bot.FindSceneSession(ctx) + if err != nil { + if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) { + return false, nil + } + return false, err + } + if session.Scene == "" { + return false, nil + } + + for _, plugin := range bot.plugins { + scene, ok := plugin.scenes[session.Scene] + if !ok { + continue + } + if scene.PluginName != "" && scene.PluginName != plugin.name { + continue + } + sceneCtx := &SceneContext{ + MsgContext: ctx, + sess: session, + key: key, + } + return bot.executeScene(scene, sceneCtx) + } + return false, ErrSceneNotFound +} + +func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error) { + if ctx.MsgContext == nil || ctx.sess.Scene == "" { + return false, nil + } + text := ctx.Msg.Text + if text == "" { + text = ctx.Msg.Caption + } + text = strings.TrimSpace(text) + prefix, cmd, args := bot.parseCommand(text) + if cmd != "" { + ctx.Prefix = prefix + ctx.Text = args + ctx.Args = strings.Fields(args) + + res, matched, err := scene.executeCommand(cmd, ctx, bot.dbContext) + if err != nil { + return false, err + } + if matched { + return bot.applySceneResult(scene, ctx, res) + } + } + ctx.Text = text + ctx.Args = nil + ctx.Prefix = "" + if ctx.sess.Step != "" { + res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.dbContext) + if err != nil { + return false, err + } + if matched { + return bot.applySceneResult(scene, ctx, res) + } + } + + res, matched, err := scene.executeMessage(ctx, bot.dbContext) + if err != nil { + return false, err + } + if matched { + return bot.applySceneResult(scene, ctx, res) + } + + return false, nil +} +func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result SceneResult) (bool, error) { + switch result.Action { + case SceneActionStay: + if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil { + return false, err + } + return true, nil + + case SceneActionNext: + if result.Next == "" { + return false, ErrSceneStepNotFound + } + if _, ok := scene.steps[result.Next]; !ok { + return false, ErrSceneStepNotFound + } + ctx.sess.Step = result.Next + if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil { + return false, err + } + return true, nil + + case SceneActionExit: + if err := bot.sessionStore.Delete(ctx.key); err != nil { + return false, err + } + return true, nil + + case SceneActionPass: + return false, nil + + default: + return false, nil + } +} +func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { + switch scope { + case SceneScopeUserChat: + return fmt.Sprintf("user_id:%d:chat_id:%d", ctx.FromID, ctx.Msg.Chat.ID), true + case SceneScopeChat: + return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true + case SceneScopeUser: + return fmt.Sprintf("user_id:%d", ctx.FromID), true + default: + return "", false + } +} -- 2.54.0 From 3ad9e48d71467803daf5ad329895160a6fef904d Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 27 Mar 2026 16:06:24 +0300 Subject: [PATCH 63/99] fix: guard nil logger in prefix check --- handler.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/handler.go b/handler.go index dd9bcd0..99a6759 100644 --- a/handler.go +++ b/handler.go @@ -270,7 +270,9 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { for _, prefix := range bot.prefixes { if prefix == "" { - bot.logger.Warnln("empty prefix is not allowed") + if bot.logger != nil { + bot.logger.Warnln("empty prefix is not allowed") + } continue } if strings.HasPrefix(text, prefix) { -- 2.54.0 From a4d70e1510d1ab6ac8c88aefa729defafbf9cde9 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Sat, 28 Mar 2026 12:58:05 +0000 Subject: [PATCH 64/99] Stabilize scene session skeleton Hide internal scene runtime methods Refresh docs, tests, and changelog for scenes --- CHANGELOG.md | 10 +-- TODO.md | 2 +- bot.go | 31 ++++++++- bot_scene.go | 15 ++-- errors.go | 17 +++-- msg_context.go | 31 ++++++--- plugins.go | 7 ++ scene.go | 59 ++++++++++++---- scene_context.go | 18 +++++ scene_handler.go | 15 +++- scene_test.go | 174 +++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 332 insertions(+), 47 deletions(-) create mode 100644 scene_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ae55230..f639188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,23 +8,15 @@ - `Bot.GetPayloadType()`, `InlineKeyboard.GetPayloadType()`, and optional strict payload decoding via `BotOpts.StrictPayloadType` / `Bot.SetStrictPayloadType(...)`. - `MsgContext.BindArgs(...)` for binding positional command arguments into exported struct fields. - Binding sentinels `ErrBindArgsTargetNotPointer`, `ErrBindArgsTargetNotStruct`, `ErrBindArgsUnsupportedFieldType`, and `ErrBindArgsConversion`. +- Work-in-progress scene/session support, including plugin scene registration, scoped scene sessions, scene entry/exit APIs on `MsgContext`, default in-memory session storage, scene-local routing before normal command handling, and state helpers on `SceneContext`. ### Changed - `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors. - README and README_RU examples now use the new handler signature and document the long-message helpers. - README and README_RU now link to the project wiki, and the wiki now includes a page-priority tracker while content is being filled in. -- `AGENTS.md` now requires every change to be recorded in `CHANGELOG.md`, enforces version alignment with `utils/version.go`, and blocks breaking changes without a major-version bump. -- `AGENTS.md` now also defines a short commit-message format: one summary line plus up to three high-signal detail lines. -- `AGENTS.md` now explicitly requires each commit-message detail line to be placed on its own new line. -- `AGENTS.md` now also requires commit messages to be emitted as a plain multiline block instead of collapsed prose or list formatting. -- `AGENTS.md` now requires new or expanded project documentation to be maintained in both English and Russian whenever reasonably possible. -- `AGENTS.md` now requires all agent-created commits to be GPG-signed and to fail fast instead of falling back to unsigned commits when signing cannot be completed. -- `AGENTS.md` now also links the wiki backlog flow more tightly to `TODO.md` and `CHANGELOG.md`, requiring draft-wiki confirmation for large new ideas and synchronized completion records for backlog items. -- Added `TODO.md` to track missing framework-level concepts, with detailed notes for scenes, typed handler input, and request-scoped cancellation. - Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides. - `MsgContext.Context()` now safely falls back to `context.Background()` when no request-scoped context is attached. - `MsgContext` reply, edit, callback, delete, action, and draft-limiter paths now use the context accessor instead of reaching into raw internal state. -- `TODO.md` is now a short pointer file, while the detailed framework backlog lives in the wiki as `Framework-Backlog` / `Framework-Backlog-RU`. - Version constants were bumped to `v1.0.0-rc.12`. ### Fixed diff --git a/TODO.md b/TODO.md index 1038fc4..ba2122f 100644 --- a/TODO.md +++ b/TODO.md @@ -12,6 +12,6 @@ Russian page: Current high-priority status: -- `1. Conversation / Scene Model`: not implemented yet. +- `1. Conversation / Scene Model`: work in progress. - `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`. - `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`. diff --git a/bot.go b/bot.go index 00753cc..305d0ed 100644 --- a/bot.go +++ b/bot.go @@ -342,18 +342,24 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { bot.draftProvider = p return bot } + +// GetDraftProvider returns the draft provider currently used by the bot. func (bot *Bot[T]) GetDraftProvider() *DraftProvider { return bot.draftProvider } -func (bot *Bot[T]) SetSettionStore(store SessionStore) *Bot[T] { +// SetSessionStore replaces the session store used for scene management. +func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { bot.sessionStore = store return bot } + +// GetSessionStore returns the session store used for scene management. func (bot *Bot[T]) GetSessionStore() SessionStore { return bot.sessionStore } +// SetSceneScopePriority sets the lookup order for resolving active scene sessions. func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { newPriority := make([]SceneScope, 0, 3) for _, scope := range priority { @@ -782,6 +788,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { name: p.name, commands: make(map[string]*Command[T], len(p.commands)), payloads: make(map[string]*Command[T], len(p.payloads)), + scenes: make(map[string]*Scene[T], len(p.scenes)), middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), skipAutoCmd: p.skipAutoCmd, logger: p.logger, @@ -795,6 +802,9 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { for name, command := range p.payloads { cloned.payloads[name] = cloneCommand(command) } + for name, scene := range p.scenes { + cloned.scenes[name] = cloneScene(scene) + } maps.Copy(cloned.handlers, p.handlers) return cloned @@ -810,3 +820,22 @@ func cloneCommand[T DbContext](command *Command[T]) *Command[T] { cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) return &cloned } + +func cloneScene[T DbContext](scene *Scene[T]) *Scene[T] { + if scene == nil { + return nil + } + + cloned := *scene + cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) + cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) + + for name, handler := range scene.steps { + cloned.steps[name] = handler + } + for name, handler := range scene.commands { + cloned.commands[name] = handler + } + + return &cloned +} diff --git a/bot_scene.go b/bot_scene.go index d8136eb..e5c97ed 100644 --- a/bot_scene.go +++ b/bot_scene.go @@ -1,17 +1,17 @@ package laniakea -func (bot *Bot[T]) GetSession(key string) (SceneSession, error) { +func (bot *Bot[T]) getSession(key string) (SceneSession, error) { return bot.sessionStore.Get(key) } -func (bot *Bot[T]) SetSession(key string, session SceneSession) error { +func (bot *Bot[T]) setSession(key string, session SceneSession) error { return bot.sessionStore.Set(key, session) } -func (bot *Bot[T]) DeleteSession(key string) error { +func (bot *Bot[T]) deleteSession(key string) error { return bot.sessionStore.Delete(key) } -func (bot *Bot[T]) FindScene(name string) (*sceneMeta, bool) { +func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) { for _, plugin := range bot.plugins { scene, ok := plugin.scenes[name] if !ok { @@ -33,9 +33,9 @@ func (bot *Bot[T]) FindScene(name string) (*sceneMeta, bool) { return nil, false } -func (bot *Bot[T]) FindSceneSession(ctx *MsgContext) (string, SceneSession, error) { +func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, error) { var zero SceneSession - if ctx.Msg == nil { + if ctx.Msg == nil && ctx.FromID == 0 { return "", zero, ErrMessageNil } @@ -56,6 +56,7 @@ func (bot *Bot[T]) FindSceneSession(ctx *MsgContext) (string, SceneSession, erro return "", zero, ErrCantFindSession } -func (bot *Bot[T]) BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { + +func (bot *Bot[T]) buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { return buildSceneKey(scope, ctx) } diff --git a/errors.go b/errors.go index 40c4cbb..0ccfde9 100644 --- a/errors.go +++ b/errors.go @@ -44,12 +44,17 @@ var ( // ErrBindArgsUnsupportedFieldType reports that BindArgs encountered an unsupported field kind. ErrBindArgsUnsupportedFieldType = errors.New("bind args: unsupported field type") // ErrBindArgsConversion reports that BindArgs could not convert a string argument into a field type. - ErrBindArgsConversion = errors.New("bind args: conversion failed") - ErrCantFindSession = errors.New("can't find session for this context") - ErrSceneNotFound = errors.New("scene not found") - ErrSceneStepNotFound = errors.New("scene step not found") - ErrSceneCommandNotFound = errors.New("scene command not found") - ErrNotInScene = errors.New("not in scene") + ErrBindArgsConversion = errors.New("bind args: conversion failed") + // ErrCantFindSession reports that no scene session matches the current context. + ErrCantFindSession = errors.New("can't find session for this context") + // ErrSceneNotFound reports that the requested scene is not registered. + ErrSceneNotFound = errors.New("scene not found") + // ErrSceneStepNotFound reports that the requested scene step is not registered. + ErrSceneStepNotFound = errors.New("scene step not found") + // ErrNotInScene reports that the current context has no active scene session. + ErrNotInScene = errors.New("not in scene") + // ErrSceneEntryNotSet reports that a scene has no configured entry step. + ErrSceneEntryNotSet = errors.New("scene entry step not set") ) func validateMessageText(text string) error { diff --git a/msg_context.go b/msg_context.go index 26f460b..a334439 100644 --- a/msg_context.go +++ b/msg_context.go @@ -644,26 +644,35 @@ func (ctx *MsgContext) Context() context.Context { return ctx.ctx } +// EnterScene enters the named scene at its configured entry step. func (ctx *MsgContext) EnterScene(name string) error { - scene, ok := ctx.sceneRuntime.FindScene(name) + scene, ok := ctx.sceneRuntime.findScene(name) if !ok { return ErrSceneNotFound } - key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + 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) + return ctx.sceneRuntime.setSession(key, session) } + +// EnterSceneStep enters the named scene at a specific step. func (ctx *MsgContext) EnterSceneStep(name, step string) error { - scene, ok := ctx.sceneRuntime.FindScene(name) + scene, ok := ctx.sceneRuntime.findScene(name) if !ok { return ErrSceneNotFound } @@ -671,7 +680,7 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { return ErrSceneStepNotFound } - key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } @@ -681,10 +690,12 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { Step: step, } - return ctx.sceneRuntime.SetSession(key, session) + return ctx.sceneRuntime.setSession(key, session) } + +// ExitScene leaves the currently active scene for this context. func (ctx *MsgContext) ExitScene() error { - _, session, err := ctx.sceneRuntime.FindSceneSession(ctx) + _, session, err := ctx.sceneRuntime.findSceneSession(ctx) if err != nil { return err } @@ -692,15 +703,15 @@ func (ctx *MsgContext) ExitScene() error { return ErrNotInScene } - scene, ok := ctx.sceneRuntime.FindScene(session.Scene) + scene, ok := ctx.sceneRuntime.findScene(session.Scene) if !ok { return ErrSceneNotFound } - key, ok := ctx.sceneRuntime.BuildSceneKey(scene.Scope, ctx) + key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } - return ctx.sceneRuntime.DeleteSession(key) + return ctx.sceneRuntime.deleteSession(key) } diff --git a/plugins.go b/plugins.go index a0c6834..3229a22 100644 --- a/plugins.go +++ b/plugins.go @@ -215,11 +215,18 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ... return cmd } +// AddScene registers a multi-step scene in the plugin. func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { + if scene == nil { + return p + } scene.PluginName = p.name scene.setPluginName(p.name) + p.scenes[scene.Name] = scene return p } + +// NewScene creates, registers, and returns a new scene owned by the plugin. func (p *Plugin[T]) NewScene(name string) *Scene[T] { scene := NewScene[T](name) scene.setPluginName(p.name) diff --git a/scene.go b/scene.go index c9c545a..7b6263c 100644 --- a/scene.go +++ b/scene.go @@ -5,7 +5,10 @@ import ( "sync" ) +// SceneHandler handles a scene step, scene command, or fallback message. type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error) + +// Scene defines a multi-step conversational flow. type Scene[T any] struct { Name string Scope SceneScope @@ -17,6 +20,7 @@ type Scene[T any] struct { message SceneHandler[T] } +// NewScene creates a new scene with user-chat scope by default. func NewScene[T any](name string) *Scene[T] { return &Scene[T]{ Name: name, @@ -27,10 +31,14 @@ func NewScene[T any](name string) *Scene[T] { message: nil, } } + +// SetScope changes how scene sessions are keyed and shared. func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { s.Scope = scope return s } + +// SetEntry sets the initial step entered by MsgContext.EnterScene. func (s *Scene[T]) SetEntry(step string) *Scene[T] { s.Entry = step return s @@ -40,14 +48,19 @@ func (s *Scene[T]) setPluginName(name string) *Scene[T] { return s } +// OnStep registers a handler for a named scene step. func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] { s.steps[step] = handler return s } + +// OnCommand registers a command handler active while the scene is running. func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] { s.commands[cmd] = handler return s } + +// OnMessage registers a fallback handler used when no scene command or step matches. func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] { s.message = handler return s @@ -77,39 +90,42 @@ func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, e return result, true, err } +// SceneSession stores the active scene state for one session key. type SceneSession struct { Scene string Step string Data []byte } -// SetData sets the session data. It is thread-safe and can be used to store any arbitrary data as a byte slice. +// SetData stores arbitrary opaque session data. func (s *SceneSession) SetData(data []byte) { s.Data = data } -// GetData retrieves the session data. It is thread-safe and returns the data as a byte slice. +// GetData returns the raw session data payload. func (s *SceneSession) GetData() []byte { return s.Data } + +// HasData reports whether the session has a non-empty data payload. func (s *SceneSession) HasData() bool { return len(s.Data) > 0 } -// ClearData clears the session data. It is thread-safe and sets the data to nil. +// ClearData removes any stored session data. func (s *SceneSession) ClearData() { s.Data = nil } -// BindData binds the session data to the provided struct. +// BindData unmarshals the stored JSON payload into v. func (s *SceneSession) BindData(v any) error { if len(s.Data) == 0 { - return nil // No data to bind, return nil error + return nil } return json.Unmarshal(s.Data, v) } -// SaveData saves the provided struct as JSON in the session data. +// SaveData marshals v as JSON and stores it in the session. func (s *SceneSession) SaveData(v any) error { data, err := json.Marshal(v) if err != nil { @@ -119,22 +135,27 @@ func (s *SceneSession) SaveData(v any) error { return nil } +// SessionStore persists scene sessions by key. type SessionStore interface { Get(key string) (SceneSession, error) Set(key string, session SceneSession) error Delete(key string) error } +// MemorySessionStore stores scene sessions in memory. type MemorySessionStore struct { store map[string]SceneSession mu sync.RWMutex } +// NewMemorySessionStore creates an empty in-memory session store. func NewMemorySessionStore() *MemorySessionStore { return &MemorySessionStore{ store: make(map[string]SceneSession), } } + +// Get returns the session stored under key, or the zero session when absent. func (s *MemorySessionStore) Get(key string) (SceneSession, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -143,12 +164,16 @@ func (s *MemorySessionStore) Get(key string) (SceneSession, error) { } return SceneSession{}, nil } + +// Set stores session under key. func (s *MemorySessionStore) Set(key string, session SceneSession) error { s.mu.Lock() s.store[key] = session s.mu.Unlock() return nil } + +// Delete removes the session stored under key. func (s *MemorySessionStore) Delete(key string) error { s.mu.Lock() delete(s.store, key) @@ -156,35 +181,45 @@ func (s *MemorySessionStore) Delete(key string) error { return nil } +// SceneResult describes how scene execution should proceed after a handler returns. type SceneResult struct { Action SceneAction Next string } +// SceneAction controls how the bot updates scene state after a handler returns. type SceneAction int const ( + // SceneActionStay keeps the current scene and step active. SceneActionStay SceneAction = iota + // SceneActionNext moves the session to another named step. SceneActionNext + // SceneActionExit removes the current scene session. SceneActionExit + // SceneActionPass lets normal bot routing continue after the scene handler. SceneActionPass ) +// SceneScope defines how scene sessions are keyed. type SceneScope int const ( + // SceneScopeUser shares a scene across all chats for one user. SceneScopeUser SceneScope = iota + // SceneScopeChat shares a scene across all users in one chat. SceneScopeChat + // SceneScopeUserChat isolates a scene per user-chat pair. SceneScopeUserChat ) type sceneRuntime interface { - FindScene(name string) (*sceneMeta, bool) - GetSession(key string) (SceneSession, error) - SetSession(key string, session SceneSession) error - DeleteSession(key string) error - BuildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) - FindSceneSession(ctx *MsgContext) (string, SceneSession, error) + findScene(name string) (*sceneMeta, bool) + getSession(key string) (SceneSession, error) + setSession(key string, session SceneSession) error + deleteSession(key string) error + buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) + findSceneSession(ctx *MsgContext) (string, SceneSession, error) } type sceneMeta struct { diff --git a/scene_context.go b/scene_context.go index 048a14c..a3ef20b 100644 --- a/scene_context.go +++ b/scene_context.go @@ -1,23 +1,41 @@ package laniakea +// SceneContext wraps MsgContext with scene session state for scene handlers. type SceneContext struct { *MsgContext sess SceneSession key string } +// Next advances the current scene to step. func (ctx *SceneContext) Next(step string) SceneResult { return SceneResult{ Action: SceneActionNext, Next: step, } } + +// Stay keeps the current scene step active. func (ctx *SceneContext) Stay() SceneResult { return SceneResult{Action: SceneActionStay} } + +// Exit leaves the current scene. func (ctx *SceneContext) Exit() SceneResult { return SceneResult{Action: SceneActionExit} } + +// Pass stops scene handling and lets normal routing continue. func (ctx *SceneContext) Pass() SceneResult { return SceneResult{Action: SceneActionPass} } + +// BindData unmarshals the current scene session payload into v. +func (ctx *SceneContext) BindData(v any) error { + return ctx.sess.BindData(v) +} + +// SaveData marshals v and stores it in the current scene session payload. +func (ctx *SceneContext) SaveData(v any) error { + return ctx.sess.SaveData(v) +} diff --git a/scene_handler.go b/scene_handler.go index 8fd5f95..de640a7 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -7,7 +7,7 @@ import ( ) func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { - key, session, err := bot.FindSceneSession(ctx) + key, session, err := bot.findSceneSession(ctx) if err != nil { if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) { return false, nil @@ -117,12 +117,25 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S } } func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { + if ctx == nil { + return "", false + } + switch scope { case SceneScopeUserChat: + if ctx.Msg == nil || ctx.Msg.Chat == nil || ctx.FromID == 0 { + return "", false + } return fmt.Sprintf("user_id:%d:chat_id:%d", ctx.FromID, ctx.Msg.Chat.ID), true case SceneScopeChat: + if ctx.Msg == nil || ctx.Msg.Chat == nil { + return "", false + } return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true case SceneScopeUser: + if ctx.FromID == 0 { + return "", false + } return fmt.Sprintf("user_id:%d", ctx.FromID), true default: return "", false diff --git a/scene_test.go b/scene_test.go new file mode 100644 index 0000000..75bcfb6 --- /dev/null +++ b/scene_test.go @@ -0,0 +1,174 @@ +package laniakea + +import ( + "context" + "errors" + "testing" + + "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.nix13.pw/scuroneko/slog" +) + +func TestPluginAddSceneRegistersScene(t *testing.T) { + plugin := NewPlugin[NoDB]("wizard") + scene := NewScene[NoDB]("signup") + + plugin.AddScene(scene) + + if got, ok := plugin.scenes["signup"]; !ok || got != scene { + t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene) + } + if scene.PluginName != "wizard" { + t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.PluginName, "wizard") + } +} + +func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { + called := false + + plugin := NewPlugin[NoDB]("wizard") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + called = true + if ctx.Text != "hello there" { + t.Fatalf("unexpected scene text: got %q want %q", ctx.Text, "hello there") + } + return ctx.Exit(), nil + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + sceneMeta, ok := bot.findScene("signup") + if !ok { + t.Fatal("expected scene metadata to be available after plugin registration") + } + if sceneMeta.Entry != "start" { + t.Fatalf("unexpected scene entry: got %q want %q", sceneMeta.Entry, "start") + } + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 1, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 7, + Text: "hello there", + Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + From: &tgapi.User{ID: 42}, + }, + }) + + if !called { + t.Fatal("expected scene step handler to be called") + } + + lookupCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + } + if _, session, err := bot.findSceneSession(lookupCtx); err == nil && session.Scene != "" { + t.Fatalf("expected scene session to be removed after exit, got %#v", session) + } +} + +func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { + tests := []struct { + name string + scope SceneScope + ctx *MsgContext + }{ + { + name: "nil context", + scope: SceneScopeUserChat, + ctx: nil, + }, + { + name: "missing message for chat scope", + scope: SceneScopeChat, + ctx: &MsgContext{}, + }, + { + name: "missing from id for user scope", + scope: SceneScopeUser, + ctx: &MsgContext{}, + }, + { + name: "missing from id for user chat scope", + scope: SceneScopeUserChat, + ctx: &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if key, ok := buildSceneKey(tt.scope, tt.ctx); ok || key != "" { + t.Fatalf("expected invalid scene key, got key=%q ok=%v", key, ok) + } + }) + } +} + +func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { + t.Run("empty entry", func(t *testing.T) { + plugin := NewPlugin[NoDB]("wizard") + plugin.NewScene("signup") + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + ctx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + sceneRuntime: bot, + } + + err := ctx.EnterScene("signup") + if !errors.Is(err, ErrSceneEntryNotSet) { + t.Fatalf("expected ErrSceneEntryNotSet, got %v", err) + } + }) + + t.Run("missing entry step", func(t *testing.T) { + plugin := NewPlugin[NoDB]("wizard") + plugin.NewScene("signup").SetEntry("start") + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + ctx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + sceneRuntime: bot, + } + + err := ctx.EnterScene("signup") + if !errors.Is(err, ErrSceneStepNotFound) { + t.Fatalf("expected ErrSceneStepNotFound, got %v", err) + } + }) +} -- 2.54.0 From f74496a3e87b6c644bb402974dacf973e783532f Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 30 Mar 2026 00:12:24 +0300 Subject: [PATCH 65/99] Release v1.0.0-rc.12 Finalize scenes, typed arg binding, and request-scoped context plumbing Refresh docs, backlog state, and regression coverage for the rc.12 release --- CHANGELOG.md | 6 + README.md | 49 ++++++- README_RU.md | 49 ++++++- TODO.md | 14 +- bot.go | 16 ++- bot_opts.go | 2 +- bot_opts_test.go | 2 +- bot_scene.go | 3 - bot_test.go | 4 +- cmd_generator.go | 2 +- cmd_generator_test.go | 4 +- drafts.go | 2 +- drafts_test.go | 4 +- errors.go | 5 +- go.mod | 6 +- go.sum | 8 +- handler.go | 2 +- handler_test.go | 4 +- keyboard.go | 4 +- methods.go | 2 +- msg_context.go | 16 ++- msg_context_test.go | 4 +- plugins.go | 8 +- runners_test.go | 2 +- scene.go | 18 ++- scene_handler.go | 18 ++- scene_test.go | 298 +++++++++++++++++++++++++++++++++++++++- tgapi/api.go | 4 +- tgapi/messages_types.go | 2 +- tgapi/methods.go | 2 +- tgapi/uploader_api.go | 4 +- utils.go | 2 +- utils/multipart_test.go | 4 +- utils/utils.go | 2 +- utils/utils_test.go | 2 +- 35 files changed, 493 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f639188..3f5ff97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,11 @@ - `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors. - README and README_RU examples now use the new handler signature and document the long-message helpers. - README and README_RU now link to the project wiki, and the wiki now includes a page-priority tracker while content is being filled in. +- README and README_RU now document scenes, session scopes, scene state helpers, and `SceneActionPass` semantics. +- `TODO.md` and the framework backlog pages now group the remaining framework work into explicit priority 1, 2, and 3 buckets. - Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides. +- Scene runtime sentinel errors now have explicit godoc comments. +- Public scene structs now document their exported fields more explicitly. - `MsgContext.Context()` now safely falls back to `context.Background()` when no request-scoped context is attached. - `MsgContext` reply, edit, callback, delete, action, and draft-limiter paths now use the context accessor instead of reaching into raw internal state. - Version constants were bumped to `v1.0.0-rc.12`. @@ -33,6 +37,8 @@ ### Tests - Added regression tests for `MsgContext.BindArgs(...)`, including scalar conversion, tail-string binding, zero-value trailing fields, invalid targets, unsupported field types, and end-to-end command/payload binding. +- Added scene regression tests for runtime guards, scene-local command handling, and `SceneActionPass` preserving session state. +- Added scene regression tests for message fallback handling, user-scoped session lookup without `Msg`, and custom `SessionStore` error propagation. ## v1.0.0-rc.11 diff --git a/README.md b/README.md index 2350721..b550fd5 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) -![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) +![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases) A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It simplifies bot development with a clean plugin system, middleware support, automatic command generation, and built-in rate limiting. [На русском](README_RU.md) -[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) +[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki) --- @@ -29,7 +29,7 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s ## 📦 Installation ```bash -go get git.nix13.pw/scuroneko/laniakea +go get git.scuroneko.dev/scuroneko/laniakea ``` or @@ -47,7 +47,7 @@ package main import ( "log" - "git.nix13.pw/scuroneko/laniakea" // Import the Laniakea library + "git.scuroneko.dev/scuroneko/laniakea" // Import the Laniakea library ) // echo is a command handler function. @@ -182,6 +182,43 @@ if err != nil { bot.DatabaseContext(db) ``` +### Scenes and Sessions + +Scenes model multi-step conversations inside a plugin. Each active scene is stored in a session keyed by scope, so you can isolate flows per user, per chat, or per user-chat pair. + +```go +plugin := laniakea.NewPlugin[MyDB]("signup") + +plugin.NewScene("signup"). + SetScope(laniakea.SceneScopeUserChat). + SetEntry("ask_name"). + OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) { + if ctx.Text == "" { + ctx.Answer("What is your name?") + return ctx.Stay(), nil + } + + if err := ctx.SaveData(struct { + Name string `json:"name"` + }{Name: ctx.Text}); err != nil { + return laniakea.SceneResult{}, err + } + + ctx.Answer("Nice to meet you.") + return ctx.Next("done"), nil + }). + OnStep("done", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) { + return ctx.Exit(), nil + }) +``` + +- Use `ctx.EnterScene("signup")` to enter the configured entry step. +- Use `ctx.EnterSceneStep("signup", "done")` when you need an explicit starting step. +- Return `ctx.Stay()`, `ctx.Next(step)`, `ctx.Exit()`, or `ctx.Pass()` from scene handlers to control flow. +- `SceneActionPass` keeps the current session unchanged and continues normal bot routing. +- Use `SceneContext.SaveData(...)` and `SceneContext.BindData(...)` for JSON session state. +- Use `SceneScopeUser`, `SceneScopeChat`, or `SceneScopeUserChat` depending on how widely a conversation should be shared. + ## 🧩 Middleware Middleware are functions that run before a command handler. They are perfect for cross-cutting concerns like logging, access control, rate limiting, or modifying the context. @@ -247,9 +284,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details. ## 📚 Learn More -[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) +[GoDoc](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea) -[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) +[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki) [Telegram Bot API](https://core.telegram.org/bots/api) diff --git a/README_RU.md b/README_RU.md index dbefd88..f038beb 100644 --- a/README_RU.md +++ b/README_RU.md @@ -4,13 +4,13 @@ [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) -![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) +![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases) Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке Middleware, автоматической генерации команд и встроенному рейтлимитеру. [English](README.md) -[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) +[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki) --- @@ -30,7 +30,7 @@ ## 📦 Установка ```bash -go get git.nix13.pw/scuroneko/laniakea +go get git.scuroneko.dev/scuroneko/laniakea ``` или @@ -48,7 +48,7 @@ package main import ( "log" - "git.nix13.pw/scuroneko/laniakea" // Импортируем библиотеку Laniakea + "git.scuroneko.dev/scuroneko/laniakea" // Импортируем библиотеку Laniakea ) // echo — это функция-обработчик команды. @@ -170,6 +170,43 @@ if err != nil { bot.DatabaseContext(db) ``` +### Сцены и сессии (Scenes and Sessions) + +Сцены описывают многошаговые диалоги внутри плагина. Активная сцена хранится в session state, ключ которого зависит от scope, поэтому поток можно изолировать на пользователя, на чат или на пару пользователь-чат. + +```go +plugin := laniakea.NewPlugin[MyDB]("signup") + +plugin.NewScene("signup"). + SetScope(laniakea.SceneScopeUserChat). + SetEntry("ask_name"). + OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) { + if ctx.Text == "" { + ctx.Answer("Как тебя зовут?") + return ctx.Stay(), nil + } + + if err := ctx.SaveData(struct { + Name string `json:"name"` + }{Name: ctx.Text}); err != nil { + return laniakea.SceneResult{}, err + } + + ctx.Answer("Приятно познакомиться.") + return ctx.Next("done"), nil + }). + OnStep("done", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) { + return ctx.Exit(), nil + }) +``` + +- Используйте `ctx.EnterScene("signup")`, чтобы войти в entry step, настроенный у сцены. +- Используйте `ctx.EnterSceneStep("signup", "done")`, если нужен явный стартовый step. +- Из scene handler возвращайте `ctx.Stay()`, `ctx.Next(step)`, `ctx.Exit()` или `ctx.Pass()` для управления потоком. +- `SceneActionPass` не меняет текущую session state и продолжает обычный routing бота. +- Для JSON-состояния сцены используйте `SceneContext.SaveData(...)` и `SceneContext.BindData(...)`. +- Выбирайте `SceneScopeUser`, `SceneScopeChat` или `SceneScopeUserChat` в зависимости от того, насколько широко должен разделяться диалог. + ### tgapi: API и Uploader В `tgapi` есть два клиента: @@ -243,9 +280,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE). ## 📚 Дополнительная информация -[GoDoc Laniakea](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) +[GoDoc Laniakea](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea) -[Wiki](https://git.nix13.pw/ScuroNeko/Laniakea/wiki) +[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki) [Telegram Bot API](https://core.telegram.org/bots/api) diff --git a/TODO.md b/TODO.md index ba2122f..4d22f15 100644 --- a/TODO.md +++ b/TODO.md @@ -4,14 +4,20 @@ The framework backlog has moved to the wiki. Primary page: -- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog +- https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Framework-Backlog Russian page: -- https://git.nix13.pw/ScuroNeko/Laniakea/wiki/Framework-Backlog-RU +- https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Framework-Backlog-RU -Current high-priority status: +Current priority split: -- `1. Conversation / Scene Model`: work in progress. +- `Priority 1`: update schema contract, user-facing vs internal error model, configuration freeze model. +- `Priority 2`: webhook runtime model, authorization and policy model, observability model. +- `Priority 3`: service layer and dependency graph model, plugin composition contract. + +Completed former high-priority items: + +- `1. Conversation / Scene Model`: completed in `v1.0.0-rc.12`. - `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`. - `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`. diff --git a/bot.go b/bot.go index 305d0ed..8163819 100644 --- a/bot.go +++ b/bot.go @@ -12,10 +12,10 @@ import ( "sync" "time" - "git.nix13.pw/scuroneko/extypes" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/laniakea/utils" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/extypes" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/slog" "github.com/alitto/pond/v2" ) @@ -350,6 +350,10 @@ func (bot *Bot[T]) GetDraftProvider() *DraftProvider { // SetSessionStore replaces the session store used for scene management. func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { + if store == nil { + bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore") + return bot + } bot.sessionStore = store return bot } @@ -363,6 +367,10 @@ func (bot *Bot[T]) GetSessionStore() SessionStore { func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { newPriority := make([]SceneScope, 0, 3) for _, scope := range priority { + if scope != SceneScopeUser && scope != SceneScopeChat && scope != SceneScopeUserChat { + bot.logger.Warnln(fmt.Sprintf("invalid scene scope %v in priority list; ignoring", scope)) + continue + } if slices.Index(newPriority, scope) >= 0 { bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope)) continue diff --git a/bot_opts.go b/bot_opts.go index d10aba8..83e7413 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -5,7 +5,7 @@ import ( "strconv" "strings" - "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) // BotOpts holds configuration options for initializing a Bot. diff --git a/bot_opts_test.go b/bot_opts_test.go index 4c5c7ed..7dc3340 100644 --- a/bot_opts_test.go +++ b/bot_opts_test.go @@ -4,7 +4,7 @@ import ( "reflect" "testing" - "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) { diff --git a/bot_scene.go b/bot_scene.go index e5c97ed..2fff340 100644 --- a/bot_scene.go +++ b/bot_scene.go @@ -35,9 +35,6 @@ func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) { func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, error) { var zero SceneSession - if ctx.Msg == nil && ctx.FromID == 0 { - return "", zero, ErrMessageNil - } for _, scope := range bot.sceneScopePriority { key, ok := buildSceneKey(scope, ctx) diff --git a/bot_test.go b/bot_test.go index 8795daa..e1028fb 100644 --- a/bot_test.go +++ b/bot_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" ) func TestGetUpdateTypesReturnsCopy(t *testing.T) { diff --git a/cmd_generator.go b/cmd_generator.go index 2bf524a..cf8f572 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) // CmdRegexp matches command names allowed for Telegram command registration. diff --git a/cmd_generator_test.go b/cmd_generator_test.go index bbbca03..269ef0a 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -10,8 +10,8 @@ import ( "sync/atomic" "testing" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" ) type roundTripFunc func(*http.Request) (*http.Response, error) diff --git a/drafts.go b/drafts.go index 1e1c89a..d550c60 100644 --- a/drafts.go +++ b/drafts.go @@ -5,7 +5,7 @@ import ( "sync" "sync/atomic" - "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) // Interface for generating unique draft IDs. diff --git a/drafts_test.go b/drafts_test.go index 887e19a..3d79e03 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" ) func TestDraftFlushRequiresChatID(t *testing.T) { diff --git a/errors.go b/errors.go index 0ccfde9..3132155 100644 --- a/errors.go +++ b/errors.go @@ -24,7 +24,8 @@ var ( ErrPayloadTypeMismatch = errors.New("payload type mismatch") // ErrDraftChatIDZero reports that a draft has no target chat ID. ErrDraftChatIDZero = errors.New("zero draft chat ID") - ErrMessageNil = errors.New("message is nil") + // ErrMessageNil reports that a required message value is nil. + ErrMessageNil = errors.New("message is nil") // ErrMessageContextNil reports that an operation requires ctx.Msg but none is set. ErrMessageContextNil = errors.New("message context is nil") // ErrEditTargetMissing reports that an edit operation has no message target. @@ -55,6 +56,8 @@ var ( ErrNotInScene = errors.New("not in scene") // ErrSceneEntryNotSet reports that a scene has no configured entry step. ErrSceneEntryNotSet = errors.New("scene entry step not set") + // ErrSceneRuntimeNil reports that scene APIs were used without an attached runtime. + ErrSceneRuntimeNil = errors.New("scene runtime is nil") ) func validateMessageText(text string) error { diff --git a/go.mod b/go.mod index 7bfdeb5..0399919 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ -module git.nix13.pw/scuroneko/laniakea +module git.scuroneko.dev/scuroneko/laniakea go 1.26 retract v1.0.0-rc.5 require ( - git.nix13.pw/scuroneko/extypes v1.2.2 - git.nix13.pw/scuroneko/slog v1.1.2 + git.scuroneko.dev/scuroneko/extypes v1.2.3 + git.scuroneko.dev/scuroneko/slog v1.1.3 github.com/alitto/pond/v2 v2.7.0 golang.org/x/time v0.15.0 ) diff --git a/go.sum b/go.sum index 72b92c1..6297810 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -git.nix13.pw/scuroneko/extypes v1.2.2 h1:N54c1ejrPs1yfIkvYuwqI7B1+8S9mDv2GqQA6sct4dk= -git.nix13.pw/scuroneko/extypes v1.2.2/go.mod h1:b4XYk1OW1dVSiE2MT/OMuX/K/UItf1swytX6eroVYnk= -git.nix13.pw/scuroneko/slog v1.1.2 h1:pl7tV5FN25Yso7sLYoOgBXi9+jLo5BDJHWmHlNPjpY0= -git.nix13.pw/scuroneko/slog v1.1.2/go.mod h1:UcfRIHDqpVQHahBGM93awLDK8//AsAvOqBwwbWqMkjM= +git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s= +git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors= +git.scuroneko.dev/scuroneko/slog v1.1.3 h1:vI4GZykn8gDb6OJ2xq+KLcEk38M7O4e/z1kzpeRHEHw= +git.scuroneko.dev/scuroneko/slog v1.1.3/go.mod h1:gnDap54sfZv3EuSyZd7fjOH46aLbDFpvtN2wgFcWkgE= github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= diff --git a/handler.go b/handler.go index 99a6759..e93c387 100644 --- a/handler.go +++ b/handler.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) // ErrInvalidPayloadType is returned when callback payload encoding type is unknown. diff --git a/handler_test.go b/handler_test.go index d5a60e9..efed1cc 100644 --- a/handler_test.go +++ b/handler_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" ) func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { diff --git a/keyboard.go b/keyboard.go index ab19c67..fc5635e 100644 --- a/keyboard.go +++ b/keyboard.go @@ -3,8 +3,8 @@ package laniakea import ( "fmt" - "git.nix13.pw/scuroneko/extypes" - "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/extypes" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) const ( diff --git a/methods.go b/methods.go index e10efba..2a55b77 100644 --- a/methods.go +++ b/methods.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - "git.nix13.pw/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) // Updates fetches new updates from Telegram API using long polling. diff --git a/msg_context.go b/msg_context.go index a334439..fd1c5fb 100644 --- a/msg_context.go +++ b/msg_context.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" ) // MsgContext holds the context for handling a Telegram message or callback query. @@ -646,6 +646,10 @@ func (ctx *MsgContext) Context() context.Context { // 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 @@ -672,6 +676,10 @@ func (ctx *MsgContext) EnterScene(name string) error { // 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 @@ -695,6 +703,10 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { // 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 diff --git a/msg_context_test.go b/msg_context_test.go index b7e9fa2..d69ddd4 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" ) func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { diff --git a/plugins.go b/plugins.go index 3229a22..75783aa 100644 --- a/plugins.go +++ b/plugins.go @@ -4,10 +4,10 @@ import ( "errors" "regexp" - "git.nix13.pw/scuroneko/extypes" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/laniakea/utils" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/extypes" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/slog" ) // CommandValueType defines the expected type of command argument. diff --git a/runners_test.go b/runners_test.go index 7e17d77..ec04a28 100644 --- a/runners_test.go +++ b/runners_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/slog" ) func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { diff --git a/scene.go b/scene.go index 7b6263c..8e4132d 100644 --- a/scene.go +++ b/scene.go @@ -10,9 +10,13 @@ type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error) // Scene defines a multi-step conversational flow. type Scene[T any] struct { - Name string - Scope SceneScope - Entry string // starting step + // Name identifies the scene in plugin registration and session state. + Name string + // Scope controls how active scene sessions are keyed and shared. + Scope SceneScope + // Entry names the first step used by MsgContext.EnterScene. + Entry string + // PluginName stores the owning plugin name for scene resolution. PluginName string steps map[string]SceneHandler[T] @@ -43,6 +47,7 @@ func (s *Scene[T]) SetEntry(step string) *Scene[T] { s.Entry = step return s } + func (s *Scene[T]) setPluginName(name string) *Scene[T] { s.PluginName = name return s @@ -92,9 +97,12 @@ func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, e // SceneSession stores the active scene state for one session key. type SceneSession struct { + // Scene is the registered scene name for the active session. Scene string - Step string - Data []byte + // Step is the current step name inside the active scene. + Step string + // Data stores opaque session payload bytes, typically JSON. + Data []byte } // SetData stores arbitrary opaque session data. diff --git a/scene_handler.go b/scene_handler.go index de640a7..ef790b7 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -26,6 +26,9 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { if scene.PluginName != "" && scene.PluginName != plugin.name { continue } + if !plugin.executeMiddlewares(ctx, bot.dbContext) { + return false, nil + } sceneCtx := &SceneContext{ MsgContext: ctx, sess: session, @@ -40,10 +43,15 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error if ctx.MsgContext == nil || ctx.sess.Scene == "" { return false, nil } - text := ctx.Msg.Text - if text == "" { - text = ctx.Msg.Caption + + var text string + if ctx.Msg != nil { + text = ctx.Msg.Text + if text == "" { + text = ctx.Msg.Caption + } } + text = strings.TrimSpace(text) prefix, cmd, args := bot.parseCommand(text) if cmd != "" { @@ -89,7 +97,6 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S return false, err } return true, nil - case SceneActionNext: if result.Next == "" { return false, ErrSceneStepNotFound @@ -102,16 +109,13 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S return false, err } return true, nil - case SceneActionExit: if err := bot.sessionStore.Delete(ctx.key); err != nil { return false, err } return true, nil - case SceneActionPass: return false, nil - default: return false, nil } diff --git a/scene_test.go b/scene_test.go index 75bcfb6..394becb 100644 --- a/scene_test.go +++ b/scene_test.go @@ -5,10 +5,28 @@ import ( "errors" "testing" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" ) +type failingSessionStore struct { + getErr error + setErr error + deleteErr error +} + +func (s failingSessionStore) Get(key string) (SceneSession, error) { + return SceneSession{}, s.getErr +} + +func (s failingSessionStore) Set(key string, session SceneSession) error { + return s.setErr +} + +func (s failingSessionStore) Delete(key string) error { + return s.deleteErr +} + func TestPluginAddSceneRegistersScene(t *testing.T) { plugin := NewPlugin[NoDB]("wizard") scene := NewScene[NoDB]("signup") @@ -172,3 +190,279 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { } }) } + +func TestSceneContextMethodsRequireRuntime(t *testing.T) { + ctx := &MsgContext{} + + if err := ctx.EnterScene("signup"); !errors.Is(err, ErrSceneRuntimeNil) { + t.Fatalf("expected ErrSceneRuntimeNil from EnterScene, got %v", err) + } + if err := ctx.EnterSceneStep("signup", "start"); !errors.Is(err, ErrSceneRuntimeNil) { + t.Fatalf("expected ErrSceneRuntimeNil from EnterSceneStep, got %v", err) + } + if err := ctx.ExitScene(); !errors.Is(err, ErrSceneRuntimeNil) { + t.Fatalf("expected ErrSceneRuntimeNil from ExitScene, got %v", err) + } +} + +func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { + sceneCommandCalled := false + stepCalled := false + + plugin := NewPlugin[NoDB]("wizard") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + stepCalled = true + return ctx.Stay(), nil + }). + OnCommand("cancel", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + sceneCommandCalled = true + if ctx.Prefix != "/" { + t.Fatalf("unexpected prefix: got %q want /", ctx.Prefix) + } + if ctx.Text != "right now" { + t.Fatalf("unexpected scene command text: got %q want %q", ctx.Text, "right now") + } + if len(ctx.Args) != 2 || ctx.Args[0] != "right" || ctx.Args[1] != "now" { + t.Fatalf("unexpected scene command args: %#v", ctx.Args) + } + return ctx.Exit(), nil + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 2, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 8, + Text: "/cancel right now", + Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + From: &tgapi.User{ID: 42}, + }, + }) + + if !sceneCommandCalled { + t.Fatal("expected scene command handler to be called") + } + if stepCalled { + t.Fatal("expected scene command to short-circuit the scene step") + } +} + +func TestScenePassDoesNotPersistSessionData(t *testing.T) { + commandCalled := false + + plugin := NewPlugin[NoDB]("wizard") + plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + commandCalled = true + return nil + }, "ping") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + if err := ctx.SaveData(struct { + Value string `json:"value"` + }{Value: "changed"}); err != nil { + t.Fatalf("SaveData returned error: %v", err) + } + return ctx.Pass(), nil + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + }) + if !ok { + t.Fatal("expected scene key to be built") + } + + before, err := bot.sessionStore.Get(key) + if err != nil { + t.Fatalf("Get before handle returned error: %v", err) + } + if before.HasData() { + t.Fatalf("expected empty session data before handle, got %#v", before) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 3, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 9, + Text: "/ping", + Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + From: &tgapi.User{ID: 42}, + }, + }) + + if !commandCalled { + t.Fatal("expected normal command routing to continue after SceneActionPass") + } + + after, err := bot.sessionStore.Get(key) + if err != nil { + t.Fatalf("Get after handle returned error: %v", err) + } + if after.Scene != "signup" || after.Step != "start" { + t.Fatalf("unexpected session after pass: %#v", after) + } + if after.HasData() { + t.Fatalf("expected SceneActionPass to leave session data unchanged, got %#v", after) + } +} + +func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { + fallbackCalled := false + + plugin := NewPlugin[NoDB]("wizard") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + return ctx.Stay(), nil + }). + OnMessage(func(ctx *SceneContext, db NoDB) (SceneResult, error) { + fallbackCalled = true + if ctx.Text != "hello fallback" { + t.Fatalf("unexpected fallback text: got %q want %q", ctx.Text, "hello fallback") + } + return ctx.Exit(), nil + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + FromID: 42, + }) + if !ok { + t.Fatal("expected scene key to be built") + } + if err := bot.sessionStore.Set(key, SceneSession{Scene: "signup", Step: "unknown"}); err != nil { + t.Fatalf("Set returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 4, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 10, + Text: "hello fallback", + Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + From: &tgapi.User{ID: 42}, + }, + }) + + if !fallbackCalled { + t.Fatal("expected scene fallback handler to be called") + } +} + +func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat}, + } + + if err := bot.sessionStore.Set("user_id:42", SceneSession{Scene: "signup", Step: "start"}); err != nil { + t.Fatalf("Set returned error: %v", err) + } + + key, session, err := bot.findSceneSession(&MsgContext{FromID: 42}) + if err != nil { + t.Fatalf("findSceneSession returned error: %v", err) + } + if key != "user_id:42" { + t.Fatalf("unexpected session key: got %q want %q", key, "user_id:42") + } + if session.Scene != "signup" || session.Step != "start" { + t.Fatalf("unexpected session: %#v", session) + } +} + +func TestSceneStoreErrorsPropagate(t *testing.T) { + getErr := errors.New("get failed") + setErr := errors.New("set failed") + + t.Run("find scene session get error", func(t *testing.T) { + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + sessionStore: failingSessionStore{getErr: getErr}, + sceneScopePriority: []SceneScope{SceneScopeUser}, + } + + _, _, err := bot.findSceneSession(&MsgContext{FromID: 42}) + if !errors.Is(err, getErr) { + t.Fatalf("expected getErr, got %v", err) + } + }) + + t.Run("apply scene result set error", func(t *testing.T) { + scene := NewScene[NoDB]("signup").OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + return ctx.Stay(), nil + }) + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + sessionStore: failingSessionStore{setErr: setErr}, + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + + _, err := bot.applySceneResult(scene, &SceneContext{ + MsgContext: &MsgContext{}, + sess: SceneSession{Scene: "signup", Step: "start"}, + key: "user_id:42:chat_id:100", + }, SceneResult{Action: SceneActionStay}) + if !errors.Is(err, setErr) { + t.Fatalf("expected setErr, got %v", err) + } + }) +} diff --git a/tgapi/api.go b/tgapi/api.go index 7a22dcb..e5ec1e9 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -9,8 +9,8 @@ import ( "net/http" "time" - "git.nix13.pw/scuroneko/laniakea/utils" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/slog" ) // APIOpts holds configuration options for initializing the Telegram API client. diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 237b156..353da19 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -1,6 +1,6 @@ package tgapi -import "git.nix13.pw/scuroneko/extypes" +import "git.scuroneko.dev/scuroneko/extypes" // MessageID represents a message identifier wrapper returned by some API methods. type MessageID struct { diff --git a/tgapi/methods.go b/tgapi/methods.go index d9789ae..a13f328 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -6,7 +6,7 @@ import ( "io" "net/http" - "git.nix13.pw/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/laniakea/utils" ) // UpdateParams holds parameters for the getUpdates method. diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 9692179..5012160 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -10,8 +10,8 @@ import ( "strings" "time" - "git.nix13.pw/scuroneko/laniakea/utils" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/slog" ) const ( diff --git a/utils.go b/utils.go index 7e94ce2..0cb42ac 100644 --- a/utils.go +++ b/utils.go @@ -3,7 +3,7 @@ package laniakea import ( "strings" - "git.nix13.pw/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/laniakea/utils" ) // Ptr returns a pointer to v. diff --git a/utils/multipart_test.go b/utils/multipart_test.go index 479ed05..a1dc522 100644 --- a/utils/multipart_test.go +++ b/utils/multipart_test.go @@ -6,8 +6,8 @@ import ( "mime/multipart" "testing" - "git.nix13.pw/scuroneko/laniakea/tgapi" - "git.nix13.pw/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/utils" ) type multipartEncodeParams struct { diff --git a/utils/utils.go b/utils/utils.go index 144f161..c665508 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -3,7 +3,7 @@ package utils import ( "os" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/slog" ) // GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL. diff --git a/utils/utils_test.go b/utils/utils_test.go index 4df68fb..16e9a05 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "git.nix13.pw/scuroneko/slog" + "git.scuroneko.dev/scuroneko/slog" ) func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) { -- 2.54.0 From 66eb72cb3c5bad74c99bfedec5e1b1e4c327cf92 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 30 Mar 2026 01:01:13 +0300 Subject: [PATCH 66/99] Finalize rc.13 framework contracts Formalize update routing, config freeze, and error visibility Add regression coverage and sync backlog records --- CHANGELOG.md | 17 ++ TODO.md | 8 +- bot.go | 54 +++++ bot_test.go | 224 +++++++++++++++++++++ error_model.go | 59 ++++++ handler_test.go | 477 ++++++++++++++++++++++++++++++++++++++++++++ msg_context.go | 60 +++++- msg_context_test.go | 158 +++++++++++++++ plugins.go | 9 +- utils/version.go | 4 +- 10 files changed, 1052 insertions(+), 18 deletions(-) create mode 100644 error_model.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5ff97..299e5b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## v1.0.0-rc.13 + +### Added +- `AsUserError(...)`, `AsInternalError(...)`, `IsUserError(...)`, and `IsInternalError(...)` for explicitly marking centralized handler errors as user-visible or internal-only without breaking the existing default error flow. + +### Changed +- Bot configuration mutators now treat the bot as configuration-frozen after the first run begins and ignore late mutation attempts for bot-level config such as prefixes, payload defaults, plugins, middleware, runners, localization, scene session wiring, and database context injection. +- `MsgContext` godoc and field comments now describe the normalized update contract more explicitly, including when `Msg`, `From`, callback target fields, `Text`, and `Args` are expected to be populated. +- `MsgContext.Error(...)` and returned handler errors now suppress the automatic user reply when the error is explicitly marked with `AsInternalError(...)`, while keeping the previous user-visible default for unclassified errors. + +### Tests +- Added regression coverage for the bot configuration freeze model, including ignored post-run mutations for core bot configuration methods and late registration paths. +- Added table-driven update-contract coverage for `prepareUpdateCtx(...)`, including message-backed, callback-backed, user-backed, and no-user update kinds. +- Added regression tests proving that `edited_message` and `edited_channel_post` stay out of command routing and continue through generic update handlers. +- Added callback-routing regression tests for both chat-message and inline-message callback targets, including `CallbackQueryId`, `CallbackMsgId`, `InlineMsgId`, and payload-argument guarantees. +- Added regression tests for the new error-visibility model in both message and callback flows, including silent internal-only errors and explicit user-visible callback replies. + ## v1.0.0-rc.12 ### Added diff --git a/TODO.md b/TODO.md index 4d22f15..70a59a3 100644 --- a/TODO.md +++ b/TODO.md @@ -12,12 +12,14 @@ Russian page: Current priority split: -- `Priority 1`: update schema contract, user-facing vs internal error model, configuration freeze model. -- `Priority 2`: webhook runtime model, authorization and policy model, observability model. -- `Priority 3`: service layer and dependency graph model, plugin composition contract. +- `Priority 1`: webhook runtime model, authorization and policy model, observability model. +- `Priority 2`: service layer and dependency graph model, plugin composition contract. Completed former high-priority items: +- `[v1.0.0-rc.13] Update schema contract`: documented and tested the normalized `MsgContext` update-routing contract, including routing categories and per-update field guarantees. +- `[v1.0.0-rc.13] User-facing vs internal error model`: added explicit user-visible vs internal-only error markers and updated centralized handler error routing accordingly. +- `[v1.0.0-rc.13] Configuration freeze model`: formalized bot configuration freeze after first run, documented lifecycle commit points, and added regression coverage for ignored late mutations. - `1. Conversation / Scene Model`: completed in `v1.0.0-rc.12`. - `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`. - `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`. diff --git a/bot.go b/bot.go index 8163819..70d47b3 100644 --- a/bot.go +++ b/bot.go @@ -118,6 +118,18 @@ type Bot[T DbContext] struct { ran bool } +func (bot *Bot[T]) configMutable(method string) bool { + bot.runStateMu.Lock() + defer bot.runStateMu.Unlock() + if !bot.ran { + return true + } + if bot.logger != nil { + bot.logger.Warnln(fmt.Sprintf("%s called after bot configuration was frozen; ignoring", method)) + } + return false +} + // NewBot creates and initializes a new Bot instance using the provided BotOpts. // // Automatically: @@ -339,6 +351,9 @@ func (bot *Bot[T]) L10n(lang, key string) string { // SetDraftProvider replaces the default DraftProvider with a custom one. // Useful for using LinearDraftIdGenerator to persist draft IDs across restarts. func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { + if !bot.configMutable("SetDraftProvider") { + return bot + } bot.draftProvider = p return bot } @@ -350,6 +365,9 @@ func (bot *Bot[T]) GetDraftProvider() *DraftProvider { // SetSessionStore replaces the session store used for scene management. func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { + if !bot.configMutable("SetSessionStore") { + return bot + } if store == nil { bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore") return bot @@ -365,6 +383,9 @@ func (bot *Bot[T]) GetSessionStore() SessionStore { // SetSceneScopePriority sets the lookup order for resolving active scene sessions. func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { + if !bot.configMutable("SetSceneScopePriority") { + return bot + } newPriority := make([]SceneScope, 0, 3) for _, scope := range priority { if scope != SceneScopeUser && scope != SceneScopeChat && scope != SceneScopeUserChat { @@ -391,6 +412,9 @@ func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { // Value-typed contexts are supported, but the bot warns once because handlers // receive T by value. func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] { + if !bot.configMutable("DatabaseContext") { + return bot + } if !bot.warnedValueDB && shouldWarnOnValueDBContext[T]() && bot.logger != nil { bot.logger.Warnln("database context uses a value type; shared dependencies should usually use a pointer type as T") bot.warnedValueDB = true @@ -403,6 +427,9 @@ func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] { // UpdateTypes sets the list of update types the bot will request from Telegram. // Overwrites any previously set types. func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { + if !bot.configMutable("UpdateTypes") { + return bot + } bot.updateTypes = make([]tgapi.UpdateType, 0) bot.updateTypes = append(bot.updateTypes, t...) return bot @@ -413,6 +440,9 @@ func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { // Base64 stores the same JSON encoded as a Base64URL string. // InlineKeyboard.SetPayloadType may override this value for an individual keyboard. func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { + if !bot.configMutable("SetPayloadType") { + return bot + } bot.payloadType = t return bot } @@ -423,6 +453,9 @@ func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType } // SetStrictPayloadType enables or disables strict callback payload decoding. // When enabled, callback payloads must match the bot's default payload type. func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] { + if !bot.configMutable("SetStrictPayloadType") { + return bot + } bot.strictPayloadType = strict return bot } @@ -430,6 +463,9 @@ func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] { // AddUpdateType adds one or more update types to the list. // Does not overwrite existing types. func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { + if !bot.configMutable("AddUpdateType") { + return bot + } bot.updateTypes = append(bot.updateTypes, t...) return bot } @@ -437,6 +473,9 @@ func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { // AddPrefixes adds one or more command prefixes (e.g., "/", "!"). // Must have at least one prefix before Run(). func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { + if !bot.configMutable("AddPrefixes") { + return bot + } bot.prefixes = append(bot.prefixes, prefixes...) return bot } @@ -445,6 +484,9 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { // Use "%s" to insert the error message. // Example: "❌ Error: %s" → "❌ Error: Command not found". func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { + if !bot.configMutable("ErrorTemplate") { + return bot + } bot.errorTemplate = s return bot } @@ -478,6 +520,9 @@ func (bot *Bot[T]) Debug(debug bool) *Bot[T] { // are passed here. Post-registration mutation through the original *Plugin is // not a supported API, even if some changes appear to work due to shared maps. func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { + if !bot.configMutable("AddPlugins") { + return bot + } level := bot.GetLoggerLevel() for _, p := range plugin { if p == nil { @@ -514,6 +559,9 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { // // Middleware with an empty name are skipped with a warning. func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { + if !bot.configMutable("AddMiddleware") { + return bot + } for _, m := range middleware { if m.name == "" { bot.logger.Warnln("middleware must have a non-empty name") @@ -552,6 +600,9 @@ func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { // // Runners with an empty name are skipped with a warning. func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { + if !bot.configMutable("AddRunner") { + return bot + } if runner.name == "" { bot.logger.Warnln("runner must have a non-empty name") return bot @@ -575,6 +626,9 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { // // Replaces any previously set L10n instance. func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { + if !bot.configMutable("AddL10n") { + return bot + } if l == nil { bot.logger.Warn("AddL10n called with nil L10n; localization will be disabled") return bot diff --git a/bot_test.go b/bot_test.go index e1028fb..712e0ab 100644 --- a/bot_test.go +++ b/bot_test.go @@ -215,3 +215,227 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) { t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err) } } + +func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { + type testDB struct{ Name string } + + makeBot := func() *Bot[*testDB] { + return &Bot[*testDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, + payloadType: BotPayloadBase64, + strictPayloadType: false, + errorTemplate: "%s", + l10n: &L10n{}, + draftProvider: &DraftProvider{}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + } + + tests := []struct { + name string + check func(t *testing.T, bot *Bot[*testDB]) + }{ + { + name: "DatabaseContext", + check: func(t *testing.T, bot *Bot[*testDB]) { + original := &testDB{Name: "before"} + bot.DatabaseContext(original) + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + later := &testDB{Name: "after"} + bot.DatabaseContext(later) + if bot.dbContext != original { + t.Fatal("DatabaseContext mutated after configuration freeze") + } + }, + }, + { + name: "UpdateTypes", + check: func(t *testing.T, bot *Bot[*testDB]) { + original := append([]tgapi.UpdateType(nil), bot.updateTypes...) + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.UpdateTypes(tgapi.UpdateTypePoll) + if !reflect.DeepEqual(bot.updateTypes, original) { + t.Fatalf("UpdateTypes mutated after configuration freeze: got %v want %v", bot.updateTypes, original) + } + }, + }, + { + name: "AddUpdateType", + check: func(t *testing.T, bot *Bot[*testDB]) { + original := append([]tgapi.UpdateType(nil), bot.updateTypes...) + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.AddUpdateType(tgapi.UpdateTypePoll) + if !reflect.DeepEqual(bot.updateTypes, original) { + t.Fatalf("AddUpdateType mutated after configuration freeze: got %v want %v", bot.updateTypes, original) + } + }, + }, + { + name: "SetPayloadType", + check: func(t *testing.T, bot *Bot[*testDB]) { + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.SetPayloadType(BotPayloadJson) + if bot.payloadType != BotPayloadBase64 { + t.Fatalf("payloadType mutated after configuration freeze: got %q want %q", bot.payloadType, BotPayloadBase64) + } + }, + }, + { + name: "SetStrictPayloadType", + check: func(t *testing.T, bot *Bot[*testDB]) { + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.SetStrictPayloadType(true) + if bot.strictPayloadType { + t.Fatal("strictPayloadType mutated after configuration freeze") + } + }, + }, + { + name: "AddPrefixes", + check: func(t *testing.T, bot *Bot[*testDB]) { + original := append([]string(nil), bot.prefixes...) + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.AddPrefixes("!") + if !reflect.DeepEqual(bot.prefixes, original) { + t.Fatalf("prefixes mutated after configuration freeze: got %v want %v", bot.prefixes, original) + } + }, + }, + { + name: "ErrorTemplate", + check: func(t *testing.T, bot *Bot[*testDB]) { + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.ErrorTemplate("changed") + if bot.errorTemplate != "%s" { + t.Fatalf("errorTemplate mutated after configuration freeze: got %q want %q", bot.errorTemplate, "%s") + } + }, + }, + { + name: "SetDraftProvider", + check: func(t *testing.T, bot *Bot[*testDB]) { + original := bot.draftProvider + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.SetDraftProvider(&DraftProvider{}) + if bot.draftProvider != original { + t.Fatal("draftProvider mutated after configuration freeze") + } + }, + }, + { + name: "SetSessionStore", + check: func(t *testing.T, bot *Bot[*testDB]) { + original := bot.sessionStore + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.SetSessionStore(NewMemorySessionStore()) + if bot.sessionStore != original { + t.Fatal("sessionStore mutated after configuration freeze") + } + }, + }, + { + name: "SetSceneScopePriority", + check: func(t *testing.T, bot *Bot[*testDB]) { + original := append([]SceneScope(nil), bot.sceneScopePriority...) + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.SetSceneScopePriority([]SceneScope{SceneScopeUser}) + if !reflect.DeepEqual(bot.sceneScopePriority, original) { + t.Fatalf("sceneScopePriority mutated after configuration freeze: got %v want %v", bot.sceneScopePriority, original) + } + }, + }, + { + name: "AddL10n", + check: func(t *testing.T, bot *Bot[*testDB]) { + original := bot.l10n + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + t.Cleanup(bot.finishRun) + + bot.AddL10n(&L10n{}) + if bot.l10n != original { + t.Fatal("l10n mutated after configuration freeze") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.check(t, makeBot()) + }) + } +} + +func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + middlewares: []Middleware[NoDB]{NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })}, + runners: []Runner[NoDB]{NewRunner("base", func(bot *Bot[NoDB]) error { return nil })}, + } + plugin := NewPlugin[NoDB]("late") + + if err := bot.beginRun(); err != nil { + t.Fatalf("beginRun returned error: %v", err) + } + defer bot.finishRun() + + bot.AddPlugins(plugin) + bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true })) + bot.AddRunner(NewRunner("late", func(bot *Bot[NoDB]) error { return nil })) + + if len(bot.plugins) != 0 { + t.Fatalf("expected AddPlugins to be ignored after configuration freeze, got %d plugins", len(bot.plugins)) + } + if len(bot.middlewares) != 1 { + t.Fatalf("expected AddMiddleware to be ignored after configuration freeze, got %d middlewares", len(bot.middlewares)) + } + if len(bot.runners) != 1 { + t.Fatalf("expected AddRunner to be ignored after configuration freeze, got %d runners", len(bot.runners)) + } +} diff --git a/error_model.go b/error_model.go new file mode 100644 index 0000000..940ba67 --- /dev/null +++ b/error_model.go @@ -0,0 +1,59 @@ +package laniakea + +import "errors" + +type classifiedError struct { + err error + userVisible bool + internalOnly bool +} + +func (e *classifiedError) Error() string { + if e == nil || e.err == nil { + return "" + } + return e.err.Error() +} + +func (e *classifiedError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + +// AsUserError marks err as safe to show to the user through the centralized +// handler error flow. +func AsUserError(err error) error { + if err == nil { + return nil + } + return &classifiedError{err: err, userVisible: true} +} + +// AsInternalError marks err as internal-only so it will be logged but not sent +// to the user through the centralized handler error flow. +func AsInternalError(err error) error { + if err == nil { + return nil + } + return &classifiedError{err: err, internalOnly: true} +} + +// IsUserError reports whether err was explicitly marked as user-visible. +func IsUserError(err error) bool { + var classified *classifiedError + if !errors.As(err, &classified) { + return false + } + return classified.userVisible +} + +// IsInternalError reports whether err was explicitly marked as internal-only. +func IsInternalError(err error) bool { + var classified *classifiedError + if !errors.As(err, &classified) { + return false + } + return classified.internalOnly +} diff --git a/handler_test.go b/handler_test.go index efed1cc..9bf901a 100644 --- a/handler_test.go +++ b/handler_test.go @@ -8,6 +8,10 @@ import ( "git.scuroneko.dev/scuroneko/slog" ) +func ptr[T any](v T) *T { + return &v +} + func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { bot := &Bot[NoDB]{prefixes: []string{"", "/"}} @@ -74,6 +78,275 @@ func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { } } +func TestPrepareUpdateCtxContract(t *testing.T) { + tests := []struct { + name string + update *tgapi.Update + wantMsg bool + wantFrom bool + wantFromID int64 + wantCallbackID string + wantCallbackMsgID int + wantInlineMsgID string + }{ + { + name: "message", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 11, + From: &tgapi.User{ID: 101}, + Chat: &tgapi.Chat{ID: 1001}, + }, + }, + wantMsg: true, + wantFrom: true, + wantFromID: 101, + }, + { + name: "edited message", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeEditedMessage, + EditedMessage: &tgapi.Message{ + MessageID: 12, + From: &tgapi.User{ID: 102}, + Chat: &tgapi.Chat{ID: 1002}, + }, + }, + wantMsg: true, + wantFrom: true, + wantFromID: 102, + }, + { + name: "channel post sender chat", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeChannelPost, + ChannelPost: &tgapi.Message{ + MessageID: 13, + Chat: &tgapi.Chat{ID: -1003}, + }, + }, + wantMsg: true, + }, + { + name: "business message", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeBusinessMessage, + BusinessMessage: &tgapi.Message{ + MessageID: 14, + From: &tgapi.User{ID: 103}, + Chat: &tgapi.Chat{ID: 1004}, + }, + }, + wantMsg: true, + wantFrom: true, + wantFromID: 103, + }, + { + name: "inline query", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeInlineQuery, + InlineQuery: &tgapi.InlineQuery{ID: "iq", From: tgapi.User{ID: 104}}, + }, + wantFrom: true, + wantFromID: 104, + }, + { + name: "chosen inline result", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeChosenInlineResult, + ChosenInlineResult: &tgapi.ChosenInlineResult{ResultID: "res", From: tgapi.User{ID: 105}}, + }, + wantFrom: true, + wantFromID: 105, + }, + { + name: "callback query with message", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-1", + From: tgapi.User{ID: 106}, + Message: &tgapi.Message{ + MessageID: 77, + Chat: &tgapi.Chat{ID: 1005}, + }, + }, + }, + wantMsg: true, + wantFrom: true, + wantFromID: 106, + wantCallbackID: "cb-1", + wantCallbackMsgID: 77, + }, + { + name: "callback query with inline message", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-2", + From: tgapi.User{ID: 107}, + InlineMessageID: ptr("inline-42"), + }, + }, + wantFrom: true, + wantFromID: 107, + wantCallbackID: "cb-2", + wantInlineMsgID:"inline-42", + }, + { + name: "shipping query", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeShippingQuery, + ShippingQuery: &tgapi.ShippingQuery{ID: "ship", From: tgapi.User{ID: 108}}, + }, + wantFrom: true, + wantFromID: 108, + }, + { + name: "pre checkout query", + update: &tgapi.Update{ + Type: tgapi.UpdateTypePreCheckoutQuery, + PreCheckoutQuery: &tgapi.PreCheckoutQuery{ID: "pre", From: tgapi.User{ID: 109}}, + }, + wantFrom: true, + wantFromID: 109, + }, + { + name: "purchased paid media", + update: &tgapi.Update{ + Type: tgapi.UpdateTypePurchasedPaidMedia, + PurchasedPaidMedia: &tgapi.PaidMediaPurchased{From: tgapi.User{ID: 110}}, + }, + wantFrom: true, + wantFromID: 110, + }, + { + name: "my chat member", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeMyChatMember, + MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}}, + }, + wantFrom: true, + wantFromID: 111, + }, + { + name: "chat member", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeChatMember, + ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}}, + }, + wantFrom: true, + wantFromID: 112, + }, + { + name: "chat join request", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeChatJoinRequest, + ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}}, + }, + wantFrom: true, + wantFromID: 113, + }, + { + name: "business connection", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeBusinessConnection, + BusinessConnection: &tgapi.BusinessConnection{User: tgapi.User{ID: 114}}, + }, + wantFrom: true, + wantFromID: 114, + }, + { + name: "poll answer", + update: &tgapi.Update{ + Type: tgapi.UpdateTypePollAnswer, + PollAnswer: &tgapi.PollAnswer{User: tgapi.User{ID: 115}}, + }, + wantFrom: true, + wantFromID: 115, + }, + { + name: "message reaction", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeMessageReaction, + MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}}, + }, + wantFrom: true, + wantFromID: 116, + }, + { + name: "chat boost", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeChatBoost, + ChatBoost: &tgapi.ChatBoostUpdated{ + Boost: tgapi.ChatBoost{Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 117}}}, + }, + }, + wantFrom: true, + wantFromID: 117, + }, + { + name: "removed chat boost", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeRemovedChatBoost, + RemovedChatBoost: &tgapi.ChatBoostRemoved{ + Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 118}}, + }, + }, + wantFrom: true, + wantFromID: 118, + }, + { + name: "poll", + update: &tgapi.Update{ + Type: tgapi.UpdateTypePoll, + Poll: &tgapi.Poll{ID: "poll"}, + }, + }, + { + name: "message reaction count", + update: &tgapi.Update{ + Type: tgapi.UpdateTypeMessageReactionCount, + MessageReactionCount: &tgapi.MessageReactionCountUpdated{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bot := &Bot[NoDB]{} + ctx := &MsgContext{} + bot.prepareUpdateCtx(tt.update, ctx) + + if got := ctx.Msg != nil; got != tt.wantMsg { + t.Fatalf("unexpected Msg presence: got %v want %v", got, tt.wantMsg) + } + if got := ctx.From != nil; got != tt.wantFrom { + t.Fatalf("unexpected From presence: got %v want %v", got, tt.wantFrom) + } + if ctx.FromID != tt.wantFromID { + t.Fatalf("unexpected FromID: got %d want %d", ctx.FromID, tt.wantFromID) + } + if ctx.CallbackQueryId != tt.wantCallbackID { + t.Fatalf("unexpected CallbackQueryId: got %q want %q", ctx.CallbackQueryId, tt.wantCallbackID) + } + if ctx.CallbackMsgId != tt.wantCallbackMsgID { + t.Fatalf("unexpected CallbackMsgId: got %d want %d", ctx.CallbackMsgId, tt.wantCallbackMsgID) + } + if ctx.InlineMsgId != tt.wantInlineMsgID { + t.Fatalf("unexpected InlineMsgId: got %q want %q", ctx.InlineMsgId, tt.wantInlineMsgID) + } + if ctx.Text != "" { + t.Fatalf("prepareUpdateCtx must not populate Text, got %q", ctx.Text) + } + if len(ctx.Args) != 0 { + t.Fatalf("prepareUpdateCtx must not populate Args, got %v", ctx.Args) + } + }) + } +} + func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { tests := []struct { name string @@ -323,3 +596,207 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { t.Fatalf("unexpected bound payload input: got %#v want %#v", got, want) } } + +func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { + commandCalled := false + updateCalled := false + + plugin := NewPlugin[NoDB]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + commandCalled = true + return nil + }, "ping") + plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoDB) error { + updateCalled = true + if ctx.Msg == nil { + t.Fatal("expected ctx.Msg in edited message handler") + } + if ctx.Text != "" { + t.Fatalf("expected empty Text in edited_message update handler, got %q", ctx.Text) + } + if len(ctx.Args) != 0 { + t.Fatalf("expected empty Args in edited_message update handler, got %v", ctx.Args) + } + return nil + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 20, + Type: tgapi.UpdateTypeEditedMessage, + EditedMessage: &tgapi.Message{ + MessageID: 1, + Text: "/ping", + From: &tgapi.User{ID: 1}, + Chat: &tgapi.Chat{ID: 42}, + }, + }) + + if commandCalled { + t.Fatal("edited_message must not enter command flow") + } + if !updateCalled { + t.Fatal("expected edited_message update handler to be called") + } +} + +func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { + commandCalled := false + updateCalled := false + + plugin := NewPlugin[NoDB]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + commandCalled = true + return nil + }, "ping") + plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoDB) error { + updateCalled = true + if ctx.Msg == nil { + t.Fatal("expected ctx.Msg in edited channel post handler") + } + return nil + }) + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 21, + Type: tgapi.UpdateTypeEditedChannelPost, + EditedChannelPost: &tgapi.Message{ + MessageID: 1, + Text: "/ping", + Chat: &tgapi.Chat{ID: -10042}, + }, + }) + + if commandCalled { + t.Fatal("edited_channel_post must not enter command flow") + } + if !updateCalled { + t.Fatal("expected edited_channel_post update handler to be called") + } +} + +func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { + called := false + plugin := NewPlugin[NoDB]("test") + plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { + called = true + if ctx.CallbackQueryId != "cb-msg" { + t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) + } + if ctx.CallbackMsgId != 55 { + t.Fatalf("unexpected CallbackMsgId: %d", ctx.CallbackMsgId) + } + if ctx.InlineMsgId != "" { + t.Fatalf("did not expect InlineMsgId, got %q", ctx.InlineMsgId) + } + if ctx.Msg == nil { + t.Fatal("expected callback message context") + } + if ctx.From == nil || ctx.FromID != 7 { + t.Fatalf("unexpected callback sender: %#v / %d", ctx.From, ctx.FromID) + } + if ctx.Text != "" { + t.Fatalf("callback flow must not populate Text, got %q", ctx.Text) + } + if got, want := ctx.Args, []string{"7", "ok"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("unexpected callback args: got %v want %v", got, want) + } + return nil + }, "approve") + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}}) + if err != nil { + t.Fatalf("encodeJsonPayload returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 30, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-msg", + Data: data, + From: tgapi.User{ID: 7}, + Message: &tgapi.Message{ + MessageID: 55, + Chat: &tgapi.Chat{ID: 77}, + }, + }, + }) + + if !called { + t.Fatal("expected payload handler to be called") + } +} + +func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { + called := false + plugin := NewPlugin[NoDB]("test") + plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { + called = true + if ctx.CallbackQueryId != "cb-inline" { + t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) + } + if ctx.CallbackMsgId != 0 { + t.Fatalf("did not expect CallbackMsgId, got %d", ctx.CallbackMsgId) + } + if ctx.InlineMsgId != "inline-55" { + t.Fatalf("unexpected InlineMsgId: %q", ctx.InlineMsgId) + } + if ctx.Msg != nil { + t.Fatalf("did not expect callback chat message context, got %#v", ctx.Msg) + } + if ctx.From == nil || ctx.FromID != 8 { + t.Fatalf("unexpected callback sender: %#v / %d", ctx.From, ctx.FromID) + } + if ctx.Text != "" { + t.Fatalf("callback flow must not populate Text, got %q", ctx.Text) + } + if got, want := ctx.Args, []string{"9"}; len(got) != len(want) || got[0] != want[0] { + t.Fatalf("unexpected callback args: got %v want %v", got, want) + } + return nil + }, "inline.approve") + + bot := &Bot[NoDB]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + } + + data, err := encodeJsonPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}}) + if err != nil { + t.Fatalf("encodeJsonPayload returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 31, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-inline", + Data: data, + From: tgapi.User{ID: 8}, + InlineMessageID: ptr("inline-55"), + }, + }) + + if !called { + t.Fatal("expected inline payload handler to be called") + } +} diff --git a/msg_context.go b/msg_context.go index fd1c5fb..31f488f 100644 --- a/msg_context.go +++ b/msg_context.go @@ -13,27 +13,59 @@ import ( "git.scuroneko.dev/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. +// 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. +// - 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 *tgapi.Message + // 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 // 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 string - CallbackMsgId int + // 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 int64 - Prefix string - Text string - Args []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 + // 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 @@ -478,6 +510,13 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { // 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 != "" { @@ -485,7 +524,6 @@ func (ctx *MsgContext) error(err error) { } else { ctx.answer(text, nil, tgapi.ParseNone) } - ctx.Logger.Errorln(err) } // Error is an alias for error(). diff --git a/msg_context_test.go b/msg_context_test.go index d69ddd4..f0465d6 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -166,6 +166,164 @@ func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) { } } +func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { + var requests int + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Logger: slog.CreateLogger(), + errorTemplate: "Error: %s", + } + + ctx.error(errors.New("boom")) + + if requests != 1 { + t.Fatalf("expected one user-facing error reply, got %d requests", requests) + } + if got := gotBody["text"]; got != "Error: boom" { + t.Fatalf("unexpected error reply text: %v", got) + } +} + +func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatal("unexpected HTTP request for internal-only error") + return nil, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Logger: slog.CreateLogger(), + errorTemplate: "Error: %s", + } + + ctx.error(AsInternalError(errors.New("boom"))) +} + +func TestErrorInternalSkipsCallbackAnswer(t *testing.T) { + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatal("unexpected callback answer request for internal-only error") + return nil, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + Logger: slog.CreateLogger(), + errorTemplate: "%s", + CallbackQueryId: "cb-1", + } + + ctx.error(AsInternalError(errors.New("boom"))) +} + +func TestErrorUserVisibleAnswersCallback(t *testing.T) { + var requests int + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + Logger: slog.CreateLogger(), + errorTemplate: "Oops: %s", + CallbackQueryId: "cb-1", + } + + ctx.error(AsUserError(errors.New("boom"))) + + if requests != 1 { + t.Fatalf("expected one callback error answer, got %d requests", requests) + } + if got := gotBody["text"]; got != "Oops: boom" { + t.Fatalf("unexpected callback error text: %v", got) + } +} + func TestAnswerRejectsEmptyMessage(t *testing.T) { ctx := &MsgContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, diff --git a/plugins.go b/plugins.go index 75783aa..3d4067e 100644 --- a/plugins.go +++ b/plugins.go @@ -40,6 +40,11 @@ var ErrCmdArgCountMismatch = errors.New("command arg count mismatch") // ErrCmdArgRegexpMismatch is returned when an argument fails regex validation. var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") +var ( + errCommandNotFound = errors.New("command not found") + errPayloadNotFound = errors.New("payload not found") +) + // CommandArg defines a single argument for a command, including type, regex, // and whether it is required. type CommandArg struct { @@ -314,7 +319,7 @@ func (p *Plugin[T]) Close() error { func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) { command, exists := p.commands[cmd] if !exists { - ctx.error(errors.New("command not found")) + ctx.error(AsInternalError(errCommandNotFound)) return } @@ -340,7 +345,7 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) { func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) { command, exists := p.payloads[payload] if !exists { - ctx.error(errors.New("payload not found")) + ctx.error(AsInternalError(errPayloadNotFound)) return } diff --git a/utils/version.go b/utils/version.go index fb41955..40e5010 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,7 +2,7 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0-rc.12" + VersionString = "1.0.0-rc.13" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. @@ -10,5 +10,5 @@ const ( // VersionPatch is the module patch version. VersionPatch = 0 // VersionBeta is the prerelease counter for the current version. - VersionBeta = 12 + VersionBeta = 13 ) -- 2.54.0 From e2444752c22c5b5edb1a191fc333c2455f44b251 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 30 Mar 2026 16:43:59 +0300 Subject: [PATCH 67/99] rename app data API Align docs, examples, and tests with AppData and NoData Update handler, scene, runner, and plugin generics to the new naming Ignore .codex and record the cleanup in the changelog --- .gitignore | 1 + CHANGELOG.md | 1 + README.md | 32 +++++----- README_RU.md | 32 +++++----- bot.go | 125 ++++++++++++++++++++------------------ bot_test.go | 102 +++++++++++++++---------------- cmd_generator_test.go | 12 ++-- doc.go | 6 +- handler.go | 14 ++--- handler_test.go | 136 +++++++++++++++++++++--------------------- plugins.go | 16 ++--- plugins_test.go | 8 +-- runners.go | 6 +- runners_test.go | 12 ++-- scene_handler.go | 8 +-- scene_test.go | 50 ++++++++-------- 16 files changed, 287 insertions(+), 274 deletions(-) diff --git a/.gitignore b/.gitignore index 88b114b..848b64a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .vscode/ test/ .codex/ +.codex diff --git a/CHANGELOG.md b/CHANGELOG.md index 299e5b4..9287891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Bot configuration mutators now treat the bot as configuration-frozen after the first run begins and ignore late mutation attempts for bot-level config such as prefixes, payload defaults, plugins, middleware, runners, localization, scene session wiring, and database context injection. - `MsgContext` godoc and field comments now describe the normalized update contract more explicitly, including when `Msg`, `From`, callback target fields, `Text`, and `Args` are expected to be populated. - `MsgContext.Error(...)` and returned handler errors now suppress the automatic user reply when the error is explicitly marked with `AsInternalError(...)`, while keeping the previous user-visible default for unclassified errors. +- Godoc, README examples, and regression-test naming now consistently describe the shared generic dependency model as app data, including `NoData` and `SetAppData(...)`. ### Tests - Added regression coverage for the bot configuration freeze model, including ignored post-run mutations for core bot configuration methods and late registration paths. diff --git a/README.md b/README.md index b550fd5..d8cabd4 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s * **Middleware Support:** Run code before or after commands (e.g., logging, access control). * **Automatic Command Generation:** Generate help and command lists automatically. * **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling). -* **Context-Aware:** Pass custom database or state contexts to your handlers. -* **Fluent Interface:** Chain methods for clean configuration (e.g., `bot.ErrorTemplate(...).AddPlugins(...)`). +* **Context-Aware:** Pass custom application data or state contexts to your handlers. +* **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`). --- @@ -53,8 +53,8 @@ import ( // echo is a command handler function. // It receives two parameters: // - ctx: the message context (contains info about the message, sender, chat, etc.) -// - db: your custom database context (here we use NoDB, a placeholder for no database) -func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error { +// - data: your shared application data (here we use NoData, a placeholder for no shared data) +func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error { // Answer the user with the text they sent, without any command prefix. // ctx.Text contains the user's message with the command part stripped off. ctx.Answer(ctx.Text) // User input WITHOUT command @@ -66,8 +66,8 @@ func main() { opts := &laniakea.BotOpts{Token: "TOKEN"} // 2. Initialize a new bot instance. - // We use laniakea.NoDB as the database context type (no database needed for this example). - bot, err := laniakea.NewBot[laniakea.NoDB](opts) + // We use laniakea.NoData as the application data type (no shared data needed for this example). + bot, err := laniakea.NewBot[laniakea.NoData](opts) if err != nil { log.Fatal(err) } @@ -76,7 +76,7 @@ func main() { // 3. Create a new plugin named "ping". // Plugins help group related commands and middlewares. - p := laniakea.NewPlugin[laniakea.NoDB]("ping") + p := laniakea.NewPlugin[laniakea.NoData]("ping") // 4. Add a command to the plugin. // p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command. @@ -84,15 +84,15 @@ func main() { // 5. Add another command using an anonymous function (closure). // This command simply replies "Pong" when the user sends "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error { + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error { ctx.Answer("Pong") return nil }, "ping")) // 6. Configure the bot with a custom error template and add the plugin. - // ErrorTemplate sets a format string for errors (where %s will be replaced by the actual error). + // SetErrorTemplate sets a format string for errors (where %s will be replaced by the actual error). // AddPlugins(p) registers our "ping" plugin with the bot. - bot = bot.ErrorTemplate("Error\n\n%s").AddPlugins(p) + bot = bot.SetErrorTemplate("Error\n\n%s").AddPlugins(p) // 7. Automatically generate commands like /start, /help, and a list of all registered commands. // This is optional but very useful for most bots. @@ -109,11 +109,11 @@ func main() { ### How It Works 1. `BotOpts`: Holds configuration like the API token. -2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it. +2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData if you don't need it. 3. `NewPlugin`: Creates a logical group for commands and middlewares. 4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash). -5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T, and return an error for centralized error handling. -6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. +5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling. +6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails. 9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session. @@ -168,9 +168,9 @@ This split keeps method intent explicit: JSON-only calls go through `API`, file For advanced cases, `tgapi.NewRequest(...)` and `tgapi.NewUploaderRequest(...)` remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves. -### Database Context +### App Data -The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools should usually use a pointer type. +The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools, service containers, or API clients should usually use a pointer type. ```go type MyDB struct { /* ... */ } @@ -179,7 +179,7 @@ bot, err := laniakea.NewBot[*MyDB](opts) if err != nil { log.Fatal(err) } -bot.DatabaseContext(db) +bot.SetAppData(db) ``` ### Scenes and Sessions diff --git a/README_RU.md b/README_RU.md index f038beb..5a76dd5 100644 --- a/README_RU.md +++ b/README_RU.md @@ -22,8 +22,8 @@ * **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа). * **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически. * **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). -* **Контекст данных:** Передавайте свой контекст базы данных или состояния в обработчики. -* **Текучий интерфейс (Fluent Interface):** Стройте цепочки методов для чистой конфигурации (например, `bot.ErrorTemplate(...).AddPlugins(...)`). +* **Контекст данных:** Передавайте общие данные приложения или state в обработчики. +* **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`. --- @@ -54,8 +54,8 @@ import ( // echo — это функция-обработчик команды. // Она получает два параметра: // - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) -// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку) -func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error { +// - data: ваши общие данные приложения (здесь мы используем NoData — заглушку без общих зависимостей) +func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error { // Отвечаем пользователю текстом, который он прислал, без префикса команды. // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды @@ -67,8 +67,8 @@ func main() { opts := &laniakea.BotOpts{Token: "TOKEN"} // 2. Инициализируем новый экземпляр бота. - // Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера). - bot, err := laniakea.NewBot[laniakea.NoDB](opts) + // Используем laniakea.NoData как тип данных приложения (общие зависимости не нужны для примера). + bot, err := laniakea.NewBot[laniakea.NoData](opts) if err != nil { log.Fatal(err) } @@ -77,7 +77,7 @@ func main() { // 3. Создаём новый плагин с именем "ping". // Плагины помогают группировать связанные команды и промежуточные обработчики. - p := laniakea.NewPlugin[laniakea.NoDB]("ping") + p := laniakea.NewPlugin[laniakea.NoData]("ping") // 4. Добавляем команду в плагин. // p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo". @@ -85,15 +85,15 @@ func main() { // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error { + p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error { ctx.Answer("Pong") return nil }, "ping")) // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. - // ErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки). + // SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки). // AddPlugins(p) регистрирует наш плагин "ping" в боте. - bot = bot.ErrorTemplate("Ошибка\n\n%s").AddPlugins(p) + bot = bot.SetErrorTemplate("Ошибка\n\n%s").AddPlugins(p) // 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд. // Это необязательно, но очень полезно для большинства ботов. @@ -110,11 +110,11 @@ func main() { ### Как это работает 1. `BotOpts`: Содержит конфигурацию, например, токен API. -2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен. +2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. 4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша). -5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки. -6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. +5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки. +6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. 9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот. @@ -157,8 +157,8 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) error { - Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие. - И много других методов и полей! -### Контекст базы данных (Database Context) -Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type. +### App Data +Параметр типа `T` в `NewBot[T]` — мощная возможность. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД, контейнера сервисов или API-клиента обычно стоит использовать pointer type. ```go type MyDB struct { /* ... */ } @@ -167,7 +167,7 @@ bot, err := laniakea.NewBot[*MyDB](opts) if err != nil { log.Fatal(err) } -bot.DatabaseContext(db) +bot.SetAppData(db) ``` ### Сцены и сессии (Scenes and Sessions) diff --git a/bot.go b/bot.go index 70d47b3..02e73e9 100644 --- a/bot.go +++ b/bot.go @@ -19,8 +19,11 @@ import ( "github.com/alitto/pond/v2" ) -// DbContext is the generic dependency type injected into bots, plugins, and handlers. -// Use it for shared application state such as database handles or service containers. +// AppData is the generic shared application data type injected into bots, +// plugins, and handlers. +// +// Use it for long-lived shared dependencies such as database handles, service +// containers, API clients, or immutable configuration snapshots. // // Example: // @@ -30,18 +33,22 @@ import ( // if err != nil { // return err // } -// bot.DatabaseContext(myDB) +// bot.SetAppData(myDB) // -// Use NoDB if no database is needed. -type DbContext any +// Use NoData if no shared application data is needed. +type AppData any -// NoDB is a placeholder type for bots that do not use a database. -// Use Bot[NoDB] to indicate no dependency injection is required. -type NoDB struct{ DbContext } +// NoData is a placeholder type for bots that do not use shared application +// data. +// +// Use Bot[NoData] to indicate no shared dependency injection is required. +type NoData struct{ AppData } -// DbLogger is a function type that returns a slog.LoggerWriter for database logging. -// Used to inject database-specific log output (e.g., SQL queries, ORM events). -type DbLogger[T DbContext] func(db T) slog.LoggerWriter +// AppDataLogger builds a slog.LoggerWriter from injected application data. +// +// Use it when shared application data exposes a log sink or adapter that should +// receive framework logs. +type AppDataLogger[T AppData] func(data T) slog.LoggerWriter // BotPayloadType defines the serialization format for callback data payloads. type BotPayloadType string @@ -78,7 +85,7 @@ var ( // // Runtime accessors are safe for concurrent use. Configure the bot before Run. // A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session. -type Bot[T DbContext] struct { +type Bot[T AppData] struct { token string debug bool errorTemplate string @@ -98,11 +105,12 @@ type Bot[T DbContext] struct { api *tgapi.API // Telegram API client uploader *tgapi.Uploader // File uploader - dbContext T // Injected database context - hasDBContext bool - warnedValueDB bool - l10n *L10n // Localization manager - draftProvider *DraftProvider // Draft message builder + l10n *L10n // Localization manager + draftProvider *DraftProvider // Draft message builder + + appData T // Injected application data + hasAppData bool + warnedValueData bool sessionStore SessionStore // Session store for scene management sceneScopePriority []SceneScope @@ -328,9 +336,9 @@ func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { // GetLogger returns the main bot logger. func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } -// GetDBContext returns the injected database context. -// If DatabaseContext was not called, it returns the zero value of T. -func (bot *Bot[T]) GetDBContext() T { return bot.dbContext } +// GetAppData returns the injected application data. +// If SetAppData was not called, it returns the zero value of T. +func (bot *Bot[T]) GetAppData() T { return bot.appData } // GetLoggerLevel returns the effective log level derived from the bot's debug // flag. @@ -406,27 +414,30 @@ func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { return bot } -// DatabaseContext injects a database context into the bot. -// This context is accessible to plugins and middleware via GetDBContext(). +// SetAppData injects shared application data into the bot. +// +// The data is accessible to commands, payload handlers, middleware, scenes, +// and runners through the generic type parameter T. +// // For shared dependencies such as *sql.DB, prefer using a pointer type as T. -// Value-typed contexts are supported, but the bot warns once because handlers -// receive T by value. -func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] { - if !bot.configMutable("DatabaseContext") { +// Value-typed application data is supported, but the bot warns once because +// handlers receive T by value. +func (bot *Bot[T]) SetAppData(ctx T) *Bot[T] { + if !bot.configMutable("SetAppData") { return bot } - if !bot.warnedValueDB && shouldWarnOnValueDBContext[T]() && bot.logger != nil { - bot.logger.Warnln("database context uses a value type; shared dependencies should usually use a pointer type as T") - bot.warnedValueDB = true + if !bot.warnedValueData && shouldWarnOnValueAppData[T]() && bot.logger != nil { + bot.logger.Warnln("app data uses a value type; shared dependencies should usually use a pointer type as T") + bot.warnedValueData = true } - bot.dbContext = ctx - bot.hasDBContext = true + bot.appData = ctx + bot.hasAppData = true return bot } -// UpdateTypes sets the list of update types the bot will request from Telegram. +// SetUpdateTypes sets the list of update types the bot will request from Telegram. // Overwrites any previously set types. -func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { +func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] { if !bot.configMutable("UpdateTypes") { return bot } @@ -480,10 +491,10 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { return bot } -// ErrorTemplate sets the format string for error messages sent to users. +// SetErrorTemplate sets the format string for error messages sent to users. // Use "%s" to insert the error message. // Example: "❌ Error: %s" → "❌ Error: Command not found". -func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { +func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] { if !bot.configMutable("ErrorTemplate") { return bot } @@ -491,8 +502,8 @@ func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { return bot } -// Debug enables or disables debug logging. -func (bot *Bot[T]) Debug(debug bool) *Bot[T] { +// SetDebug enables or disables debug logging. +func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { bot.debug = debug level := slog.FATAL if debug { @@ -612,7 +623,7 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { return bot } -// AddL10n sets the localization (i18n) provider for the bot. +// SetL10n sets the localization (i18n) provider for the bot. // // The L10n instance must be pre-populated with translations. // Translations are accessed via Bot.L10n(lang, key). @@ -622,22 +633,22 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { // l10n := l10n.New() // l10n.Add("en", "hello", "Hello!") // l10n.Add("es", "hello", "¡Hola!") -// bot.AddL10n(l10n) +// bot.SetL10n(l10n) // // Replaces any previously set L10n instance. -func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { - if !bot.configMutable("AddL10n") { +func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] { + if !bot.configMutable("SetL10n") { return bot } if l == nil { - bot.logger.Warn("AddL10n called with nil L10n; localization will be disabled") + bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled") return bot } bot.l10n = l return bot } -// AddDatabaseLoggerWriter adds a database logger writer to all loggers. +// AddAppDataLoggerWriter adds an app-data-backed logger writer to all loggers. // // The writer will receive logs from: // - Main bot logger @@ -647,23 +658,23 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { // // Call this after AddPlugins if plugin loggers should also receive the writer. // Plugins registered later do not automatically inherit previously added -// database writers; call AddDatabaseLoggerWriter again after adding them. +// writers; call AddAppDataLoggerWriter again after adding them. // // Example: // -// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter { -// return db.QueryLogger() +// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter { +// return data.QueryLogger() // }) -func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { - if !bot.hasDBContext { - bot.logger.Warnln("database context is not set; skipping database logger writer") +func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { + if !bot.hasAppData { + bot.logger.Warnln("app data is not set; skipping app-data logger writer") return bot } - if isNilValue(bot.dbContext) { - bot.logger.Warnln("database context is nil; skipping database logger writer") + if isNilValue(bot.appData) { + bot.logger.Warnln("app data is nil; skipping app-data logger writer") return bot } - w := writer(bot.dbContext) + w := writer(bot.appData) bot.logger.AddWriter(w) if bot.RequestLogger != nil { bot.RequestLogger.AddWriter(w) @@ -832,9 +843,9 @@ func isNilValue[T any](v T) bool { } } -func shouldWarnOnValueDBContext[T any]() bool { +func shouldWarnOnValueAppData[T any]() bool { t := reflect.TypeFor[T]() - if t == reflect.TypeFor[NoDB]() { + if t == reflect.TypeFor[NoData]() { return false } switch t.Kind() { @@ -845,7 +856,7 @@ func shouldWarnOnValueDBContext[T any]() bool { } } -func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { +func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] { cloned := Plugin[T]{ name: p.name, commands: make(map[string]*Command[T], len(p.commands)), @@ -872,7 +883,7 @@ func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] { return cloned } -func cloneCommand[T DbContext](command *Command[T]) *Command[T] { +func cloneCommand[T AppData](command *Command[T]) *Command[T] { if command == nil { return nil } @@ -883,7 +894,7 @@ func cloneCommand[T DbContext](command *Command[T]) *Command[T] { return &cloned } -func cloneScene[T DbContext](scene *Scene[T]) *Scene[T] { +func cloneScene[T AppData](scene *Scene[T]) *Scene[T] { if scene == nil { return nil } diff --git a/bot_test.go b/bot_test.go index 712e0ab..69f5832 100644 --- a/bot_test.go +++ b/bot_test.go @@ -13,7 +13,7 @@ import ( ) func TestGetUpdateTypesReturnsCopy(t *testing.T) { - bot := &Bot[NoDB]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}} + bot := &Bot[NoData]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}} got := bot.GetUpdateTypes() got[0] = tgapi.UpdateTypeCallbackQuery @@ -24,17 +24,17 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) { } func TestAddPluginsSnapshotsConfiguration(t *testing.T) { - bot := &Bot[NoDB]{logger: slog.CreateLogger()} - plugin := NewPlugin[NoDB]("demo") + bot := &Bot[NoData]{logger: slog.CreateLogger()} + plugin := NewPlugin[NoData]("demo") - cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "start") - plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })) + cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start") + plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })) bot.AddPlugins(plugin) cmd.SetDescription("mutated after registration") - plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "late") - plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true })) + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "late") + plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true })) registered := bot.plugins[0] if _, exists := registered.commands["late"]; exists { @@ -49,7 +49,7 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { } func TestBotPayloadTypeConfiguration(t *testing.T) { - bot := &Bot[NoDB]{payloadType: BotPayloadBase64} + bot := &Bot[NoData]{payloadType: BotPayloadBase64} if got := bot.GetPayloadType(); got != BotPayloadBase64 { t.Fatalf("unexpected initial payload type: %q", got) @@ -65,8 +65,8 @@ func TestBotPayloadTypeConfiguration(t *testing.T) { } func TestAddPluginsSkipsNilPlugin(t *testing.T) { - bot := &Bot[NoDB]{logger: slog.CreateLogger()} - plugin := NewPlugin[NoDB]("demo") + bot := &Bot[NoData]{logger: slog.CreateLogger()} + plugin := NewPlugin[NoData]("demo") bot.AddPlugins(nil, plugin) @@ -79,7 +79,7 @@ func TestAddPluginsSkipsNilPlugin(t *testing.T) { } func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { - bot := &Bot[NoDB]{} + bot := &Bot[NoData]{} bot.initLoggers(&BotOpts{ Debug: true, @@ -122,39 +122,39 @@ func TestNextPollRetryDelay(t *testing.T) { } } -func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsUnset(t *testing.T) { - bot := &Bot[NoDB]{logger: slog.CreateLogger()} +func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) { + bot := &Bot[NoData]{logger: slog.CreateLogger()} called := false - bot.AddDatabaseLoggerWriter(func(db NoDB) slog.LoggerWriter { + bot.AddAppDataLoggerWriter(func(db NoData) slog.LoggerWriter { called = true return nil }) if called { - t.Fatal("expected database logger writer to be skipped when db context is unset") + t.Fatal("expected app-data logger writer to be skipped when app data is unset") } } -func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsNil(t *testing.T) { +func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) { type testDB struct{} bot := &Bot[*testDB]{logger: slog.CreateLogger()} var db *testDB - bot.DatabaseContext(db) + bot.SetAppData(db) called := false - bot.AddDatabaseLoggerWriter(func(db *testDB) slog.LoggerWriter { + bot.AddAppDataLoggerWriter(func(db *testDB) slog.LoggerWriter { called = true return nil }) if called { - t.Fatal("expected database logger writer to be skipped when db context is nil") + t.Fatal("expected app-data logger writer to be skipped when app data is nil") } } -func TestShouldWarnOnValueDBContext(t *testing.T) { +func TestShouldWarnOnValueAppData(t *testing.T) { type testDB struct{} type dbIface interface{ Ping() error } @@ -163,36 +163,36 @@ func TestShouldWarnOnValueDBContext(t *testing.T) { got bool want bool }{ - {name: "NoDB", got: shouldWarnOnValueDBContext[NoDB](), want: false}, - {name: "pointer", got: shouldWarnOnValueDBContext[*testDB](), want: false}, - {name: "interface", got: shouldWarnOnValueDBContext[dbIface](), want: false}, - {name: "map", got: shouldWarnOnValueDBContext[map[string]int](), want: false}, - {name: "struct", got: shouldWarnOnValueDBContext[testDB](), want: true}, - {name: "int", got: shouldWarnOnValueDBContext[int](), want: true}, + {name: "NoData", got: shouldWarnOnValueAppData[NoData](), want: false}, + {name: "pointer", got: shouldWarnOnValueAppData[*testDB](), want: false}, + {name: "interface", got: shouldWarnOnValueAppData[dbIface](), want: false}, + {name: "map", got: shouldWarnOnValueAppData[map[string]int](), want: false}, + {name: "struct", got: shouldWarnOnValueAppData[testDB](), want: true}, + {name: "int", got: shouldWarnOnValueAppData[int](), want: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.got != tt.want { - t.Fatalf("shouldWarnOnValueDBContext = %v, want %v", tt.got, tt.want) + t.Fatalf("shouldWarnOnValueAppData = %v, want %v", tt.got, tt.want) } }) } } -func TestDatabaseContextMarksValueWarningOnce(t *testing.T) { +func TestSetAppDataMarksValueWarningOnce(t *testing.T) { type testDB struct{} bot := &Bot[testDB]{logger: slog.CreateLogger()} - bot.DatabaseContext(testDB{}) - if !bot.warnedValueDB { - t.Fatal("expected value-typed database context to mark warning state") + bot.SetAppData(testDB{}) + if !bot.warnedValueData { + t.Fatal("expected value-typed app data to mark warning state") } ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()} - ptrBot.DatabaseContext(&testDB{}) - if ptrBot.warnedValueDB { - t.Fatal("did not expect pointer-typed database context to mark warning state") + ptrBot.SetAppData(&testDB{}) + if ptrBot.warnedValueData { + t.Fatal("did not expect pointer-typed app data to mark warning state") } } @@ -200,10 +200,10 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, - plugins: []Plugin[NoDB]{{name: "demo"}}, + plugins: []Plugin[NoData]{{name: "demo"}}, updateQueue: make(chan *tgapi.Update, 1), maxWorkers: 1, } @@ -235,23 +235,23 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { } tests := []struct { - name string + name string check func(t *testing.T, bot *Bot[*testDB]) }{ { - name: "DatabaseContext", + name: "SetAppData", check: func(t *testing.T, bot *Bot[*testDB]) { original := &testDB{Name: "before"} - bot.DatabaseContext(original) + bot.SetAppData(original) if err := bot.beginRun(); err != nil { t.Fatalf("beginRun returned error: %v", err) } t.Cleanup(bot.finishRun) later := &testDB{Name: "after"} - bot.DatabaseContext(later) - if bot.dbContext != original { - t.Fatal("DatabaseContext mutated after configuration freeze") + bot.SetAppData(later) + if bot.appData != original { + t.Fatal("SetAppData mutated after configuration freeze") } }, }, @@ -264,7 +264,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { } t.Cleanup(bot.finishRun) - bot.UpdateTypes(tgapi.UpdateTypePoll) + bot.SetUpdateTypes(tgapi.UpdateTypePoll) if !reflect.DeepEqual(bot.updateTypes, original) { t.Fatalf("UpdateTypes mutated after configuration freeze: got %v want %v", bot.updateTypes, original) } @@ -336,7 +336,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { } t.Cleanup(bot.finishRun) - bot.ErrorTemplate("changed") + bot.SetErrorTemplate("changed") if bot.errorTemplate != "%s" { t.Fatalf("errorTemplate mutated after configuration freeze: got %q want %q", bot.errorTemplate, "%s") } @@ -396,7 +396,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { } t.Cleanup(bot.finishRun) - bot.AddL10n(&L10n{}) + bot.SetL10n(&L10n{}) if bot.l10n != original { t.Fatal("l10n mutated after configuration freeze") } @@ -412,13 +412,13 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { } func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, - middlewares: []Middleware[NoDB]{NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true })}, - runners: []Runner[NoDB]{NewRunner("base", func(bot *Bot[NoDB]) error { return nil })}, + middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })}, + runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })}, } - plugin := NewPlugin[NoDB]("late") + plugin := NewPlugin[NoData]("late") if err := bot.beginRun(); err != nil { t.Fatalf("beginRun returned error: %v", err) @@ -426,8 +426,8 @@ func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { defer bot.finishRun() bot.AddPlugins(plugin) - bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true })) - bot.AddRunner(NewRunner("late", func(bot *Bot[NoDB]) error { return nil })) + bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true })) + bot.AddRunner(NewRunner("late", func(bot *Bot[NoData]) error { return nil })) if len(bot.plugins) != 0 { t.Fatalf("expected AddPlugins to be ignored after configuration freeze, got %d plugins", len(bot.plugins)) diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 269ef0a..064b2b0 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -43,16 +43,16 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { } }() - plugin := NewPlugin[NoDB]("overflow") - exec := func(ctx *MsgContext, db NoDB) error { return nil } + plugin := NewPlugin[NoData]("overflow") + exec := func(ctx *MsgContext, db NoData) error { return nil } for i := 0; i < 101; i++ { plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) } - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ api: api, logger: slog.CreateLogger(), - plugins: []Plugin[NoDB]{*plugin}, + plugins: []Plugin[NoData]{*plugin}, } err := bot.AutoGenerateCommands() @@ -65,8 +65,8 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { } func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { - plugin := NewPlugin[NoDB]("sorted") - exec := func(ctx *MsgContext, db NoDB) error { return nil } + plugin := NewPlugin[NoData]("sorted") + exec := func(ctx *MsgContext, db NoData) error { return nil } plugin.AddCommand(NewCommand(exec, "zeta")) plugin.AddCommand(NewCommand(exec, "alpha")) diff --git a/doc.go b/doc.go index 9a6dcee..6cb8433 100644 --- a/doc.go +++ b/doc.go @@ -13,17 +13,17 @@ Core concepts: Example usage: - bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv()) + bot, err := laniakea.NewBot[*mydb.AppData](laniakea.LoadOptsFromEnv()) if err != nil { return err } - bot.DatabaseContext(myDB). + bot.SetAppData(myDB). AddUpdateType(tgapi.UpdateTypeMessage). AddPrefixes("/", "!"). AddPlugins(&startPlugin, &helpPlugin). AddMiddleware(authMiddleware, logMiddleware). AddRunner(cleanupRunner). - AddL10n(l10n.New()) + SetL10n(l10n.New()) return bot.Run() diff --git a/handler.go b/handler.go index e93c387..afdb882 100644 --- a/handler.go +++ b/handler.go @@ -37,7 +37,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { bot.prepareUpdateCtx(u, msgCtx) for _, middleware := range bot.middlewares { - if !middleware.Execute(msgCtx, bot.dbContext) { + if !middleware.Execute(msgCtx, bot.appData) { return } } @@ -102,10 +102,10 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { if plugin.logger != nil { ctx.Logger = plugin.logger } - if !plugin.executeMiddlewares(ctx, bot.dbContext) { + if !plugin.executeMiddlewares(ctx, bot.appData) { return } - plugin.executeCmd(cmd, ctx, bot.dbContext) + plugin.executeCmd(cmd, ctx, bot.appData) return } } @@ -130,10 +130,10 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { if ctx.Logger == nil { ctx.Logger = bot.logger } - if !plugin.executeMiddlewares(ctx, bot.dbContext) { + if !plugin.executeMiddlewares(ctx, bot.appData) { return } - plugin.executePayload(data.Command, ctx, bot.dbContext) + plugin.executePayload(data.Command, ctx, bot.appData) return } } @@ -149,10 +149,10 @@ func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { if plugin.logger != nil { pluginCtx.Logger = plugin.logger } - if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) { + if !plugin.executeMiddlewares(pluginCtx, bot.appData) { continue } - if err := handler(pluginCtx, bot.dbContext); err != nil { + if err := handler(pluginCtx, bot.appData); err != nil { pluginCtx.error(err) } } diff --git a/handler_test.go b/handler_test.go index 9bf901a..74aa6f8 100644 --- a/handler_test.go +++ b/handler_test.go @@ -13,7 +13,7 @@ func ptr[T any](v T) *T { } func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { - bot := &Bot[NoDB]{prefixes: []string{"", "/"}} + bot := &Bot[NoData]{prefixes: []string{"", "/"}} if prefix, ok := bot.checkPrefixes("hello"); ok { t.Fatalf("unexpected prefix match for plain text: %q", prefix) @@ -27,10 +27,10 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) { logger := slog.CreateLogger() called := false - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: logger, - middlewares: []Middleware[NoDB]{ - NewMiddleware("logger-check", func(ctx *MsgContext, db NoDB) bool { + middlewares: []Middleware[NoData]{ + NewMiddleware("logger-check", func(ctx *MsgContext, db NoData) bool { called = true if ctx.Logger != logger { t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger) @@ -55,8 +55,8 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) { } func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { - plugin := NewPlugin[NoDB]("test") - handler := func(ctx *MsgContext, db NoDB) error { return nil } + plugin := NewPlugin[NoData]("test") + handler := func(ctx *MsgContext, db NoData) error { return nil } for _, updateType := range []tgapi.UpdateType{ tgapi.UpdateTypeMessage, @@ -80,14 +80,14 @@ func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { func TestPrepareUpdateCtxContract(t *testing.T) { tests := []struct { - name string - update *tgapi.Update - wantMsg bool - wantFrom bool - wantFromID int64 - wantCallbackID string - wantCallbackMsgID int - wantInlineMsgID string + name string + update *tgapi.Update + wantMsg bool + wantFrom bool + wantFromID int64 + wantCallbackID string + wantCallbackMsgID int + wantInlineMsgID string }{ { name: "message", @@ -145,7 +145,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "inline query", update: &tgapi.Update{ - Type: tgapi.UpdateTypeInlineQuery, + Type: tgapi.UpdateTypeInlineQuery, InlineQuery: &tgapi.InlineQuery{ID: "iq", From: tgapi.User{ID: 104}}, }, wantFrom: true, @@ -154,7 +154,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "chosen inline result", update: &tgapi.Update{ - Type: tgapi.UpdateTypeChosenInlineResult, + Type: tgapi.UpdateTypeChosenInlineResult, ChosenInlineResult: &tgapi.ChosenInlineResult{ResultID: "res", From: tgapi.User{ID: 105}}, }, wantFrom: true, @@ -189,15 +189,15 @@ func TestPrepareUpdateCtxContract(t *testing.T) { InlineMessageID: ptr("inline-42"), }, }, - wantFrom: true, - wantFromID: 107, - wantCallbackID: "cb-2", - wantInlineMsgID:"inline-42", + wantFrom: true, + wantFromID: 107, + wantCallbackID: "cb-2", + wantInlineMsgID: "inline-42", }, { name: "shipping query", update: &tgapi.Update{ - Type: tgapi.UpdateTypeShippingQuery, + Type: tgapi.UpdateTypeShippingQuery, ShippingQuery: &tgapi.ShippingQuery{ID: "ship", From: tgapi.User{ID: 108}}, }, wantFrom: true, @@ -206,7 +206,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "pre checkout query", update: &tgapi.Update{ - Type: tgapi.UpdateTypePreCheckoutQuery, + Type: tgapi.UpdateTypePreCheckoutQuery, PreCheckoutQuery: &tgapi.PreCheckoutQuery{ID: "pre", From: tgapi.User{ID: 109}}, }, wantFrom: true, @@ -215,7 +215,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "purchased paid media", update: &tgapi.Update{ - Type: tgapi.UpdateTypePurchasedPaidMedia, + Type: tgapi.UpdateTypePurchasedPaidMedia, PurchasedPaidMedia: &tgapi.PaidMediaPurchased{From: tgapi.User{ID: 110}}, }, wantFrom: true, @@ -224,7 +224,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "my chat member", update: &tgapi.Update{ - Type: tgapi.UpdateTypeMyChatMember, + Type: tgapi.UpdateTypeMyChatMember, MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}}, }, wantFrom: true, @@ -233,7 +233,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "chat member", update: &tgapi.Update{ - Type: tgapi.UpdateTypeChatMember, + Type: tgapi.UpdateTypeChatMember, ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}}, }, wantFrom: true, @@ -242,7 +242,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "chat join request", update: &tgapi.Update{ - Type: tgapi.UpdateTypeChatJoinRequest, + Type: tgapi.UpdateTypeChatJoinRequest, ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}}, }, wantFrom: true, @@ -251,7 +251,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "business connection", update: &tgapi.Update{ - Type: tgapi.UpdateTypeBusinessConnection, + Type: tgapi.UpdateTypeBusinessConnection, BusinessConnection: &tgapi.BusinessConnection{User: tgapi.User{ID: 114}}, }, wantFrom: true, @@ -260,7 +260,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "poll answer", update: &tgapi.Update{ - Type: tgapi.UpdateTypePollAnswer, + Type: tgapi.UpdateTypePollAnswer, PollAnswer: &tgapi.PollAnswer{User: tgapi.User{ID: 115}}, }, wantFrom: true, @@ -269,7 +269,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "message reaction", update: &tgapi.Update{ - Type: tgapi.UpdateTypeMessageReaction, + Type: tgapi.UpdateTypeMessageReaction, MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}}, }, wantFrom: true, @@ -307,7 +307,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { { name: "message reaction count", update: &tgapi.Update{ - Type: tgapi.UpdateTypeMessageReactionCount, + Type: tgapi.UpdateTypeMessageReactionCount, MessageReactionCount: &tgapi.MessageReactionCountUpdated{}, }, }, @@ -315,7 +315,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - bot := &Bot[NoDB]{} + bot := &Bot[NoData]{} ctx := &MsgContext{} bot.prepareUpdateCtx(tt.update, ctx) @@ -384,7 +384,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { called := false - plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) error { + plugin := NewPlugin[NoData]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoData) error { called = true if ctx.Update.UpdateID != tt.update.UpdateID { t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID) @@ -404,9 +404,9 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { return nil }) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), - plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, } bot.handle(context.Background(), tt.update) @@ -422,7 +422,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { firstCalled := false secondCalled := false - first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error { + first := NewPlugin[NoData]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error { firstCalled = true if ctx.FromID != 41 { t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID) @@ -433,7 +433,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { ctx.Args = []string{"mutated"} return nil }) - second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error { + second := NewPlugin[NoData]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error { secondCalled = true if ctx.From == nil { t.Fatal("expected ctx.From to remain populated for second handler") @@ -450,9 +450,9 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { return nil }) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), - plugins: []Plugin[NoDB]{ + plugins: []Plugin[NoData]{ clonePlugin(first), clonePlugin(second), }, @@ -475,8 +475,8 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { called := false - plugin := NewPlugin[NoDB]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + plugin := NewPlugin[NoData]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { called = true if ctx.Msg == nil { t.Fatal("expected message context") @@ -493,10 +493,10 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { return nil }, "ping") - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, - plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, } bot.handle(context.Background(), &tgapi.Update{ @@ -522,18 +522,18 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { } var got banInput - plugin := NewPlugin[NoDB]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + plugin := NewPlugin[NoData]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return ctx.BindArgs(&got) }, "ban", NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(), NewCommandArg("reason").SetRequired(), ) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, - plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, } bot.handle(context.Background(), &tgapi.Update{ @@ -559,18 +559,18 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { } var got payloadInput - plugin := NewPlugin[NoDB]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { + plugin := NewPlugin[NoData]("test") + plugin.NewPayload(func(ctx *MsgContext, db NoData) error { return ctx.BindArgs(&got) }, "approve", NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(), NewCommandArg("note").SetRequired(), ) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), payloadType: BotPayloadJson, - plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, } data, err := encodeJsonPayload(CallbackData{ @@ -601,12 +601,12 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { commandCalled := false updateCalled := false - plugin := NewPlugin[NoDB]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + plugin := NewPlugin[NoData]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { commandCalled = true return nil }, "ping") - plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoDB) error { + plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error { updateCalled = true if ctx.Msg == nil { t.Fatal("expected ctx.Msg in edited message handler") @@ -620,10 +620,10 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { return nil }) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, - plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, } bot.handle(context.Background(), &tgapi.Update{ @@ -649,12 +649,12 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { commandCalled := false updateCalled := false - plugin := NewPlugin[NoDB]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + plugin := NewPlugin[NoData]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { commandCalled = true return nil }, "ping") - plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoDB) error { + plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error { updateCalled = true if ctx.Msg == nil { t.Fatal("expected ctx.Msg in edited channel post handler") @@ -662,10 +662,10 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { return nil }) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, - plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, } bot.handle(context.Background(), &tgapi.Update{ @@ -688,8 +688,8 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { called := false - plugin := NewPlugin[NoDB]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { + plugin := NewPlugin[NoData]("test") + plugin.NewPayload(func(ctx *MsgContext, db NoData) error { called = true if ctx.CallbackQueryId != "cb-msg" { t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) @@ -715,10 +715,10 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { return nil }, "approve") - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), payloadType: BotPayloadJson, - plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, } data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}}) @@ -747,8 +747,8 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { called := false - plugin := NewPlugin[NoDB]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoDB) error { + plugin := NewPlugin[NoData]("test") + plugin.NewPayload(func(ctx *MsgContext, db NoData) error { called = true if ctx.CallbackQueryId != "cb-inline" { t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) @@ -774,10 +774,10 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { return nil }, "inline.approve") - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), payloadType: BotPayloadJson, - plugins: []Plugin[NoDB]{clonePlugin(plugin)}, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, } data, err := encodeJsonPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}}) diff --git a/plugins.go b/plugins.go index 3d4067e..53f8b76 100644 --- a/plugins.go +++ b/plugins.go @@ -85,13 +85,13 @@ func (c CommandArg) SetRequired() CommandArg { } // CommandExecutor is the function type that executes a command. -// It receives the message context and a database context (generic). +// It receives the message context and injected application data. // Returning a non-nil error routes it through the bot's error handler. -type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) error +type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error // Command represents a bot command with arguments, description, and executor. // Can be registered in a Plugin and optionally skipped from auto-generation. -type Command[T DbContext] struct { +type Command[T AppData] struct { command string // The command trigger (e.g., "/start") description string // Human-readable description for help exec CommandExecutor[T] // Function to execute when command is triggered @@ -162,7 +162,7 @@ func (c *Command[T]) validateArgs(args []string) error { // A Plugin is intended to be fully configured before it is passed to Bot.AddPlugins. // After registration, treat the plugin as committed and do not mutate it further. // Post-registration changes through the original *Plugin are not a supported API. -type Plugin[T DbContext] struct { +type Plugin[T AppData] struct { name string // Name of the plugin (e.g., "admin", "user") commands map[string]*Command[T] // Registered commands (triggered by message) payloads map[string]*Command[T] // Registered payloads (triggered by callback data) @@ -177,7 +177,7 @@ type Plugin[T DbContext] struct { } // NewPlugin creates a new Plugin with the given name. -func NewPlugin[T DbContext](name string) *Plugin[T] { +func NewPlugin[T AppData](name string) *Plugin[T] { return &Plugin[T]{ name: name, commands: make(map[string]*Command[T]), @@ -380,11 +380,11 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool { // MiddlewareExecutor is the function type for middleware logic. // Returns true to continue execution, false to block it. // If async, return value is ignored. -type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db T) bool +type MiddlewareExecutor[T AppData] func(ctx *MsgContext, db T) bool // Middleware represents a reusable execution interceptor. // Can be synchronous (blocking) or asynchronous (non-blocking). -type Middleware[T DbContext] struct { +type Middleware[T AppData] struct { name string // Human-readable name for logging/debugging executor MiddlewareExecutor[T] // Function to execute order int // Optional sort order (not used yet) @@ -392,7 +392,7 @@ type Middleware[T DbContext] struct { } // NewMiddleware creates a new synchronous middleware. -func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) Middleware[T] { +func NewMiddleware[T AppData](name string, executor MiddlewareExecutor[T]) Middleware[T] { return Middleware[T]{name, executor, 0, false} } diff --git a/plugins_test.go b/plugins_test.go index d2d44c5..b293aec 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -6,7 +6,7 @@ import ( ) func TestValidateArgsRequiresFullMatch(t *testing.T) { - intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) + intCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) if err := intCmd.validateArgs([]string{"123"}); err != nil { t.Fatalf("expected valid integer argument, got %v", err) } @@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) } - boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) + boolCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) if err := boolCmd.validateArgs([]string{"false"}); err != nil { t.Fatalf("expected valid bool argument, got %v", err) } @@ -24,8 +24,8 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { } func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { - cmd := NewCommand[NoDB]( - func(ctx *MsgContext, db NoDB) error { return nil }, + cmd := NewCommand[NoData]( + func(ctx *MsgContext, db NoData) error { return nil }, "mixed", NewCommandArg("optional"), NewCommandArg("required").SetRequired(), diff --git a/runners.go b/runners.go index dd55246..11c1d1d 100644 --- a/runners.go +++ b/runners.go @@ -7,7 +7,7 @@ import ( // RunnerFn is the function type for a runner. It receives a pointer to // the Bot and returns an error if execution fails. -type RunnerFn[T DbContext] func(*Bot[T]) error +type RunnerFn[T AppData] func(*Bot[T]) error // Runner represents a configurable background or one-time task to be // executed by a Bot. @@ -20,7 +20,7 @@ type RunnerFn[T DbContext] func(*Bot[T]) error // - onetime=true, async=true: Run once in a goroutine (non-blocking). // - onetime=false, async=true: Run repeatedly in a goroutine with timeout. // - onetime=false, async=false: Invalid configuration — ignored with warning. -type Runner[T DbContext] struct { +type Runner[T AppData] struct { name string // Human-readable name for logging onetime bool // If true, runs once; if false, runs periodically async bool // If true, runs in a goroutine; else, runs synchronously @@ -33,7 +33,7 @@ type Runner[T DbContext] struct { // // Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. // DO NOT call builder methods concurrently or after Execute(). -func NewRunner[T DbContext](name string, fn RunnerFn[T]) Runner[T] { +func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { return Runner[T]{ name: name, fn: fn, diff --git a/runners_test.go b/runners_test.go index ec04a28..5223203 100644 --- a/runners_test.go +++ b/runners_test.go @@ -11,10 +11,10 @@ import ( func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { var calls atomic.Int32 - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), - runners: []Runner[NoDB]{ - NewRunner("sync-once", func(*Bot[NoDB]) error { + runners: []Runner[NoData]{ + NewRunner("sync-once", func(*Bot[NoData]) error { calls.Add(1) return nil }).Onetime(true).Async(false), @@ -33,10 +33,10 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) { triggered := make(chan struct{}, 1) ctx, cancel := context.WithCancel(context.Background()) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), - runners: []Runner[NoDB]{ - NewRunner("background", func(*Bot[NoDB]) error { + runners: []Runner[NoData]{ + NewRunner("background", func(*Bot[NoData]) error { if calls.Add(1) == 1 { triggered <- struct{}{} } diff --git a/scene_handler.go b/scene_handler.go index ef790b7..9ddcc81 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -26,7 +26,7 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { if scene.PluginName != "" && scene.PluginName != plugin.name { continue } - if !plugin.executeMiddlewares(ctx, bot.dbContext) { + if !plugin.executeMiddlewares(ctx, bot.appData) { return false, nil } sceneCtx := &SceneContext{ @@ -59,7 +59,7 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error ctx.Text = args ctx.Args = strings.Fields(args) - res, matched, err := scene.executeCommand(cmd, ctx, bot.dbContext) + res, matched, err := scene.executeCommand(cmd, ctx, bot.appData) if err != nil { return false, err } @@ -71,7 +71,7 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error ctx.Args = nil ctx.Prefix = "" if ctx.sess.Step != "" { - res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.dbContext) + res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.appData) if err != nil { return false, err } @@ -80,7 +80,7 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error } } - res, matched, err := scene.executeMessage(ctx, bot.dbContext) + res, matched, err := scene.executeMessage(ctx, bot.appData) if err != nil { return false, err } diff --git a/scene_test.go b/scene_test.go index 394becb..71ae470 100644 --- a/scene_test.go +++ b/scene_test.go @@ -28,8 +28,8 @@ func (s failingSessionStore) Delete(key string) error { } func TestPluginAddSceneRegistersScene(t *testing.T) { - plugin := NewPlugin[NoDB]("wizard") - scene := NewScene[NoDB]("signup") + plugin := NewPlugin[NoData]("wizard") + scene := NewScene[NoData]("signup") plugin.AddScene(scene) @@ -44,10 +44,10 @@ func TestPluginAddSceneRegistersScene(t *testing.T) { func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { called := false - plugin := NewPlugin[NoDB]("wizard") + plugin := NewPlugin[NoData]("wizard") plugin.NewScene("signup"). SetEntry("start"). - OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { called = true if ctx.Text != "hello there" { t.Fatalf("unexpected scene text: got %q want %q", ctx.Text, "hello there") @@ -55,7 +55,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { return ctx.Exit(), nil }) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), @@ -145,10 +145,10 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { t.Run("empty entry", func(t *testing.T) { - plugin := NewPlugin[NoDB]("wizard") + plugin := NewPlugin[NoData]("wizard") plugin.NewScene("signup") - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -168,10 +168,10 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { }) t.Run("missing entry step", func(t *testing.T) { - plugin := NewPlugin[NoDB]("wizard") + plugin := NewPlugin[NoData]("wizard") plugin.NewScene("signup").SetEntry("start") - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -209,14 +209,14 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { sceneCommandCalled := false stepCalled := false - plugin := NewPlugin[NoDB]("wizard") + plugin := NewPlugin[NoData]("wizard") plugin.NewScene("signup"). SetEntry("start"). - OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { stepCalled = true return ctx.Stay(), nil }). - OnCommand("cancel", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + OnCommand("cancel", func(ctx *SceneContext, db NoData) (SceneResult, error) { sceneCommandCalled = true if ctx.Prefix != "/" { t.Fatalf("unexpected prefix: got %q want /", ctx.Prefix) @@ -230,7 +230,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { return ctx.Exit(), nil }) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), @@ -269,14 +269,14 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { func TestScenePassDoesNotPersistSessionData(t *testing.T) { commandCalled := false - plugin := NewPlugin[NoDB]("wizard") - plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { + plugin := NewPlugin[NoData]("wizard") + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { commandCalled = true return nil }, "ping") plugin.NewScene("signup"). SetEntry("start"). - OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { if err := ctx.SaveData(struct { Value string `json:"value"` }{Value: "changed"}); err != nil { @@ -285,7 +285,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { return ctx.Pass(), nil }) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), @@ -348,13 +348,13 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { fallbackCalled := false - plugin := NewPlugin[NoDB]("wizard") + plugin := NewPlugin[NoData]("wizard") plugin.NewScene("signup"). SetEntry("start"). - OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil }). - OnMessage(func(ctx *SceneContext, db NoDB) (SceneResult, error) { + OnMessage(func(ctx *SceneContext, db NoData) (SceneResult, error) { fallbackCalled = true if ctx.Text != "hello fallback" { t.Fatalf("unexpected fallback text: got %q want %q", ctx.Text, "hello fallback") @@ -362,7 +362,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { return ctx.Exit(), nil }) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), @@ -407,7 +407,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { } func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat}, @@ -434,7 +434,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { setErr := errors.New("set failed") t.Run("find scene session get error", func(t *testing.T) { - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), sessionStore: failingSessionStore{getErr: getErr}, sceneScopePriority: []SceneScope{SceneScopeUser}, @@ -447,10 +447,10 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { }) t.Run("apply scene result set error", func(t *testing.T) { - scene := NewScene[NoDB]("signup").OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) { + scene := NewScene[NoData]("signup").OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil }) - bot := &Bot[NoDB]{ + bot := &Bot[NoData]{ logger: slog.CreateLogger(), sessionStore: failingSessionStore{setErr: setErr}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, -- 2.54.0 From 140f3397b2a78050e1836ac0135689d13668ae50 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 30 Mar 2026 18:17:28 +0300 Subject: [PATCH 68/99] add authorization policies 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 --- CHANGELOG.md | 7 + TODO.md | 6 +- bot.go | 416 ++++---------------------------------------- bot_config.go | 203 +++++++++++++++++++++ bot_register.go | 156 +++++++++++++++++ drafts_test.go | 2 +- handler.go | 246 +------------------------- handler_test.go | 48 ++++- msg_context.go | 7 + msg_context_test.go | 12 +- msg_handler.go | 117 +++++++++++++ plugins.go | 6 + policy.go | 210 ++++++++++++++++++++++ policy_test.go | 229 ++++++++++++++++++++++++ scene_test.go | 28 +-- tgapi/chat_types.go | 16 +- update_context.go | 179 +++++++++++++++++++ 17 files changed, 1221 insertions(+), 667 deletions(-) create mode 100644 bot_config.go create mode 100644 bot_register.go create mode 100644 msg_handler.go create mode 100644 policy.go create mode 100644 policy_test.go create mode 100644 update_context.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9287891..c0006de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,23 @@ ### Added - `AsUserError(...)`, `AsInternalError(...)`, `IsUserError(...)`, and `IsInternalError(...)` for explicitly marking centralized handler errors as user-visible or internal-only without breaking the existing default error flow. +- `Policy[T]`, `RequirePolicy(...)`, and built-in chat and callback policy helpers for expressing reusable authorization rules through the existing middleware pipeline. +- `Bot.UsePolicy(...)` and `Plugin.UsePolicy(...)` as shorthand for registering policies as middleware. +- `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` for composing reusable authorization rules without introducing a second execution pipeline. ### Changed - Bot configuration mutators now treat the bot as configuration-frozen after the first run begins and ignore late mutation attempts for bot-level config such as prefixes, payload defaults, plugins, middleware, runners, localization, scene session wiring, and database context injection. - `MsgContext` godoc and field comments now describe the normalized update contract more explicitly, including when `Msg`, `From`, callback target fields, `Text`, and `Args` are expected to be populated. +- `MsgContext` normalization now also carries `Chat` and `ChatID` for more Telegram update kinds, allowing policy and update handlers to rely on normalized chat identity outside message-only flows. - `MsgContext.Error(...)` and returned handler errors now suppress the automatic user reply when the error is explicitly marked with `AsInternalError(...)`, while keeping the previous user-visible default for unclassified errors. - Godoc, README examples, and regression-test naming now consistently describe the shared generic dependency model as app data, including `NoData` and `SetAppData(...)`. +- `tgapi.Chat.Type` now uses the typed `tgapi.ChatType` enum in public DTOs and tests instead of raw string casts. ### Tests - Added regression coverage for the bot configuration freeze model, including ignored post-run mutations for core bot configuration methods and late registration paths. - Added table-driven update-contract coverage for `prepareUpdateCtx(...)`, including message-backed, callback-backed, user-backed, and no-user update kinds. +- Added regression tests for policy middleware blocking, built-in private-chat policy decisions, normalized chat identity, and admin checks that use normalized `ChatID` and `FromID`. +- Added regression tests for policy composition semantics, including all-of, any-of, and deny inversion with preserved internal failures. - Added regression tests proving that `edited_message` and `edited_channel_post` stay out of command routing and continue through generic update handlers. - Added callback-routing regression tests for both chat-message and inline-message callback targets, including `CallbackQueryId`, `CallbackMsgId`, `InlineMsgId`, and payload-argument guarantees. - Added regression tests for the new error-visibility model in both message and callback flows, including silent internal-only errors and explicit user-visible callback replies. diff --git a/TODO.md b/TODO.md index 70a59a3..4057971 100644 --- a/TODO.md +++ b/TODO.md @@ -12,11 +12,13 @@ Russian page: Current priority split: -- `Priority 1`: webhook runtime model, authorization and policy model, observability model. -- `Priority 2`: service layer and dependency graph model, plugin composition contract. +- `Priority 1`: observability model. +- `Priority 2`: service layer and dependency graph model. +- `Partial`: webhook runtime model, plugin composition contract. Completed former high-priority items: +- `[v1.0.0-rc.13] Authorization and policy model`: added first-class `Policy[T]`, middleware integration through `RequirePolicy(...)`, plugin and bot policy registration helpers, built-in Telegram-aware policies, and composable `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` helpers with regression coverage. - `[v1.0.0-rc.13] Update schema contract`: documented and tested the normalized `MsgContext` update-routing contract, including routing categories and per-update field guarantees. - `[v1.0.0-rc.13] User-facing vs internal error model`: added explicit user-visible vs internal-only error markers and updated centralized handler error routing accordingly. - `[v1.0.0-rc.13] Configuration freeze model`: formalized bot configuration freeze after first run, documented lifecycle commit points, and added regression coverage for ignored late mutations. diff --git a/bot.go b/bot.go index 02e73e9..a437b92 100644 --- a/bot.go +++ b/bot.go @@ -6,8 +6,6 @@ import ( "fmt" "maps" "reflect" - "slices" - "sort" "strings" "sync" "time" @@ -282,38 +280,6 @@ func (bot *Bot[T]) CloseRemote(ctx context.Context) error { return nil } -// Internal logger setup for the bot and optional request logger. -func (bot *Bot[T]) initLoggers(opts *BotOpts) { - level := slog.FATAL - if opts.Debug { - level = slog.DEBUG - } - - bot.logger = utils.CreateLogger("BOT", level) - if opts.WriteToFile { - path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("BOT", level, path) - if err != nil { - bot.logger.Errorln(err) - } else { - bot.logger = logger - } - } - - if opts.UseRequestLogger { - bot.RequestLogger = utils.CreateLogger("REQUESTS", level) - if opts.WriteToFile { - path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("REQUESTS", level, path) - if err != nil { - bot.logger.Errorln(err) - } else { - bot.RequestLogger = logger - } - } - } -} - // GetUpdateOffset returns the current update offset (thread-safe). func (bot *Bot[T]) GetUpdateOffset() int { bot.updateOffsetMu.Lock() @@ -328,18 +294,9 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) { bot.updateOffset = offset } -// GetUpdateTypes returns the list of update types the bot is configured to receive. -func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { - return append([]tgapi.UpdateType(nil), bot.updateTypes...) -} - // GetLogger returns the main bot logger. func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } -// GetAppData returns the injected application data. -// If SetAppData was not called, it returns the zero value of T. -func (bot *Bot[T]) GetAppData() T { return bot.appData } - // GetLoggerLevel returns the effective log level derived from the bot's debug // flag. func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel { @@ -356,340 +313,6 @@ func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } -// SetDraftProvider replaces the default DraftProvider with a custom one. -// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts. -func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { - if !bot.configMutable("SetDraftProvider") { - return bot - } - bot.draftProvider = p - return bot -} - -// GetDraftProvider returns the draft provider currently used by the bot. -func (bot *Bot[T]) GetDraftProvider() *DraftProvider { - return bot.draftProvider -} - -// SetSessionStore replaces the session store used for scene management. -func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { - if !bot.configMutable("SetSessionStore") { - return bot - } - if store == nil { - bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore") - return bot - } - bot.sessionStore = store - return bot -} - -// GetSessionStore returns the session store used for scene management. -func (bot *Bot[T]) GetSessionStore() SessionStore { - return bot.sessionStore -} - -// SetSceneScopePriority sets the lookup order for resolving active scene sessions. -func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { - if !bot.configMutable("SetSceneScopePriority") { - return bot - } - newPriority := make([]SceneScope, 0, 3) - for _, scope := range priority { - if scope != SceneScopeUser && scope != SceneScopeChat && scope != SceneScopeUserChat { - bot.logger.Warnln(fmt.Sprintf("invalid scene scope %v in priority list; ignoring", scope)) - continue - } - if slices.Index(newPriority, scope) >= 0 { - bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope)) - continue - } - newPriority = append(newPriority, scope) - } - if len(newPriority) == 0 || len(newPriority) > 3 { - bot.logger.Warnln("scene scope priority must have 1 to 3 scopes; ignoring invalid input") - return bot - } - bot.sceneScopePriority = append([]SceneScope(nil), newPriority...) - return bot -} - -// SetAppData injects shared application data into the bot. -// -// The data is accessible to commands, payload handlers, middleware, scenes, -// and runners through the generic type parameter T. -// -// For shared dependencies such as *sql.DB, prefer using a pointer type as T. -// Value-typed application data is supported, but the bot warns once because -// handlers receive T by value. -func (bot *Bot[T]) SetAppData(ctx T) *Bot[T] { - if !bot.configMutable("SetAppData") { - return bot - } - if !bot.warnedValueData && shouldWarnOnValueAppData[T]() && bot.logger != nil { - bot.logger.Warnln("app data uses a value type; shared dependencies should usually use a pointer type as T") - bot.warnedValueData = true - } - bot.appData = ctx - bot.hasAppData = true - return bot -} - -// SetUpdateTypes sets the list of update types the bot will request from Telegram. -// Overwrites any previously set types. -func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] { - if !bot.configMutable("UpdateTypes") { - return bot - } - bot.updateTypes = make([]tgapi.UpdateType, 0) - bot.updateTypes = append(bot.updateTypes, t...) - return bot -} - -// SetPayloadType sets the default payload encoding type used for callback data. -// JSON stores payload as a string: `{"cmd":"command","args":[...]}`. -// Base64 stores the same JSON encoded as a Base64URL string. -// InlineKeyboard.SetPayloadType may override this value for an individual keyboard. -func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { - if !bot.configMutable("SetPayloadType") { - return bot - } - bot.payloadType = t - return bot -} - -// GetPayloadType returns the bot's default callback payload encoding type. -func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType } - -// SetStrictPayloadType enables or disables strict callback payload decoding. -// When enabled, callback payloads must match the bot's default payload type. -func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] { - if !bot.configMutable("SetStrictPayloadType") { - return bot - } - bot.strictPayloadType = strict - return bot -} - -// AddUpdateType adds one or more update types to the list. -// Does not overwrite existing types. -func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { - if !bot.configMutable("AddUpdateType") { - return bot - } - bot.updateTypes = append(bot.updateTypes, t...) - return bot -} - -// AddPrefixes adds one or more command prefixes (e.g., "/", "!"). -// Must have at least one prefix before Run(). -func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { - if !bot.configMutable("AddPrefixes") { - return bot - } - bot.prefixes = append(bot.prefixes, prefixes...) - return bot -} - -// SetErrorTemplate sets the format string for error messages sent to users. -// Use "%s" to insert the error message. -// Example: "❌ Error: %s" → "❌ Error: Command not found". -func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] { - if !bot.configMutable("ErrorTemplate") { - return bot - } - bot.errorTemplate = s - return bot -} - -// SetDebug enables or disables debug logging. -func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { - bot.debug = debug - level := slog.FATAL - if debug { - level = slog.DEBUG - } - - bot.logger.Level(level) - if bot.RequestLogger != nil { - bot.RequestLogger.Level(level) - } - for _, p := range bot.plugins { - if p.logger == nil { - continue - } - p.logger.Level(level) - } - return bot -} - -// AddPlugins registers one or more plugins. -// Plugins are executed in registration order unless filtered by middleware. -// -// Registration is a commit point for plugin configuration. The Bot stores -// plugin metadata internally, so plugins must be fully configured before they -// are passed here. Post-registration mutation through the original *Plugin is -// not a supported API, even if some changes appear to work due to shared maps. -func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { - if !bot.configMutable("AddPlugins") { - return bot - } - level := bot.GetLoggerLevel() - for _, p := range plugin { - if p == nil { - if bot.logger != nil { - bot.logger.Warn("nil plugin skipped") - } - continue - } - cloned := clonePlugin(p) - if cloned.logger == nil { - cloned.logger = utils.CreateLogger(cloned.name, level) - } - bot.plugins = append(bot.plugins, cloned) - if bot.logger != nil { - bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) - } - } - return bot -} - -// AddMiddleware registers one or more middleware handlers. -// -// Middleware are executed in order of increasing .order value before plugins. -// If two middleware have the same order, they are sorted lexicographically by name. -// -// Middleware can: -// - Modify or reject updates before they reach plugins -// - Inject context (e.g., user auth state, rate limit status) -// - Log, validate, or transform incoming data -// -// Example: -// -// bot.AddMiddleware(authMiddleware, rateLimitMiddleware) -// -// Middleware with an empty name are skipped with a warning. -func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { - if !bot.configMutable("AddMiddleware") { - return bot - } - for _, m := range middleware { - if m.name == "" { - bot.logger.Warnln("middleware must have a non-empty name") - continue - } - bot.middlewares = append(bot.middlewares, m) - bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) - } - - // Stable sort by order (ascending), then by name (lexicographic) - sort.Slice(bot.middlewares, func(i, j int) bool { - first := bot.middlewares[i] - second := bot.middlewares[j] - if first.order != second.order { - return first.order < second.order - } - return first.name < second.name - }) - - return bot -} - -// AddRunner registers a background runner to execute concurrently with the bot. -// -// Runners are goroutines that run independently of update processing. -// Common use cases: -// - Periodic cleanup (e.g., expiring drafts, clearing temp files) -// - Metrics collection or health checks -// - Scheduled tasks (e.g., daily announcements) -// -// Runners are started immediately after Bot.Run() is called. -// -// Example: -// -// bot.AddRunner(cleanupRunner) -// -// Runners with an empty name are skipped with a warning. -func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { - if !bot.configMutable("AddRunner") { - return bot - } - if runner.name == "" { - bot.logger.Warnln("runner must have a non-empty name") - return bot - } - bot.runners = append(bot.runners, runner) - bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) - return bot -} - -// SetL10n sets the localization (i18n) provider for the bot. -// -// The L10n instance must be pre-populated with translations. -// Translations are accessed via Bot.L10n(lang, key). -// -// Example: -// -// l10n := l10n.New() -// l10n.Add("en", "hello", "Hello!") -// l10n.Add("es", "hello", "¡Hola!") -// bot.SetL10n(l10n) -// -// Replaces any previously set L10n instance. -func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] { - if !bot.configMutable("SetL10n") { - return bot - } - if l == nil { - bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled") - return bot - } - bot.l10n = l - return bot -} - -// AddAppDataLoggerWriter adds an app-data-backed logger writer to all loggers. -// -// The writer will receive logs from: -// - Main bot logger -// - Request logger (if enabled) -// - API and Uploader loggers -// - Already registered plugin loggers -// -// Call this after AddPlugins if plugin loggers should also receive the writer. -// Plugins registered later do not automatically inherit previously added -// writers; call AddAppDataLoggerWriter again after adding them. -// -// Example: -// -// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter { -// return data.QueryLogger() -// }) -func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { - if !bot.hasAppData { - bot.logger.Warnln("app data is not set; skipping app-data logger writer") - return bot - } - if isNilValue(bot.appData) { - bot.logger.Warnln("app data is nil; skipping app-data logger writer") - return bot - } - w := writer(bot.appData) - bot.logger.AddWriter(w) - if bot.RequestLogger != nil { - bot.RequestLogger.AddWriter(w) - } - for _, l := range bot.extraLoggers { - l.AddWriter(w) - } - for _, p := range bot.plugins { - if p.logger != nil { - p.logger.AddWriter(w) - } - } - return bot -} - // RunWithContext starts the bot with a given context for graceful shutdown. // // This is the main entry point for bot execution. It: @@ -706,14 +329,6 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { // RunWithContext does not close API, uploader, or logger resources on return. // The caller must invoke Close after RunWithContext finishes. // -// Example: -// -// ctx, cancel := context.WithCancel(context.Background()) -// go bot.RunWithContext(ctx) -// // ... later ... -// cancel() // triggers graceful shutdown -// _ = bot.Close() -// // A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun. func (bot *Bot[T]) RunWithContext(ctx context.Context) error { if len(bot.prefixes) == 0 { @@ -802,6 +417,37 @@ func (bot *Bot[T]) Run() error { return bot.RunWithContext(context.Background()) } +func (bot *Bot[T]) initLoggers(opts *BotOpts) { + level := slog.FATAL + if opts.Debug { + level = slog.DEBUG + } + + bot.logger = utils.CreateLogger("BOT", level) + if opts.WriteToFile { + path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) + logger, err := utils.CreateFileLogger("BOT", level, path) + if err != nil { + bot.logger.Errorln(err) + } else { + bot.logger = logger + } + } + + if opts.UseRequestLogger { + bot.RequestLogger = utils.CreateLogger("REQUESTS", level) + if opts.WriteToFile { + path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) + logger, err := utils.CreateFileLogger("REQUESTS", level, path) + if err != nil { + bot.logger.Errorln(err) + } else { + bot.RequestLogger = logger + } + } + } +} + func (bot *Bot[T]) beginRun() error { bot.runStateMu.Lock() defer bot.runStateMu.Unlock() diff --git a/bot_config.go b/bot_config.go new file mode 100644 index 0000000..a2d8cd1 --- /dev/null +++ b/bot_config.go @@ -0,0 +1,203 @@ +package laniakea + +import ( + "fmt" + "slices" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" +) + +// AddPrefixes adds one or more command prefixes (e.g., "/", "!"). +// Must have at least one prefix before Run(). +func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { + if !bot.configMutable("AddPrefixes") { + return bot + } + bot.prefixes = append(bot.prefixes, prefixes...) + return bot +} + +// SetDraftProvider replaces the default DraftProvider with a custom one. +// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts. +func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { + if !bot.configMutable("SetDraftProvider") { + return bot + } + bot.draftProvider = p + return bot +} + +// GetDraftProvider returns the draft provider currently used by the bot. +func (bot *Bot[T]) GetDraftProvider() *DraftProvider { + return bot.draftProvider +} + +// SetSessionStore replaces the session store used for scene management. +func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { + if !bot.configMutable("SetSessionStore") { + return bot + } + if store == nil { + bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore") + return bot + } + bot.sessionStore = store + return bot +} + +// GetSessionStore returns the session store used for scene management. +func (bot *Bot[T]) GetSessionStore() SessionStore { + return bot.sessionStore +} + +// SetSceneScopePriority sets the lookup order for resolving active scene sessions. +func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] { + if !bot.configMutable("SetSceneScopePriority") { + return bot + } + newPriority := make([]SceneScope, 0, 3) + for _, scope := range priority { + if scope != SceneScopeUser && scope != SceneScopeChat && scope != SceneScopeUserChat { + bot.logger.Warnln(fmt.Sprintf("invalid scene scope %v in priority list; ignoring", scope)) + continue + } + if slices.Index(newPriority, scope) >= 0 { + bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope)) + continue + } + newPriority = append(newPriority, scope) + } + if len(newPriority) == 0 || len(newPriority) > 3 { + bot.logger.Warnln("scene scope priority must have 1 to 3 scopes; ignoring invalid input") + return bot + } + bot.sceneScopePriority = append([]SceneScope(nil), newPriority...) + return bot +} + +// SetAppData injects shared application data into the bot. +// +// The data is accessible to commands, payload handlers, middleware, scenes, +// and runners through the generic type parameter T. +// +// For shared dependencies such as *sql.DB, prefer using a pointer type as T. +// Value-typed application data is supported, but the bot warns once because +// handlers receive T by value. +func (bot *Bot[T]) SetAppData(ctx T) *Bot[T] { + if !bot.configMutable("SetAppData") { + return bot + } + if !bot.warnedValueData && shouldWarnOnValueAppData[T]() && bot.logger != nil { + bot.logger.Warnln("app data uses a value type; shared dependencies should usually use a pointer type as T") + bot.warnedValueData = true + } + bot.appData = ctx + bot.hasAppData = true + return bot +} + +// GetAppData returns the injected application data. +// If SetAppData was not called, it returns the zero value of T. +func (bot *Bot[T]) GetAppData() T { return bot.appData } + +// SetUpdateTypes sets the list of update types the bot will request from Telegram. +// Overwrites any previously set types. +func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] { + if !bot.configMutable("UpdateTypes") { + return bot + } + bot.updateTypes = make([]tgapi.UpdateType, 0) + bot.updateTypes = append(bot.updateTypes, t...) + return bot +} + +// AddUpdateType adds one or more update types to the list. +// Does not overwrite existing types. +func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { + if !bot.configMutable("AddUpdateType") { + return bot + } + bot.updateTypes = append(bot.updateTypes, t...) + return bot +} + +// GetUpdateTypes returns the list of update types the bot is configured to receive. +func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { + return append([]tgapi.UpdateType(nil), bot.updateTypes...) +} + +// SetPayloadType sets the default payload encoding type used for callback data. +// JSON stores payload as a string: `{"cmd":"command","args":[...]}`. +// Base64 stores the same JSON encoded as a Base64URL string. +// InlineKeyboard.SetPayloadType may override this value for an individual keyboard. +func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { + if !bot.configMutable("SetPayloadType") { + return bot + } + bot.payloadType = t + return bot +} + +// GetPayloadType returns the bot's default callback payload encoding type. +func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType } + +// SetStrictPayloadType enables or disables strict callback payload decoding. +// When enabled, callback payloads must match the bot's default payload type. +func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] { + if !bot.configMutable("SetStrictPayloadType") { + return bot + } + bot.strictPayloadType = strict + return bot +} + +// SetErrorTemplate sets the format string for error messages sent to users. +// Use "%s" to insert the error message. +// Example: "❌ Error: %s" → "❌ Error: Command not found". +func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] { + if !bot.configMutable("ErrorTemplate") { + return bot + } + bot.errorTemplate = s + return bot +} + +// SetDebug enables or disables debug logging. +func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { + bot.debug = debug + level := slog.FATAL + if debug { + level = slog.DEBUG + } + + bot.logger.Level(level) + if bot.RequestLogger != nil { + bot.RequestLogger.Level(level) + } + for _, p := range bot.plugins { + if p.logger == nil { + continue + } + p.logger.Level(level) + } + return bot +} + +// SetL10n sets the localization (i18n) provider for the bot. +// +// The L10n instance must be pre-populated with translations. +// Translations are accessed via Bot.L10n(lang, key). +// +// Replaces any previously set L10n instance. +func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] { + if !bot.configMutable("SetL10n") { + return bot + } + if l == nil { + bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled") + return bot + } + bot.l10n = l + return bot +} diff --git a/bot_register.go b/bot_register.go new file mode 100644 index 0000000..6a7a4ee --- /dev/null +++ b/bot_register.go @@ -0,0 +1,156 @@ +package laniakea + +import ( + "fmt" + "sort" + + "git.scuroneko.dev/scuroneko/laniakea/utils" +) + +// AddPlugins registers one or more plugins. +// Plugins are executed in registration order unless filtered by middleware. +// +// Registration is a commit point for plugin configuration. The Bot stores +// plugin metadata internally, so plugins must be fully configured before they +// are passed here. Post-registration mutation through the original *Plugin is +// not a supported API, even if some changes appear to work due to shared maps. +func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { + if !bot.configMutable("AddPlugins") { + return bot + } + level := bot.GetLoggerLevel() + for _, p := range plugin { + if p == nil { + if bot.logger != nil { + bot.logger.Warn("nil plugin skipped") + } + continue + } + cloned := clonePlugin(p) + if cloned.logger == nil { + cloned.logger = utils.CreateLogger(cloned.name, level) + } + bot.plugins = append(bot.plugins, cloned) + if bot.logger != nil { + bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) + } + } + return bot +} + +// AddMiddleware registers one or more middleware handlers. +// +// Middleware are executed in order of increasing .order value before plugins. +// If two middleware have the same order, they are sorted lexicographically by name. +// +// Middleware can: +// - Modify or reject updates before they reach plugins +// - Inject context (e.g., user auth state, rate limit status) +// - Log, validate, or transform incoming data +// +// Example: +// +// bot.AddMiddleware(authMiddleware, rateLimitMiddleware) +// +// Middleware with an empty name are skipped with a warning. +func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { + if !bot.configMutable("AddMiddleware") { + return bot + } + for _, m := range middleware { + if m.name == "" { + bot.logger.Warnln("middleware must have a non-empty name") + continue + } + bot.middlewares = append(bot.middlewares, m) + bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) + } + + // Stable sort by order (ascending), then by name (lexicographic) + sort.Slice(bot.middlewares, func(i, j int) bool { + first := bot.middlewares[i] + second := bot.middlewares[j] + if first.order != second.order { + return first.order < second.order + } + return first.name < second.name + }) + + return bot +} + +// UsePolicy registers a Policy as a bot-level middleware. +func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] { + mw := RequirePolicy(name, policy) + return bot.AddMiddleware(mw) +} + +// AddRunner registers a background runner to execute concurrently with the bot. +// +// Runners are goroutines that run independently of update processing. +// Common use cases: +// - Periodic cleanup (e.g., expiring drafts, clearing temp files) +// - Metrics collection or health checks +// - Scheduled tasks (e.g., daily announcements) +// +// Runners are started immediately after Bot.Run() is called. +// +// Example: +// +// bot.AddRunner(cleanupRunner) +// +// Runners with an empty name are skipped with a warning. +func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { + if !bot.configMutable("AddRunner") { + return bot + } + if runner.name == "" { + bot.logger.Warnln("runner must have a non-empty name") + return bot + } + bot.runners = append(bot.runners, runner) + bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) + return bot +} + +// AddAppDataLoggerWriter adds an app-data-backed logger writer to all loggers. +// +// The writer will receive logs from: +// - Main bot logger +// - Request logger (if enabled) +// - API and Uploader loggers +// - Already registered plugin loggers +// +// Call this after AddPlugins if plugin loggers should also receive the writer. +// Plugins registered later do not automatically inherit previously added +// writers; call AddAppDataLoggerWriter again after adding them. +// +// Example: +// +// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter { +// return data.QueryLogger() +// }) +func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { + if !bot.hasAppData { + bot.logger.Warnln("app data is not set; skipping app-data logger writer") + return bot + } + if isNilValue(bot.appData) { + bot.logger.Warnln("app data is nil; skipping app-data logger writer") + return bot + } + w := writer(bot.appData) + bot.logger.AddWriter(w) + if bot.RequestLogger != nil { + bot.RequestLogger.AddWriter(w) + } + for _, l := range bot.extraLoggers { + l.AddWriter(w) + } + for _, p := range bot.plugins { + if p.logger != nil { + p.logger.AddWriter(w) + } + } + return bot +} diff --git a/drafts_test.go b/drafts_test.go index 3d79e03..757aa16 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -22,7 +22,7 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { ctx := &MsgContext{ Api: &tgapi.API{}, Msg: &tgapi.Message{ - Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, Logger: slog.CreateLogger(), draftProvider: NewRandomDraftProvider(&tgapi.API{}), diff --git a/handler.go b/handler.go index afdb882..89797e7 100644 --- a/handler.go +++ b/handler.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "strings" "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) @@ -61,103 +60,6 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { } } -func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { - var msg *tgapi.Message - if update.Message != nil { - msg = update.Message - } else if update.ChannelPost != nil { - msg = update.ChannelPost - } else { - return - } - - var text string - if len(msg.Text) > 0 { - text = msg.Text - } else if len(msg.Caption) > 0 { - text = msg.Caption - } else { - return - } - - prefix, cmd, args := bot.parseCommand(text) - if cmd == "" { - return - } - ctx.Prefix = prefix - - if strings.Contains(cmd, "@") { - botUsername := bot.username - if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) { - cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname - } - } - - // Ищем команду по точному совпадению - for _, plugin := range bot.plugins { - if _, exists := plugin.commands[cmd]; exists { - ctx.Text = args - ctx.Args = strings.Fields(args) // Убирает лишние пробелы - - if plugin.logger != nil { - ctx.Logger = plugin.logger - } - if !plugin.executeMiddlewares(ctx, bot.appData) { - return - } - plugin.executeCmd(cmd, ctx, bot.appData) - return - } - } -} - -func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { - data, err := bot.decodePayload(update.CallbackQuery.Data) - if err != nil { - bot.logger.Errorln(err) - return - } - - ctx.Args = data.Args - - for _, plugin := range bot.plugins { - _, ok := plugin.payloads[data.Command] - if !ok { - continue - } - - ctx.Logger = plugin.logger - if ctx.Logger == nil { - ctx.Logger = bot.logger - } - if !plugin.executeMiddlewares(ctx, bot.appData) { - return - } - plugin.executePayload(data.Command, ctx, bot.appData) - return - } -} - -func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { - for _, plugin := range bot.plugins { - handler, ok := plugin.handlers[u.Type] - if !ok { - continue - } - - pluginCtx := cloneMsgContext(ctx) - if plugin.logger != nil { - pluginCtx.Logger = plugin.logger - } - if !plugin.executeMiddlewares(pluginCtx, bot.appData) { - continue - } - if err := handler(pluginCtx, bot.appData); err != nil { - pluginCtx.error(err) - } - } -} - func cloneMsgContext(src *MsgContext) *MsgContext { cloned := *src if src.Args != nil { @@ -166,139 +68,6 @@ func cloneMsgContext(src *MsgContext) *MsgContext { return &cloned } -func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { - var from *tgapi.User - switch u.Type { - case tgapi.UpdateTypeMessage: - if u.Message != nil { - ctx.Msg = u.Message - } - case tgapi.UpdateTypeEditedMessage: - if u.EditedMessage != nil { - ctx.Msg = u.EditedMessage - } - case tgapi.UpdateTypeChannelPost: - if u.ChannelPost != nil { - ctx.Msg = u.ChannelPost - } - case tgapi.UpdateTypeEditedChannelPost: - if u.EditedChannelPost != nil { - ctx.Msg = u.EditedChannelPost - } - case tgapi.UpdateTypeBusinessMessage: - if u.BusinessMessage != nil { - ctx.Msg = u.BusinessMessage - } - case tgapi.UpdateTypeEditedBusinessMessage: - if u.EditedBusinessMessage != nil { - ctx.Msg = u.EditedBusinessMessage - } - case tgapi.UpdateTypeInlineQuery: - if u.InlineQuery != nil { - from = &u.InlineQuery.From - } - case tgapi.UpdateTypeChosenInlineResult: - if u.ChosenInlineResult != nil { - from = &u.ChosenInlineResult.From - } - case tgapi.UpdateTypeCallbackQuery: - if u.CallbackQuery != nil { - if u.CallbackQuery.Message != nil { - ctx.Msg = u.CallbackQuery.Message - ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID - } - if u.CallbackQuery.InlineMessageID != nil { - ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID - } - ctx.CallbackQueryId = u.CallbackQuery.ID - from = &u.CallbackQuery.From - } - case tgapi.UpdateTypeShippingQuery: - if u.ShippingQuery != nil { - from = &u.ShippingQuery.From - } - case tgapi.UpdateTypePreCheckoutQuery: - if u.PreCheckoutQuery != nil { - from = &u.PreCheckoutQuery.From - } - case tgapi.UpdateTypePurchasedPaidMedia: - if u.PurchasedPaidMedia != nil { - from = &u.PurchasedPaidMedia.From - } - case tgapi.UpdateTypeMyChatMember: - if u.MyChatMember != nil { - from = &u.MyChatMember.From - } - case tgapi.UpdateTypeChatMember: - if u.ChatMember != nil { - from = &u.ChatMember.From - } - case tgapi.UpdateTypeChatJoinRequest: - if u.ChatJoinRequest != nil { - from = &u.ChatJoinRequest.From - } - case tgapi.UpdateTypeBusinessConnection: - if u.BusinessConnection != nil { - from = &u.BusinessConnection.User - } - case tgapi.UpdateTypePollAnswer: - if u.PollAnswer != nil { - from = &u.PollAnswer.User - } - case tgapi.UpdateTypeMessageReaction: - if u.MessageReaction != nil { - from = u.MessageReaction.User - } - case tgapi.UpdateTypeChatBoost: - if u.ChatBoost != nil { - from = &u.ChatBoost.Boost.Source.User - } - case tgapi.UpdateTypeRemovedChatBoost: - if u.RemovedChatBoost != nil { - from = &u.RemovedChatBoost.Source.User - } - } - if ctx.Msg != nil && from == nil { - from = ctx.Msg.From - } - if from != nil { - ctx.From = from - ctx.FromID = from.ID - } -} - -func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { - for _, prefix := range bot.prefixes { - if prefix == "" { - if bot.logger != nil { - bot.logger.Warnln("empty prefix is not allowed") - } - continue - } - if strings.HasPrefix(text, prefix) { - return prefix, true - } - } - return "", false -} -func (bot *Bot[T]) parseCommand(text string) (prefix, cmd, args string) { - if prefix, hasPrefix := bot.checkPrefixes(text); hasPrefix { - text = strings.TrimSpace(text[len(prefix):]) - spaceIndex := strings.Index(text, " ") - var cmd string - var args string - if spaceIndex == -1 { - cmd = text - args = "" - } else { - cmd = text[:spaceIndex] - args = strings.TrimSpace(text[spaceIndex:]) - } - return prefix, cmd, args - } - return "", "", "" -} - func encodeJsonPayload(d CallbackData) (string, error) { b, err := json.Marshal(d) if err != nil { @@ -306,11 +75,13 @@ func encodeJsonPayload(d CallbackData) (string, error) { } return string(b), nil } + func decodeJsonPayload(s string) (CallbackData, error) { var data CallbackData err := json.Unmarshal([]byte(s), &data) return data, err } + func encodeBase64Payload(d CallbackData) (string, error) { data, err := encodeJsonPayload(d) if err != nil { @@ -321,15 +92,6 @@ func encodeBase64Payload(d CallbackData) (string, error) { return string(dst), nil } -// func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) { -// switch payloadType { -// case BotPayloadBase64: -// return encodeBase64Payload(d) -// case BotPayloadJson: -// return encodeJsonPayload(d) -// } -// return "", ErrInvalidPayloadType -// } func decodeBase64Payload(s string) (CallbackData, error) { b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { @@ -337,6 +99,7 @@ func decodeBase64Payload(s string) (CallbackData, error) { } return decodeJsonPayload(string(b)) } + func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) { switch payloadType { case BotPayloadBase64: @@ -369,9 +132,6 @@ func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackD return CallbackData{}, "", ErrInvalidPayloadType } -// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) { -// return encodePayload(bot.payloadType, d) -// } func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType) if err != nil { diff --git a/handler_test.go b/handler_test.go index 74aa6f8..3b0674c 100644 --- a/handler_test.go +++ b/handler_test.go @@ -85,6 +85,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg bool wantFrom bool wantFromID int64 + wantChat bool + wantChatID int64 wantCallbackID string wantCallbackMsgID int wantInlineMsgID string @@ -102,6 +104,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg: true, wantFrom: true, wantFromID: 101, + wantChat: true, + wantChatID: 1001, }, { name: "edited message", @@ -116,6 +120,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg: true, wantFrom: true, wantFromID: 102, + wantChat: true, + wantChatID: 1002, }, { name: "channel post sender chat", @@ -126,7 +132,9 @@ func TestPrepareUpdateCtxContract(t *testing.T) { Chat: &tgapi.Chat{ID: -1003}, }, }, - wantMsg: true, + wantMsg: true, + wantChat: true, + wantChatID: -1003, }, { name: "business message", @@ -141,6 +149,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg: true, wantFrom: true, wantFromID: 103, + wantChat: true, + wantChatID: 1004, }, { name: "inline query", @@ -176,6 +186,8 @@ func TestPrepareUpdateCtxContract(t *testing.T) { wantMsg: true, wantFrom: true, wantFromID: 106, + wantChat: true, + wantChatID: 1005, wantCallbackID: "cb-1", wantCallbackMsgID: 77, }, @@ -225,28 +237,34 @@ func TestPrepareUpdateCtxContract(t *testing.T) { name: "my chat member", update: &tgapi.Update{ Type: tgapi.UpdateTypeMyChatMember, - MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}}, + MyChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 111}, Chat: tgapi.Chat{ID: -2001}}, }, wantFrom: true, wantFromID: 111, + wantChat: true, + wantChatID: -2001, }, { name: "chat member", update: &tgapi.Update{ Type: tgapi.UpdateTypeChatMember, - ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}}, + ChatMember: &tgapi.ChatMemberUpdated{From: tgapi.User{ID: 112}, Chat: tgapi.Chat{ID: -2002}}, }, wantFrom: true, wantFromID: 112, + wantChat: true, + wantChatID: -2002, }, { name: "chat join request", update: &tgapi.Update{ Type: tgapi.UpdateTypeChatJoinRequest, - ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}}, + ChatJoinRequest: &tgapi.ChatJoinRequest{From: tgapi.User{ID: 113}, Chat: tgapi.Chat{ID: -2003}}, }, wantFrom: true, wantFromID: 113, + wantChat: true, + wantChatID: -2003, }, { name: "business connection", @@ -270,32 +288,40 @@ func TestPrepareUpdateCtxContract(t *testing.T) { name: "message reaction", update: &tgapi.Update{ Type: tgapi.UpdateTypeMessageReaction, - MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}}, + MessageReaction: &tgapi.MessageReactionUpdated{User: &tgapi.User{ID: 116}, Chat: &tgapi.Chat{ID: -2004}}, }, wantFrom: true, wantFromID: 116, + wantChat: true, + wantChatID: -2004, }, { name: "chat boost", update: &tgapi.Update{ Type: tgapi.UpdateTypeChatBoost, ChatBoost: &tgapi.ChatBoostUpdated{ + Chat: tgapi.Chat{ID: -2005}, Boost: tgapi.ChatBoost{Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 117}}}, }, }, wantFrom: true, wantFromID: 117, + wantChat: true, + wantChatID: -2005, }, { name: "removed chat boost", update: &tgapi.Update{ Type: tgapi.UpdateTypeRemovedChatBoost, RemovedChatBoost: &tgapi.ChatBoostRemoved{ + Chat: tgapi.Chat{ID: -2006}, Source: tgapi.ChatBoostSource{User: tgapi.User{ID: 118}}, }, }, wantFrom: true, wantFromID: 118, + wantChat: true, + wantChatID: -2006, }, { name: "poll", @@ -328,6 +354,12 @@ func TestPrepareUpdateCtxContract(t *testing.T) { if ctx.FromID != tt.wantFromID { t.Fatalf("unexpected FromID: got %d want %d", ctx.FromID, tt.wantFromID) } + if got := ctx.Chat != nil; got != tt.wantChat { + t.Fatalf("unexpected Chat presence: got %v want %v", got, tt.wantChat) + } + if ctx.ChatID != tt.wantChatID { + t.Fatalf("unexpected ChatID: got %d want %d", ctx.ChatID, tt.wantChatID) + } if ctx.CallbackQueryId != tt.wantCallbackID { t.Fatalf("unexpected CallbackQueryId: got %q want %q", ctx.CallbackQueryId, tt.wantCallbackID) } @@ -505,8 +537,8 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { ChannelPost: &tgapi.Message{ MessageID: 55, Text: "/ping", - SenderChat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, - Chat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)}, + SenderChat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel}, + Chat: &tgapi.Chat{ID: -1001, Type: tgapi.ChatTypeChannel}, }, }) @@ -542,7 +574,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { Message: &tgapi.Message{ MessageID: 1, Text: "/ban 42 too loud", - Chat: &tgapi.Chat{ID: 99, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 99, Type: tgapi.ChatTypePrivate}, }, }) diff --git a/msg_context.go b/msg_context.go index 31f488f..b749013 100644 --- a/msg_context.go +++ b/msg_context.go @@ -21,6 +21,7 @@ import ( // - 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. @@ -38,6 +39,9 @@ type MsgContext struct { // 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. @@ -55,6 +59,9 @@ type MsgContext struct { // 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 diff --git a/msg_context_test.go b/msg_context_test.go index f0465d6..78c67e0 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -47,7 +47,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { ctx := &MsgContext{ Api: api, Msg: &tgapi.Message{ - Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, }, Logger: slog.CreateLogger(), @@ -201,7 +201,7 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { ctx := &MsgContext{ Api: api, - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), errorTemplate: "Error: %s", } @@ -237,7 +237,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { ctx := &MsgContext{ Api: api, - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), errorTemplate: "Error: %s", } @@ -326,7 +326,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { func TestAnswerRejectsEmptyMessage(t *testing.T) { ctx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), } @@ -356,7 +356,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { ctx := &MsgContext{ Api: api, - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), } @@ -440,7 +440,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { ctx := &MsgContext{ Api: api, - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: slog.CreateLogger(), } kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd") diff --git a/msg_handler.go b/msg_handler.go new file mode 100644 index 0000000..39dba5b --- /dev/null +++ b/msg_handler.go @@ -0,0 +1,117 @@ +package laniakea + +import ( + "strings" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { + var msg *tgapi.Message + if update.Message != nil { + msg = update.Message + } else if update.ChannelPost != nil { + msg = update.ChannelPost + } else { + return + } + + var text string + if len(msg.Text) > 0 { + text = msg.Text + } else if len(msg.Caption) > 0 { + text = msg.Caption + } else { + return + } + + prefix, cmd, args := bot.parseCommand(text) + if cmd == "" { + return + } + ctx.Prefix = prefix + + if strings.Contains(cmd, "@") { + botUsername := bot.username + if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) { + cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname + } + } + + // Ищем команду по точному совпадению + for _, plugin := range bot.plugins { + if _, exists := plugin.commands[cmd]; exists { + ctx.Text = args + ctx.Args = strings.Fields(args) // Убирает лишние пробелы + + if plugin.logger != nil { + ctx.Logger = plugin.logger + } + if !plugin.executeMiddlewares(ctx, bot.appData) { + return + } + plugin.executeCmd(cmd, ctx, bot.appData) + return + } + } +} + +func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { + data, err := bot.decodePayload(update.CallbackQuery.Data) + if err != nil { + bot.logger.Errorln(err) + return + } + + ctx.Args = data.Args + + for _, plugin := range bot.plugins { + _, ok := plugin.payloads[data.Command] + if !ok { + continue + } + + ctx.Logger = plugin.logger + if ctx.Logger == nil { + ctx.Logger = bot.logger + } + if !plugin.executeMiddlewares(ctx, bot.appData) { + return + } + plugin.executePayload(data.Command, ctx, bot.appData) + return + } +} + +func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { + for _, prefix := range bot.prefixes { + if prefix == "" { + if bot.logger != nil { + bot.logger.Warnln("empty prefix is not allowed") + } + continue + } + if strings.HasPrefix(text, prefix) { + return prefix, true + } + } + return "", false +} + +func (bot *Bot[T]) parseCommand(text string) (prefix, cmd, args string) { + if prefix, hasPrefix := bot.checkPrefixes(text); hasPrefix { + text = strings.TrimSpace(text[len(prefix):]) + spaceIndex := strings.Index(text, " ") + var cmd string + var args string + if spaceIndex == -1 { + cmd = text + args = "" + } else { + cmd = text[:spaceIndex] + args = strings.TrimSpace(text[spaceIndex:]) + } + return prefix, cmd, args + } + return "", "", "" +} diff --git a/plugins.go b/plugins.go index 53f8b76..14ff52f 100644 --- a/plugins.go +++ b/plugins.go @@ -231,6 +231,12 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { return p } +// UsePolicy registers a Policy as plugin middleware for all plugin handlers. +func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] { + mw := RequirePolicy(name, policy) + return p.AddMiddleware(mw) +} + // NewScene creates, registers, and returns a new scene owned by the plugin. func (p *Plugin[T]) NewScene(name string) *Scene[T] { scene := NewScene[T](name) diff --git a/policy.go b/policy.go new file mode 100644 index 0000000..30d0a59 --- /dev/null +++ b/policy.go @@ -0,0 +1,210 @@ +package laniakea + +import ( + "errors" + "fmt" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +// Policy defines a reusable authorization rule for the current update context. +type Policy[T AppData] func(ctx *MsgContext, data T) error + +// RequirePolicy adapts a Policy into a blocking middleware. +func RequirePolicy[T AppData](name string, p Policy[T]) Middleware[T] { + return NewMiddleware(name, func(ctx *MsgContext, data T) bool { + if err := p(ctx, data); err != nil { + ctx.error(err) + return false + } + return true + }) +} + +// AllPolicies composes policies that all must succeed. +func AllPolicies[T AppData](policies ...Policy[T]) Policy[T] { + return func(ctx *MsgContext, data T) error { + for _, p := range policies { + if err := p(ctx, data); err != nil { + return err + } + } + return nil + } +} + +// AnyPolicy composes policies where at least one must succeed. +func AnyPolicy[T AppData](policies ...Policy[T]) Policy[T] { + return func(ctx *MsgContext, data T) error { + var firstDeny error + var internalErr error + for _, p := range policies { + err := p(ctx, data) + if err == nil { + return nil + } + if IsInternalError(err) { + if internalErr == nil { + internalErr = err + } + continue + } + if firstDeny == nil { + firstDeny = err + } + } + if internalErr != nil { + return internalErr + } + if firstDeny != nil { + return firstDeny + } + return AsUserError(errors.New("no policy matched")) + } +} + +// NotPolicy inverts a policy deny result while preserving internal failures. +func NotPolicy[T AppData](policy Policy[T]) Policy[T] { + return func(ctx *MsgContext, data T) error { + var err error + if err = policy(ctx, data); err == nil { + return AsUserError(errors.New("the action is not allowed due to policy violation")) + } + if IsInternalError(err) { + return err + } + return nil + } +} + +// RequirePrivateChat allows execution only in private chats. +func RequirePrivateChat[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.Msg == nil || ctx.Msg.Chat == nil { + return AsInternalError(errors.New("private-chat policy requires message chat context")) + } + + if ctx.Msg.Chat.Type != tgapi.ChatTypePrivate { + return AsUserError(errors.New("this action is only available in private chat")) + } + + return nil + } +} + +// RequireGroupChat allows execution only in group or supergroup chats. +func RequireGroupChat[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.Msg == nil || ctx.Msg.Chat == nil { + return AsInternalError(errors.New("group-chat policy requires message chat context")) + } + + if ctx.Msg.Chat.Type != tgapi.ChatTypeGroup && ctx.Msg.Chat.Type != tgapi.ChatTypeSupergroup { + return AsUserError(errors.New("this action is only available in group chats")) + } + + return nil + } +} + +// RequireSupergroupChat allows execution only in supergroup chats. +func RequireSupergroupChat[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.Msg == nil || ctx.Msg.Chat == nil { + return AsInternalError(errors.New("supergroup-chat policy requires message chat context")) + } + + if ctx.Msg.Chat.Type != tgapi.ChatTypeSupergroup { + return AsUserError(errors.New("this action is only available in supergroup chats")) + } + + return nil + } +} + +// RequireChatAdmin allows execution only for chat administrators or owners. +func RequireChatAdmin[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.FromID == 0 || ctx.ChatID == 0 { + return AsInternalError(errors.New("chat-admin policy requires message chat context")) + } + + member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{ + ChatID: ctx.ChatID, + UserID: ctx.FromID, + }) + if err != nil { + return AsInternalError(fmt.Errorf("failed to fetch chat member status: %w", err)) + } + + if member.Status != tgapi.ChatMemberStatusAdministrator && member.Status != tgapi.ChatMemberStatusOwner { + return AsUserError(errors.New("this action is only available to chat admins")) + } + + return nil + } +} + +// RequireChatCreator allows execution only for the chat owner. +func RequireChatCreator[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.FromID == 0 || ctx.ChatID == 0 { + return AsInternalError(errors.New("chat-creator policy requires message chat context")) + } + + member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{ + ChatID: ctx.ChatID, + UserID: ctx.FromID, + }) + if err != nil { + return AsInternalError(fmt.Errorf("failed to fetch chat creator: %w", err)) + } + + if member.Status != tgapi.ChatMemberStatusOwner { + return AsUserError(errors.New("this action is only available to the chat creator")) + } + + return nil + } +} + +// RequireBotAdmin allows execution only when the bot is an admin in the chat. +func RequireBotAdmin[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.ChatID == 0 { + return AsInternalError(errors.New("bot-admin policy requires message chat context")) + } + + bot, err := ctx.Api.GetMe() + if err != nil { + return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err)) + } + + member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{ + ChatID: ctx.ChatID, + UserID: bot.ID, + }) + if err != nil { + return AsInternalError(fmt.Errorf("failed to fetch bot member status: %w", err)) + } + + if member.Status != tgapi.ChatMemberStatusAdministrator && member.Status != tgapi.ChatMemberStatusOwner { + return AsUserError(errors.New("this action requires the bot to be an admin in the chat")) + } + + return nil + } +} + +// RequireCallbackFromUser allows execution only for callback queries sent by non-bot users. +func RequireCallbackFromUser[T AppData]() Policy[T] { + return func(ctx *MsgContext, data T) error { + if ctx.Update.CallbackQuery == nil { + return AsInternalError(errors.New("callback-user policy requires callback query context")) + } + if ctx.Update.CallbackQuery.From.IsBot { + return AsUserError(errors.New("this action is only available to human users")) + } + return nil + } +} diff --git a/policy_test.go b/policy_test.go new file mode 100644 index 0000000..6e32ca3 --- /dev/null +++ b/policy_test.go @@ -0,0 +1,229 @@ +package laniakea + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" +) + +func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { + var requests int + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, + Logger: slog.CreateLogger(), + errorTemplate: "Error: %s", + } + + mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error { + return AsUserError(errors.New("blocked")) + }) + + if mw.Execute(ctx, NoData{}) { + t.Fatal("expected denied policy middleware to stop execution") + } + if requests != 1 { + t.Fatalf("expected one user-facing error reply, got %d requests", requests) + } + if got := gotBody["text"]; got != "Error: blocked" { + t.Fatalf("unexpected policy error reply text: %v", got) + } +} + +func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) { + ctx := &MsgContext{ + Msg: &tgapi.Message{ + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, + }, + Logger: slog.CreateLogger(), + } + + if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil { + t.Fatalf("RequirePrivateChat returned error: %v", err) + } +} + +func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) { + ctx := &MsgContext{ + Msg: &tgapi.Message{ + Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup}, + }, + Logger: slog.CreateLogger(), + } + + err := RequirePrivateChat[NoData]()(ctx, NoData{}) + if err == nil { + t.Fatal("expected RequirePrivateChat to deny non-private chats") + } + if !IsUserError(err) { + t.Fatalf("expected user-visible deny error, got %v", err) + } +} + +func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { + var sawGetChatMember bool + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "getChatMember") { + t.Fatalf("unexpected API method: %s", req.URL.Path) + } + sawGetChatMember = true + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"ok":true,"result":{"status":"administrator","user":{"id":55,"is_bot":false,"first_name":"tester"}}}`, + )), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + Api: api, + ChatID: -2001, + FromID: 55, + Logger: slog.CreateLogger(), + } + + if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil { + t.Fatalf("RequireChatAdmin returned error: %v", err) + } + if !sawGetChatMember { + t.Fatal("expected GetChatMember to be called") + } + if got := gotBody["chat_id"]; got != float64(-2001) { + t.Fatalf("unexpected chat_id in request: %v", got) + } + if got := gotBody["user_id"]; got != float64(55) { + t.Fatalf("unexpected user_id in request: %v", got) + } +} + +func TestAllPoliciesReturnsFirstError(t *testing.T) { + want := AsUserError(errors.New("blocked")) + policy := AllPolicies[NoData]( + func(ctx *MsgContext, data NoData) error { return nil }, + func(ctx *MsgContext, data NoData) error { return want }, + func(ctx *MsgContext, data NoData) error { + t.Fatal("unexpected evaluation after first failure") + return nil + }, + ) + + err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + if !errors.Is(err, want) { + t.Fatalf("expected first policy error, got %v", err) + } +} + +func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { + policy := AnyPolicy[NoData]( + func(ctx *MsgContext, data NoData) error { return AsInternalError(errors.New("temporary")) }, + func(ctx *MsgContext, data NoData) error { return nil }, + ) + + if err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil { + t.Fatalf("expected later success to allow access, got %v", err) + } +} + +func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { + internal := AsInternalError(errors.New("temporary")) + policy := AnyPolicy[NoData]( + func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) }, + func(ctx *MsgContext, data NoData) error { return internal }, + ) + + err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + if !errors.Is(err, internal) { + t.Fatalf("expected internal error, got %v", err) + } +} + +func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) { + first := AsUserError(errors.New("first deny")) + policy := AnyPolicy[NoData]( + func(ctx *MsgContext, data NoData) error { return first }, + func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) }, + ) + + err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + if !errors.Is(err, first) { + t.Fatalf("expected first deny error, got %v", err) + } +} + +func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { + inverted := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error { + return AsUserError(errors.New("denied")) + }) + if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil { + t.Fatalf("expected inverted deny to succeed, got %v", err) + } + + internal := AsInternalError(errors.New("temporary")) + preserve := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error { + return internal + }) + err := preserve(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + if !errors.Is(err, internal) { + t.Fatalf("expected internal error to be preserved, got %v", err) + } +} diff --git a/scene_test.go b/scene_test.go index 71ae470..0137ba8 100644 --- a/scene_test.go +++ b/scene_test.go @@ -72,7 +72,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { } enterCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -86,7 +86,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { Message: &tgapi.Message{ MessageID: 7, Text: "hello there", - Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, From: &tgapi.User{ID: 42}, }, }) @@ -96,7 +96,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { } lookupCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, } if _, session, err := bot.findSceneSession(lookupCtx); err == nil && session.Scene != "" { @@ -129,7 +129,7 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { name: "missing from id for user chat scope", scope: SceneScopeUserChat, ctx: &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, }, }, } @@ -156,7 +156,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { bot.AddPlugins(plugin) ctx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -179,7 +179,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { bot.AddPlugins(plugin) ctx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -239,7 +239,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { bot.AddPlugins(plugin) enterCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -253,7 +253,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { Message: &tgapi.Message{ MessageID: 8, Text: "/cancel right now", - Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, From: &tgapi.User{ID: 42}, }, }) @@ -294,7 +294,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { bot.AddPlugins(plugin) enterCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -303,7 +303,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { } key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) if !ok { @@ -324,7 +324,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { Message: &tgapi.Message{ MessageID: 9, Text: "/ping", - Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, From: &tgapi.User{ID: 42}, }, }) @@ -371,7 +371,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { bot.AddPlugins(plugin) enterCtx := &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, } @@ -380,7 +380,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { } key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ - Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}}, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) if !ok { @@ -396,7 +396,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { Message: &tgapi.Message{ MessageID: 10, Text: "hello fallback", - Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, From: &tgapi.User{ID: 42}, }, }) diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index d8d263e..9dd6273 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -3,14 +3,14 @@ package tgapi // Chat represents a chat (private, group, supergroup, channel). // See https://core.telegram.org/bots/api#chat type Chat struct { - ID int64 `json:"id"` - Type string `json:"type"` - Title *string `json:"title,omitempty"` - Username *string `json:"username,omitempty"` - FirstName *string `json:"first_name,omitempty"` - LastName *string `json:"last_name,omitempty"` - IsForum *bool `json:"is_forum,omitempty"` - IsDirectMessages *bool `json:"is_direct_messages,omitempty"` + ID int64 `json:"id"` + Type ChatType `json:"type"` + Title *string `json:"title,omitempty"` + Username *string `json:"username,omitempty"` + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + IsForum *bool `json:"is_forum,omitempty"` + IsDirectMessages *bool `json:"is_direct_messages,omitempty"` } // ChatType represents the type of a chat. diff --git a/update_context.go b/update_context.go new file mode 100644 index 0000000..99eee95 --- /dev/null +++ b/update_context.go @@ -0,0 +1,179 @@ +package laniakea + +import "git.scuroneko.dev/scuroneko/laniakea/tgapi" + +func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { + for _, plugin := range bot.plugins { + handler, ok := plugin.handlers[u.Type] + if !ok { + continue + } + + pluginCtx := cloneMsgContext(ctx) + if plugin.logger != nil { + pluginCtx.Logger = plugin.logger + } + if !plugin.executeMiddlewares(pluginCtx, bot.appData) { + continue + } + if err := handler(pluginCtx, bot.appData); err != nil { + pluginCtx.error(err) + } + } +} + +func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { + var from *tgapi.User + var chat *tgapi.Chat + switch u.Type { + case tgapi.UpdateTypeMessage: + if u.Message != nil { + ctx.Msg = u.Message + if u.Message.Chat != nil { + chat = u.Message.Chat + } + if u.Message.From != nil { + from = u.Message.From + } + } + case tgapi.UpdateTypeEditedMessage: + if u.EditedMessage != nil { + ctx.Msg = u.EditedMessage + if u.EditedMessage.Chat != nil { + chat = u.EditedMessage.Chat + } + if u.EditedMessage.From != nil { + from = u.EditedMessage.From + } + } + case tgapi.UpdateTypeChannelPost: + if u.ChannelPost != nil { + ctx.Msg = u.ChannelPost + if u.ChannelPost.Chat != nil { + chat = u.ChannelPost.Chat + } + if u.ChannelPost.From != nil { + from = u.ChannelPost.From + } + } + case tgapi.UpdateTypeEditedChannelPost: + if u.EditedChannelPost != nil { + ctx.Msg = u.EditedChannelPost + if u.EditedChannelPost.Chat != nil { + chat = u.EditedChannelPost.Chat + } + if u.EditedChannelPost.From != nil { + from = u.EditedChannelPost.From + } + } + case tgapi.UpdateTypeBusinessMessage: + if u.BusinessMessage != nil { + ctx.Msg = u.BusinessMessage + if u.BusinessMessage.Chat != nil { + chat = u.BusinessMessage.Chat + } + if u.BusinessMessage.From != nil { + from = u.BusinessMessage.From + } + } + case tgapi.UpdateTypeEditedBusinessMessage: + if u.EditedBusinessMessage != nil { + ctx.Msg = u.EditedBusinessMessage + if u.EditedBusinessMessage.Chat != nil { + chat = u.EditedBusinessMessage.Chat + } + if u.EditedBusinessMessage.From != nil { + from = u.EditedBusinessMessage.From + } + } + case tgapi.UpdateTypeInlineQuery: + if u.InlineQuery != nil { + from = &u.InlineQuery.From + } + case tgapi.UpdateTypeChosenInlineResult: + if u.ChosenInlineResult != nil { + from = &u.ChosenInlineResult.From + } + case tgapi.UpdateTypeCallbackQuery: + if u.CallbackQuery != nil { + if u.CallbackQuery.Message != nil { + ctx.Msg = u.CallbackQuery.Message + ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID + if u.CallbackQuery.Message.Chat != nil { + chat = u.CallbackQuery.Message.Chat + } + } + if u.CallbackQuery.InlineMessageID != nil { + ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID + } + ctx.CallbackQueryId = u.CallbackQuery.ID + from = &u.CallbackQuery.From + } + case tgapi.UpdateTypeShippingQuery: + if u.ShippingQuery != nil { + from = &u.ShippingQuery.From + } + case tgapi.UpdateTypePreCheckoutQuery: + if u.PreCheckoutQuery != nil { + from = &u.PreCheckoutQuery.From + } + case tgapi.UpdateTypePurchasedPaidMedia: + if u.PurchasedPaidMedia != nil { + from = &u.PurchasedPaidMedia.From + } + case tgapi.UpdateTypeMyChatMember: + if u.MyChatMember != nil { + from = &u.MyChatMember.From + chat = &u.MyChatMember.Chat + } + case tgapi.UpdateTypeChatMember: + if u.ChatMember != nil { + from = &u.ChatMember.From + chat = &u.ChatMember.Chat + } + case tgapi.UpdateTypeChatJoinRequest: + if u.ChatJoinRequest != nil { + from = &u.ChatJoinRequest.From + chat = &u.ChatJoinRequest.Chat + + } + case tgapi.UpdateTypeBusinessConnection: + if u.BusinessConnection != nil { + from = &u.BusinessConnection.User + } + case tgapi.UpdateTypePollAnswer: + if u.PollAnswer != nil { + from = &u.PollAnswer.User + } + case tgapi.UpdateTypeMessageReaction: + if u.MessageReaction != nil { + from = u.MessageReaction.User + chat = u.MessageReaction.Chat + } + case tgapi.UpdateTypeChatBoost: + if u.ChatBoost != nil { + from = &u.ChatBoost.Boost.Source.User + chat = &u.ChatBoost.Chat + } + case tgapi.UpdateTypeRemovedChatBoost: + if u.RemovedChatBoost != nil { + from = &u.RemovedChatBoost.Source.User + chat = &u.RemovedChatBoost.Chat + } + } + if ctx.Msg != nil && from == nil { + from = ctx.Msg.From + } + if from != nil { + ctx.From = from + ctx.FromID = from.ID + } else { + ctx.FromID = 0 + } + if chat != nil { + ctx.Chat = chat + ctx.ChatID = chat.ID + } else { + ctx.ChatID = 0 + } +} -- 2.54.0 From a818174fbfed67b9fe4c7ccffc11f7201cf08ed8 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 31 Mar 2026 13:50:21 +0300 Subject: [PATCH 69/99] add observer runtime model Instrument command, payload, update, scene, runner, polling, and policy flows Add observer regression coverage and mark observability backlog item done --- CHANGELOG.md | 10 ++ TODO.md | 8 +- bot.go | 16 ++++ bot_config.go | 21 ++++ bot_test.go | 114 ++++++++++++++++++++++ handler.go | 51 +++++++++- handler_test.go | 237 ++++++++++++++++++++++++++++++++++++++++++++++ msg_context.go | 18 ++++ msg_handler.go | 128 ++++++++++++++++++++++--- observer.go | 183 +++++++++++++++++++++++++++++++++++ plugins.go | 28 ++---- policy.go | 14 +++ policy_test.go | 52 ++++++++++ runners.go | 40 +++++++- runners_test.go | 35 +++++++ scene_handler.go | 148 +++++++++++++++++++++++++---- scene_test.go | 164 ++++++++++++++++++++++++++++++++ update_context.go | 50 +++++++++- 18 files changed, 1255 insertions(+), 62 deletions(-) create mode 100644 observer.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c0006de..5f87692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ - `MsgContext` normalization now also carries `Chat` and `ChatID` for more Telegram update kinds, allowing policy and update handlers to rely on normalized chat identity outside message-only flows. - `MsgContext.Error(...)` and returned handler errors now suppress the automatic user reply when the error is explicitly marked with `AsInternalError(...)`, while keeping the previous user-visible default for unclassified errors. - Godoc, README examples, and regression-test naming now consistently describe the shared generic dependency model as app data, including `NoData` and `SetAppData(...)`. +- Observer configuration now treats `SetObserver(nil)` as clearing instrumentation instead of leaving the previous observer attached. +- Observer lifecycle events now cover generic update handlers and scene command, step, and message-fallback handlers with logical handler names and durations. +- `RequirePolicy(...)` now emits `PolicyCheckedEvent` for both passed and denied policy decisions. +- Scene command, step, and message-fallback flows now emit observer `ErrorEvent`s with scene-specific handler kinds and logical handler names. +- Scene transition observer events now use the same transition payload for scene command, step, and message-fallback flows. +- Observer error emission now also covers generic update handlers, callback payload decode failures, runner failures, and polling retries, including dedicated runner and polling handler kinds in `ErrorEvent`. +- `TODO.md` and the framework backlog pages now mark the observability model as completed for `v1.0.0-rc.13`. - `tgapi.Chat.Type` now uses the typed `tgapi.ChatType` enum in public DTOs and tests instead of raw string casts. ### Tests @@ -21,6 +28,9 @@ - Added table-driven update-contract coverage for `prepareUpdateCtx(...)`, including message-backed, callback-backed, user-backed, and no-user update kinds. - Added regression tests for policy middleware blocking, built-in private-chat policy decisions, normalized chat identity, and admin checks that use normalized `ChatID` and `FromID`. - Added regression tests for policy composition semantics, including all-of, any-of, and deny inversion with preserved internal failures. +- Added regression tests for `SetObserver(...)`, `GetObserver()`, and clearing the observer with `SetObserver(nil)`. +- Added observer regression tests for generic update-handler errors, callback payload decode failures, runner failure events, and polling retry emission. +- Added observer regression tests for update and scene handler lifecycle events and `PolicyCheckedEvent` emission. - Added regression tests proving that `edited_message` and `edited_channel_post` stay out of command routing and continue through generic update handlers. - Added callback-routing regression tests for both chat-message and inline-message callback targets, including `CallbackQueryId`, `CallbackMsgId`, `InlineMsgId`, and payload-argument guarantees. - Added regression tests for the new error-visibility model in both message and callback flows, including silent internal-only errors and explicit user-visible callback replies. diff --git a/TODO.md b/TODO.md index 4057971..ec11cf8 100644 --- a/TODO.md +++ b/TODO.md @@ -12,16 +12,16 @@ Russian page: Current priority split: -- `Priority 1`: observability model. - `Priority 2`: service layer and dependency graph model. - `Partial`: webhook runtime model, plugin composition contract. Completed former high-priority items: +- `[v1.0.0-rc.13] Observability model`: added first-class `Observer` events for update, command, payload, scene, policy, runner, polling, and centralized error flows, with safe event dispatch and regression coverage for the new runtime hooks. - `[v1.0.0-rc.13] Authorization and policy model`: added first-class `Policy[T]`, middleware integration through `RequirePolicy(...)`, plugin and bot policy registration helpers, built-in Telegram-aware policies, and composable `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` helpers with regression coverage. - `[v1.0.0-rc.13] Update schema contract`: documented and tested the normalized `MsgContext` update-routing contract, including routing categories and per-update field guarantees. - `[v1.0.0-rc.13] User-facing vs internal error model`: added explicit user-visible vs internal-only error markers and updated centralized handler error routing accordingly. - `[v1.0.0-rc.13] Configuration freeze model`: formalized bot configuration freeze after first run, documented lifecycle commit points, and added regression coverage for ignored late mutations. -- `1. Conversation / Scene Model`: completed in `v1.0.0-rc.12`. -- `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`. -- `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`. +- `[v1.0.0-rc.12] Conversation / Scene Model`. +- `[v1.0.0-rc.12] Typed Handler Input Model`. +- `[v1.0.0-rc.12] Request Context / Cancellation Model`. diff --git a/bot.go b/bot.go index a437b92..becf416 100644 --- a/bot.go +++ b/bot.go @@ -105,6 +105,7 @@ type Bot[T AppData] struct { uploader *tgapi.Uploader // File uploader l10n *L10n // Localization manager draftProvider *DraftProvider // Draft message builder + observer Observer // Optional event observer for instrumentation appData T // Injected application data hasAppData bool @@ -356,6 +357,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { close(bot.updateQueue) }() retryDelay := time.Duration(0) + retryCount := 0 for { select { case <-ctx.Done(): @@ -368,6 +370,19 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { } bot.logger.Errorln("failed to fetch updates:", err) retryDelay = nextPollRetryDelay(retryDelay) + retryCount++ + bot.safeEmitEvent(ctx, PollingRetryEvent{ + Attempt: retryCount, + Delay: retryDelay, + Err: err, + }) + bot.safeEmitEvent(ctx, ErrorEvent{ + Plugin: "bot", + HandlerKind: HandlerPollingKind, + HandlerName: "getUpdates", + Err: err, + UserFacing: false, + }) timer := time.NewTimer(retryDelay) select { case <-ctx.Done(): @@ -380,6 +395,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { continue } retryDelay = 0 + retryCount = 0 for _, update := range updates { u := update // copy loop variable to avoid race condition diff --git a/bot_config.go b/bot_config.go index a2d8cd1..2be3f4c 100644 --- a/bot_config.go +++ b/bot_config.go @@ -33,6 +33,27 @@ func (bot *Bot[T]) GetDraftProvider() *DraftProvider { return bot.draftProvider } +// SetObserver sets an event observer for instrumentation. +func (bot *Bot[T]) SetObserver(observer Observer) *Bot[T] { + if !bot.configMutable("SetObserver") { + return bot + } + if observer == nil { + if bot.logger != nil { + bot.logger.Warn("SetObserver called with nil observer; instrumentation will be disabled") + } + bot.observer = nil + return bot + } + bot.observer = observer + return bot +} + +// GetObserver returns the bot's event observer, or nil if no observer is set. +func (bot *Bot[T]) GetObserver() Observer { + return bot.observer +} + // SetSessionStore replaces the session store used for scene management. func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { if !bot.configMutable("SetSessionStore") { diff --git a/bot_test.go b/bot_test.go index 69f5832..e85c156 100644 --- a/bot_test.go +++ b/bot_test.go @@ -3,8 +3,11 @@ package laniakea import ( "context" "errors" + "io" + "net/http" "path/filepath" "reflect" + "strings" "testing" "time" @@ -12,6 +15,37 @@ import ( "git.scuroneko.dev/scuroneko/slog" ) +type pollingRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f pollingRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type pollingRetryObserver struct { + recordingObserver + cancel context.CancelFunc +} + +func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRetryEvent) { + o.recordingObserver.OnPollingRetry(ctx, ev) + if o.cancel != nil { + o.cancel() + } +} + +type testObserver struct{} + +func (testObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {} +func (testObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {} +func (testObserver) OnHandlerStarted(context.Context, HandlerStartedEvent) {} +func (testObserver) OnHandlerFinished(context.Context, HandlerFinishedEvent) { +} +func (testObserver) OnSceneTransition(context.Context, SceneTransitionEvent) {} +func (testObserver) OnPolicyChecked(context.Context, PolicyCheckedEvent) {} +func (testObserver) OnRunnerFinished(context.Context, RunnerFinishedEvent) {} +func (testObserver) OnPollingRetry(context.Context, PollingRetryEvent) {} +func (testObserver) OnError(context.Context, ErrorEvent) {} + func TestGetUpdateTypesReturnsCopy(t *testing.T) { bot := &Bot[NoData]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}} @@ -196,6 +230,34 @@ func TestSetAppDataMarksValueWarningOnce(t *testing.T) { } } +func TestSetObserverAndGetObserver(t *testing.T) { + bot := &Bot[NoData]{logger: slog.CreateLogger()} + observer := testObserver{} + + if got := bot.GetObserver(); got != nil { + t.Fatalf("expected nil observer by default, got %#v", got) + } + + bot.SetObserver(observer) + if got := bot.GetObserver(); got == nil { + t.Fatal("expected observer to be stored") + } +} + +func TestSetObserverNilClearsObserver(t *testing.T) { + bot := &Bot[NoData]{logger: slog.CreateLogger()} + bot.SetObserver(testObserver{}) + + if bot.GetObserver() == nil { + t.Fatal("expected observer to be set") + } + + bot.SetObserver(nil) + if got := bot.GetObserver(); got != nil { + t.Fatalf("expected nil observer after clearing, got %#v", got) + } +} + func TestRunWithContextRejectsSecondRun(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -216,6 +278,58 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) { } } +func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observer := &pollingRetryObserver{cancel: cancel} + + client := &http.Client{ + Transport: pollingRoundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":false,"error_code":500,"description":"boom"}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("http://example.invalid"). + SetHTTPClient(client), + ) + defer func() { + _ = api.Close() + }() + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + api: api, + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + observer: observer, + } + + if err := bot.RunWithContext(ctx); err != nil { + t.Fatalf("RunWithContext returned error: %v", err) + } + + if len(observer.retries) != 1 { + t.Fatalf("expected one polling retry event, got %d", len(observer.retries)) + } + if got := observer.retries[0]; got.Attempt != 1 || got.Delay <= 0 || got.Err == nil { + t.Fatalf("unexpected polling retry event: %#v", got) + } + if len(observer.errors) != 1 { + t.Fatalf("expected one polling error event, got %d", len(observer.errors)) + } + if got := observer.errors[0]; got.HandlerKind != HandlerPollingKind || got.HandlerName != "getUpdates" || got.Plugin != "bot" || got.Err == nil || got.UserFacing { + t.Fatalf("unexpected polling error event: %#v", got) + } +} + func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { type testDB struct{ Name string } diff --git a/handler.go b/handler.go index 89797e7..16046f2 100644 --- a/handler.go +++ b/handler.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) @@ -19,6 +20,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) } }() + startTime := time.Now() ctx, cancel := context.WithCancel(parentCtx) defer cancel() @@ -30,10 +32,17 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { l10n: bot.l10n, draftProvider: bot.draftProvider, sceneRuntime: bot, + observer: bot.observer, payloadType: bot.payloadType, ctx: ctx, } bot.prepareUpdateCtx(u, msgCtx) + bot.safeEmitEvent(ctx, UpdateReceivedEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + FromID: msgCtx.FromID, + ChatID: msgCtx.ChatID, + }) for _, middleware := range bot.middlewares { if !middleware.Execute(msgCtx, bot.appData) { @@ -44,20 +53,56 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { sceneHandled, err := bot.tryHandleScene(msgCtx) if err != nil { bot.logger.Errorln(err) + bot.safeEmitEvent(ctx, UpdateHandledEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + FromID: msgCtx.FromID, + ChatID: msgCtx.ChatID, + Duration: time.Since(startTime), + Handled: false, + }) + bot.safeEmitEvent(ctx, ErrorEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + Plugin: "bot", + HandlerKind: HandlerSceneKind, + HandlerName: "tryHandleScene", + FromID: msgCtx.FromID, + ChatID: msgCtx.ChatID, + Err: err, + UserFacing: false, + }) return } if sceneHandled { + bot.safeEmitEvent(ctx, UpdateHandledEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + FromID: msgCtx.FromID, + ChatID: msgCtx.ChatID, + Duration: time.Since(startTime), + Handled: true, + }) return } + handled := false switch u.Type { case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost: - bot.handleMessage(u, msgCtx) + handled = bot.handleMessage(u, msgCtx) case tgapi.UpdateTypeCallbackQuery: - bot.handleCallback(u, msgCtx) + handled = bot.handleCallback(u, msgCtx) default: - bot.handleUpdate(u, msgCtx) + handled = bot.handleUpdate(u, msgCtx) } + bot.safeEmitEvent(ctx, UpdateHandledEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + FromID: msgCtx.FromID, + ChatID: msgCtx.ChatID, + Duration: time.Since(startTime), + Handled: handled, + }) } func cloneMsgContext(src *MsgContext) *MsgContext { diff --git a/handler_test.go b/handler_test.go index 3b0674c..757f14d 100644 --- a/handler_test.go +++ b/handler_test.go @@ -2,6 +2,7 @@ package laniakea import ( "context" + "errors" "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" @@ -12,6 +13,37 @@ func ptr[T any](v T) *T { return &v } +type recordingObserver struct { + started []HandlerStartedEvent + finished []HandlerFinishedEvent + errors []ErrorEvent + policies []PolicyCheckedEvent + runners []RunnerFinishedEvent + retries []PollingRetryEvent +} + +func (*recordingObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {} +func (*recordingObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {} +func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) { + o.started = append(o.started, ev) +} +func (o *recordingObserver) OnHandlerFinished(_ context.Context, ev HandlerFinishedEvent) { + o.finished = append(o.finished, ev) +} +func (*recordingObserver) OnSceneTransition(context.Context, SceneTransitionEvent) {} +func (o *recordingObserver) OnPolicyChecked(_ context.Context, ev PolicyCheckedEvent) { + o.policies = append(o.policies, ev) +} +func (o *recordingObserver) OnRunnerFinished(_ context.Context, ev RunnerFinishedEvent) { + o.runners = append(o.runners, ev) +} +func (o *recordingObserver) OnPollingRetry(_ context.Context, ev PollingRetryEvent) { + o.retries = append(o.retries, ev) +} +func (o *recordingObserver) OnError(_ context.Context, ev ErrorEvent) { + o.errors = append(o.errors, ev) +} + func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { bot := &Bot[NoData]{prefixes: []string{"", "/"}} @@ -505,6 +537,57 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { } } +func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { + observer := &recordingObserver{} + plugin := NewPlugin[NoData]("test").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error { + return AsUserError(errors.New("update failed")) + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + plugins: []Plugin[NoData]{clonePlugin(plugin)}, + observer: observer, + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 4, + Type: tgapi.UpdateTypeInlineQuery, + InlineQuery: &tgapi.InlineQuery{ + ID: "iq", + From: tgapi.User{ID: 41}, + }, + }) + + if len(observer.errors) != 1 { + t.Fatalf("expected one observer error event, got %d", len(observer.errors)) + } + ev := observer.errors[0] + if ev.Plugin != "test" { + t.Fatalf("unexpected plugin: %q", ev.Plugin) + } + if ev.HandlerKind != HandlerUpdateKind { + t.Fatalf("unexpected handler kind: %q", ev.HandlerKind) + } + if ev.HandlerName != string(tgapi.UpdateTypeInlineQuery) { + t.Fatalf("unexpected handler name: %q", ev.HandlerName) + } + if !ev.UserFacing { + t.Fatal("expected update error to be marked user-facing") + } + if len(observer.started) != 1 { + t.Fatalf("expected one handler started event, got %d", len(observer.started)) + } + if got := observer.started[0]; got.HandlerKind != HandlerUpdateKind || got.HandlerName != string(tgapi.UpdateTypeInlineQuery) || got.Plugin != "test" { + t.Fatalf("unexpected started event: %#v", got) + } + if len(observer.finished) != 1 { + t.Fatalf("expected one handler finished event, got %d", len(observer.finished)) + } + if got := observer.finished[0]; got.HandlerKind != HandlerUpdateKind || got.HandlerName != string(tgapi.UpdateTypeInlineQuery) || got.Plugin != "test" || got.Err == nil || !got.UserFacing { + t.Fatalf("unexpected finished event: %#v", got) + } +} + func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") @@ -832,3 +915,157 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { t.Fatal("expected inline payload handler to be called") } } + +func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) { + observer := &recordingObserver{} + plugin := NewPlugin[NoData]("test") + plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + return nil + }, "approve") + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, + observer: observer, + } + + data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}}) + if err != nil { + t.Fatalf("encodeJsonPayload returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 32, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-observer", + Data: data, + From: tgapi.User{ID: 7}, + Message: &tgapi.Message{ + MessageID: 56, + Chat: &tgapi.Chat{ID: 78}, + }, + }, + }) + + if len(observer.started) != 1 { + t.Fatalf("expected one started event, got %d", len(observer.started)) + } + if got := observer.started[0]; got.HandlerKind != HandlerPayloadKind || got.HandlerName != "approve" || got.Plugin != "test" { + t.Fatalf("unexpected started event: %#v", got) + } + if len(observer.finished) != 1 { + t.Fatalf("expected one finished event, got %d", len(observer.finished)) + } + if got := observer.finished[0]; got.HandlerKind != HandlerPayloadKind || got.HandlerName != "approve" || got.Plugin != "test" || got.Err != nil || got.UserFacing { + t.Fatalf("unexpected finished event: %#v", got) + } + if len(observer.errors) != 0 { + t.Fatalf("did not expect error events, got %#v", observer.errors) + } +} + +func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { + observer := &recordingObserver{} + plugin := NewPlugin[NoData]("test") + wantErr := AsInternalError(errors.New("boom")) + plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + return wantErr + }, "approve") + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, + observer: observer, + } + + data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}}) + if err != nil { + t.Fatalf("encodeJsonPayload returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 33, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-observer-err", + Data: data, + From: tgapi.User{ID: 7}, + Message: &tgapi.Message{ + MessageID: 57, + Chat: &tgapi.Chat{ID: 79}, + }, + }, + }) + + if len(observer.started) != 1 { + t.Fatalf("expected one started event, got %d", len(observer.started)) + } + if len(observer.finished) != 1 { + t.Fatalf("expected one finished event, got %d", len(observer.finished)) + } + if got := observer.finished[0]; !errors.Is(got.Err, wantErr) || got.UserFacing { + t.Fatalf("unexpected finished event: %#v", got) + } + if len(observer.errors) != 1 { + t.Fatalf("expected one error event, got %d", len(observer.errors)) + } + if got := observer.errors[0]; !errors.Is(got.Err, wantErr) || got.HandlerKind != HandlerPayloadKind || got.HandlerName != "approve" || got.Plugin != "test" || got.UserFacing { + t.Fatalf("unexpected error event: %#v", got) + } +} + +func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { + observer := &recordingObserver{} + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + observer: observer, + } + + handled := bot.handleCallback(&tgapi.Update{ + UpdateID: 34, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-bad", + Data: "{not-json", + From: tgapi.User{ID: 7}, + }, + }, &MsgContext{ + Update: tgapi.Update{ + UpdateID: 34, + Type: tgapi.UpdateTypeCallbackQuery, + }, + Logger: bot.logger, + ctx: context.Background(), + CallbackQueryId: "cb-bad", + From: &tgapi.User{ID: 7}, + FromID: 7, + sceneRuntime: bot, + }) + + if handled { + t.Fatal("expected invalid callback payload to stay unhandled") + } + if len(observer.started) != 0 || len(observer.finished) != 0 { + t.Fatalf("expected no handler lifecycle events for decode failure, got started=%d finished=%d", len(observer.started), len(observer.finished)) + } + if len(observer.errors) != 1 { + t.Fatalf("expected one observer error event, got %d", len(observer.errors)) + } + ev := observer.errors[0] + if ev.Plugin != "bot" { + t.Fatalf("unexpected plugin: %q", ev.Plugin) + } + if ev.HandlerKind != HandlerPayloadKind { + t.Fatalf("unexpected handler kind: %q", ev.HandlerKind) + } + if ev.HandlerName != "decodePayload" { + t.Fatalf("unexpected handler name: %q", ev.HandlerName) + } + if ev.UserFacing { + t.Fatal("expected decode failure to stay internal") + } +} diff --git a/msg_context.go b/msg_context.go index b749013..9edb8b2 100644 --- a/msg_context.go +++ b/msg_context.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "reflect" "strconv" "strings" @@ -79,6 +80,7 @@ type MsgContext struct { draftProvider *DraftProvider payloadType BotPayloadType sceneRuntime sceneRuntime + observer Observer ctx context.Context } @@ -689,6 +691,22 @@ func (ctx *MsgContext) Context() context.Context { return ctx.ctx } +func (ctx *MsgContext) emitPolicyChecked(event PolicyCheckedEvent) { + if ctx == nil || ctx.observer == nil { + return + } + defer func() { + if r := recover(); r != nil { + if ctx.Logger != nil { + ctx.Logger.Errorln(fmt.Sprintf("panic in observer policy event: %v", r)) + return + } + log.Printf("panic in observer policy event: %v", r) + } + }() + ctx.observer.OnPolicyChecked(ctx.Context(), event) +} + // EnterScene enters the named scene at its configured entry step. func (ctx *MsgContext) EnterScene(name string) error { if ctx.sceneRuntime == nil { diff --git a/msg_handler.go b/msg_handler.go index 39dba5b..8d355d2 100644 --- a/msg_handler.go +++ b/msg_handler.go @@ -2,18 +2,19 @@ package laniakea import ( "strings" + "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { +func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { var msg *tgapi.Message if update.Message != nil { msg = update.Message } else if update.ChannelPost != nil { msg = update.ChannelPost } else { - return + return false } var text string @@ -22,12 +23,12 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { } else if len(msg.Caption) > 0 { text = msg.Caption } else { - return + return false } prefix, cmd, args := bot.parseCommand(text) if cmd == "" { - return + return false } ctx.Prefix = prefix @@ -37,10 +38,10 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname } } - // Ищем команду по точному совпадению for _, plugin := range bot.plugins { if _, exists := plugin.commands[cmd]; exists { + ctx.Text = args ctx.Args = strings.Fields(args) // Убирает лишние пробелы @@ -48,19 +49,75 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { ctx.Logger = plugin.logger } if !plugin.executeMiddlewares(ctx, bot.appData) { - return + return false } - plugin.executeCmd(cmd, ctx, bot.appData) - return + + startTime := time.Now() + bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerCommandKind, + HandlerName: cmd, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + }) + + err := plugin.executeCmd(cmd, ctx, bot.appData) + handlerEndEvent := HandlerFinishedEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerCommandKind, + HandlerName: cmd, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + Duration: time.Since(startTime), + } + + var errorEvent *ErrorEvent = nil + if err != nil { + ctx.error(err) + handlerEndEvent.Err = err + handlerEndEvent.UserFacing = IsUserError(err) + errorEvent = &ErrorEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerCommandKind, + HandlerName: cmd, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + Err: err, + UserFacing: handlerEndEvent.UserFacing, + } + } + bot.safeEmitEvent(ctx.Context(), handlerEndEvent) + if errorEvent != nil { + bot.safeEmitEvent(ctx.Context(), *errorEvent) + } + return true } } + return false } -func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { +func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool { data, err := bot.decodePayload(update.CallbackQuery.Data) if err != nil { bot.logger.Errorln(err) - return + bot.safeEmitEvent(ctx.Context(), ErrorEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: "bot", + HandlerKind: HandlerPayloadKind, + HandlerName: "decodePayload", + FromID: ctx.FromID, + ChatID: ctx.ChatID, + Err: err, + UserFacing: false, + }) + return false } ctx.Args = data.Args @@ -75,12 +132,57 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { if ctx.Logger == nil { ctx.Logger = bot.logger } + if !plugin.executeMiddlewares(ctx, bot.appData) { - return + return false } - plugin.executePayload(data.Command, ctx, bot.appData) - return + + startTime := time.Now() + bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerPayloadKind, + HandlerName: data.Command, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + }) + err := plugin.executePayload(data.Command, ctx, bot.appData) + + endEvent := HandlerFinishedEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerPayloadKind, + HandlerName: data.Command, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + Duration: time.Since(startTime), + } + var errorEvent *ErrorEvent = nil + if err != nil { + ctx.error(err) + errorEvent = &ErrorEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerPayloadKind, + HandlerName: data.Command, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + Err: err, + UserFacing: IsUserError(err), + } + endEvent.Err = err + endEvent.UserFacing = errorEvent.UserFacing + } + bot.safeEmitEvent(ctx.Context(), endEvent) + if errorEvent != nil { + bot.safeEmitEvent(ctx.Context(), *errorEvent) + } + return true } + return false } func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { diff --git a/observer.go b/observer.go new file mode 100644 index 0000000..65fd4e3 --- /dev/null +++ b/observer.go @@ -0,0 +1,183 @@ +package laniakea + +import ( + "context" + "fmt" + "time" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +// HandlerEventKind identifies the kind of handler observed by runtime events. +type HandlerEventKind string + +const ( + // HandlerCommandKind identifies a command handler. + HandlerCommandKind HandlerEventKind = "command" + // HandlerPayloadKind identifies a callback payload handler. + HandlerPayloadKind HandlerEventKind = "payload" + // HandlerUpdateKind identifies a generic update handler. + HandlerUpdateKind HandlerEventKind = "update" + // HandlerRunnerKind identifies a background runner execution. + HandlerRunnerKind HandlerEventKind = "runner" + // HandlerPollingKind identifies polling and getUpdates runtime work. + HandlerPollingKind HandlerEventKind = "polling" + // HandlerSceneKind identifies a scene runtime handler wrapper. + HandlerSceneKind HandlerEventKind = "scene" + // HandlerSceneStepKind identifies a scene step handler. + HandlerSceneStepKind HandlerEventKind = "scene_step" + // HandlerSceneCommandKind identifies a scene-local command handler. + HandlerSceneCommandKind HandlerEventKind = "scene_command" + // HandlerSceneMessageKind identifies a scene message fallback handler. + HandlerSceneMessageKind HandlerEventKind = "scene_message" +) + +type Event interface { + isEvent() +} + +// UpdateReceivedEvent describes an update entering the bot runtime. +type UpdateReceivedEvent struct { + UpdateID int + UpdateType tgapi.UpdateType + FromID int64 + ChatID int64 +} + +// UpdateHandledEvent describes a completed update execution path. +type UpdateHandledEvent struct { + UpdateID int + UpdateType tgapi.UpdateType + FromID int64 + ChatID int64 + Duration time.Duration + Handled bool +} + +// HandlerStartedEvent describes a handler about to execute. +type HandlerStartedEvent struct { + UpdateID int + UpdateType tgapi.UpdateType + Plugin string + HandlerKind HandlerEventKind + HandlerName string + FromID int64 + ChatID int64 +} + +// HandlerFinishedEvent describes a handler that has completed. +type HandlerFinishedEvent struct { + UpdateID int + UpdateType tgapi.UpdateType + Plugin string + HandlerKind HandlerEventKind + HandlerName string + FromID int64 + ChatID int64 + Duration time.Duration + Err error + UserFacing bool +} + +// SceneTransitionEvent describes a scene state transition. +type SceneTransitionEvent struct { + Plugin string + Scene string + From string + To string + Action SceneAction + FromID int64 + ChatID int64 +} + +// PolicyCheckedEvent describes the result of a policy evaluation. +type PolicyCheckedEvent struct { + Name string + Plugin string + FromID int64 + ChatID int64 + Passed bool + Err error + Internal bool +} + +// RunnerFinishedEvent describes a completed background runner execution. +type RunnerFinishedEvent struct { + Name string + Duration time.Duration + Err error +} + +// PollingRetryEvent describes a polling retry after a failed getUpdates call. +type PollingRetryEvent struct { + Attempt int + Delay time.Duration + Err error +} + +// ErrorEvent describes an error routed through framework error handling. +type ErrorEvent struct { + UpdateID int + UpdateType tgapi.UpdateType + Plugin string + HandlerKind HandlerEventKind + HandlerName string + FromID int64 + ChatID int64 + Err error + UserFacing bool +} + +func (UpdateReceivedEvent) isEvent() {} +func (UpdateHandledEvent) isEvent() {} +func (HandlerStartedEvent) isEvent() {} +func (HandlerFinishedEvent) isEvent() {} +func (SceneTransitionEvent) isEvent() {} +func (PolicyCheckedEvent) isEvent() {} +func (RunnerFinishedEvent) isEvent() {} +func (PollingRetryEvent) isEvent() {} +func (ErrorEvent) isEvent() {} + +// Observer receives best-effort runtime instrumentation events. +type Observer interface { + OnReceiveUpdate(ctx context.Context, event UpdateReceivedEvent) + OnHandledUpdate(ctx context.Context, event UpdateHandledEvent) + OnHandlerStarted(ctx context.Context, event HandlerStartedEvent) + OnHandlerFinished(ctx context.Context, event HandlerFinishedEvent) + OnSceneTransition(ctx context.Context, event SceneTransitionEvent) + OnPolicyChecked(ctx context.Context, event PolicyCheckedEvent) + OnRunnerFinished(ctx context.Context, event RunnerFinishedEvent) + OnPollingRetry(ctx context.Context, event PollingRetryEvent) + OnError(ctx context.Context, event ErrorEvent) +} + +func (bot *Bot[T]) safeEmitEvent(ctx context.Context, event Event) { + if bot.observer == nil { + return + } + defer func() { + if r := recover(); r != nil { + bot.logger.Errorln(fmt.Sprintf("panic in observer: %v", r)) + } + }() + switch e := event.(type) { + case UpdateReceivedEvent: + bot.observer.OnReceiveUpdate(ctx, e) + case UpdateHandledEvent: + bot.observer.OnHandledUpdate(ctx, e) + case HandlerStartedEvent: + bot.observer.OnHandlerStarted(ctx, e) + case HandlerFinishedEvent: + bot.observer.OnHandlerFinished(ctx, e) + case SceneTransitionEvent: + bot.observer.OnSceneTransition(ctx, e) + case PolicyCheckedEvent: + bot.observer.OnPolicyChecked(ctx, e) + case RunnerFinishedEvent: + bot.observer.OnRunnerFinished(ctx, e) + case PollingRetryEvent: + bot.observer.OnPollingRetry(ctx, e) + case ErrorEvent: + bot.observer.OnError(ctx, e) + } +} diff --git a/plugins.go b/plugins.go index 14ff52f..f988580 100644 --- a/plugins.go +++ b/plugins.go @@ -322,55 +322,47 @@ func (p *Plugin[T]) Close() error { } // Internal helper that validates and executes a command handler. -func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) { +func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) error { command, exists := p.commands[cmd] if !exists { - ctx.error(AsInternalError(errCommandNotFound)) - return + return AsInternalError(errCommandNotFound) } if err := command.validateArgs(ctx.Args); err != nil { - ctx.error(err) - return + return AsUserError(err) } // Run command-specific middlewares for _, m := range command.middlewares { if !m.Execute(ctx, db) { - return + return AsInternalError(errors.New("middleware blocked call")) } } // Execute command - if err := command.exec(ctx, db); err != nil { - ctx.error(err) - } + return command.exec(ctx, db) } // Internal helper that validates and executes a payload handler. -func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) { +func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) error { command, exists := p.payloads[payload] if !exists { - ctx.error(AsInternalError(errPayloadNotFound)) - return + return AsInternalError(errPayloadNotFound) } if err := command.validateArgs(ctx.Args); err != nil { - ctx.error(err) - return + return AsUserError(err) } // Run command-specific middlewares for _, m := range command.middlewares { if !m.Execute(ctx, db) { - return + return AsInternalError(errors.New("middleware blocked call")) } } // Execute payload - if err := command.exec(ctx, db); err != nil { - ctx.error(err) - } + return command.exec(ctx, db) } // Internal helper that runs plugin middlewares in order. diff --git a/policy.go b/policy.go index 30d0a59..d93fe9d 100644 --- a/policy.go +++ b/policy.go @@ -14,9 +14,23 @@ type Policy[T AppData] func(ctx *MsgContext, data T) error func RequirePolicy[T AppData](name string, p Policy[T]) Middleware[T] { return NewMiddleware(name, func(ctx *MsgContext, data T) bool { if err := p(ctx, data); err != nil { + ctx.emitPolicyChecked(PolicyCheckedEvent{ + Name: name, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + Passed: false, + Err: err, + Internal: IsInternalError(err), + }) ctx.error(err) return false } + ctx.emitPolicyChecked(PolicyCheckedEvent{ + Name: name, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + Passed: true, + }) return true }) } diff --git a/policy_test.go b/policy_test.go index 6e32ca3..a7d6fd8 100644 --- a/policy_test.go +++ b/policy_test.go @@ -1,6 +1,7 @@ package laniakea import ( + "context" "encoding/json" "errors" "io" @@ -227,3 +228,54 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { t.Fatalf("expected internal error to be preserved, got %v", err) } } + +func TestRequirePolicyEmitsObserverEvents(t *testing.T) { + t.Run("allow", func(t *testing.T) { + observer := &recordingObserver{} + ctx := &MsgContext{ + Logger: slog.CreateLogger(), + ctx: context.Background(), + observer: observer, + FromID: 10, + ChatID: 20, + } + + mw := RequirePolicy[NoData]("allow", func(ctx *MsgContext, data NoData) error { + return nil + }) + + if !mw.Execute(ctx, NoData{}) { + t.Fatal("expected allowed policy middleware to continue execution") + } + if len(observer.policies) != 1 { + t.Fatalf("expected one policy event, got %d", len(observer.policies)) + } + if got := observer.policies[0]; got.Name != "allow" || !got.Passed || got.Err != nil || got.Internal { + t.Fatalf("unexpected policy event: %#v", got) + } + }) + + t.Run("deny", func(t *testing.T) { + observer := &recordingObserver{} + ctx := &MsgContext{ + Logger: slog.CreateLogger(), + ctx: context.Background(), + observer: observer, + errorTemplate: "%s", + } + + mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error { + return AsInternalError(errors.New("blocked")) + }) + + if mw.Execute(ctx, NoData{}) { + t.Fatal("expected denied policy middleware to stop execution") + } + if len(observer.policies) != 1 { + t.Fatalf("expected one policy event, got %d", len(observer.policies)) + } + if got := observer.policies[0]; got.Name != "deny" || got.Passed || got.Err == nil || !got.Internal { + t.Fatalf("unexpected policy event: %#v", got) + } + }) +} diff --git a/runners.go b/runners.go index 11c1d1d..7d50ce5 100644 --- a/runners.go +++ b/runners.go @@ -107,8 +107,21 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.runnerOnceWG.Add(1) go func(r Runner[T]) { defer bot.runnerOnceWG.Done() + startedAt := time.Now() err := r.fn(bot) + bot.safeEmitEvent(ctx, RunnerFinishedEvent{ + Name: r.name, + Duration: time.Since(startedAt), + Err: err, + }) if err != nil { + bot.safeEmitEvent(ctx, ErrorEvent{ + Plugin: "bot", + HandlerKind: HandlerRunnerKind, + HandlerName: r.name, + Err: err, + UserFacing: false, + }) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } }(runner) @@ -116,10 +129,22 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { // One-time sync: block until done t := time.Now() err := runner.fn(bot) + elapsed := time.Since(t) + bot.safeEmitEvent(ctx, RunnerFinishedEvent{ + Name: runner.name, + Duration: elapsed, + Err: err, + }) if err != nil { + bot.safeEmitEvent(ctx, ErrorEvent{ + Plugin: "bot", + HandlerKind: HandlerRunnerKind, + HandlerName: runner.name, + Err: err, + UserFacing: false, + }) bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) } - elapsed := time.Since(t) if elapsed > time.Second*2 { bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) } @@ -135,8 +160,21 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + startedAt := time.Now() err := r.fn(bot) + bot.safeEmitEvent(ctx, RunnerFinishedEvent{ + Name: r.name, + Duration: time.Since(startedAt), + Err: err, + }) if err != nil { + bot.safeEmitEvent(ctx, ErrorEvent{ + Plugin: "bot", + HandlerKind: HandlerRunnerKind, + HandlerName: r.name, + Err: err, + UserFacing: false, + }) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } } diff --git a/runners_test.go b/runners_test.go index 5223203..cbeefd4 100644 --- a/runners_test.go +++ b/runners_test.go @@ -2,6 +2,7 @@ package laniakea import ( "context" + "errors" "sync/atomic" "testing" "time" @@ -9,6 +10,10 @@ import ( "git.scuroneko.dev/scuroneko/slog" ) +type runnerObserver struct { + recordingObserver +} + func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { var calls atomic.Int32 bot := &Bot[NoData]{ @@ -60,3 +65,33 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) { t.Fatal("expected background runner to be called at least once") } } + +func TestExecRunnersEmitObserverEvents(t *testing.T) { + observer := &runnerObserver{} + wantErr := errors.New("runner failed") + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + observer: observer, + runners: []Runner[NoData]{ + NewRunner("sync-once", func(*Bot[NoData]) error { + return wantErr + }).Onetime(true).Async(false), + }, + } + + bot.ExecRunners(context.Background()) + + if len(observer.runners) != 1 { + t.Fatalf("expected one runner-finished event, got %d", len(observer.runners)) + } + if got := observer.runners[0]; got.Name != "sync-once" || !errors.Is(got.Err, wantErr) { + t.Fatalf("unexpected runner-finished event: %#v", got) + } + if len(observer.errors) != 1 { + t.Fatalf("expected one error event, got %d", len(observer.errors)) + } + if got := observer.errors[0]; got.HandlerKind != HandlerRunnerKind || got.HandlerName != "sync-once" || !errors.Is(got.Err, wantErr) { + t.Fatalf("unexpected runner error event: %#v", got) + } +} diff --git a/scene_handler.go b/scene_handler.go index 9ddcc81..12c6f00 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strings" + "time" ) func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { @@ -34,12 +35,13 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { sess: session, key: key, } - return bot.executeScene(scene, sceneCtx) + + return bot.executeScene(sceneCtx, scene) } return false, ErrSceneNotFound } -func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error) { +func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error) { if ctx.MsgContext == nil || ctx.sess.Scene == "" { return false, nil } @@ -59,37 +61,143 @@ func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error ctx.Text = args ctx.Args = strings.Fields(args) - res, matched, err := scene.executeCommand(cmd, ctx, bot.appData) - if err != nil { - return false, err - } - if matched { - return bot.applySceneResult(scene, ctx, res) + if _, ok := scene.commands[cmd]; ok { + startTime := time.Now() + bot.emitSceneStarted(ctx, scene, HandlerSceneCommandKind, cmd) + res, _, err := scene.executeCommand(cmd, ctx, bot.appData) + if err != nil { + bot.emitSceneFinished(ctx, scene, HandlerSceneCommandKind, cmd, startTime, err) + bot.emitSceneError(ctx, scene, HandlerSceneCommandKind, cmd, err) + return false, err + } + from := ctx.sess.Step + ok, err := bot.applySceneResult(scene, ctx, res) + bot.emitSceneFinished(ctx, scene, HandlerSceneCommandKind, cmd, startTime, err) + if err != nil { + bot.emitSceneError(ctx, scene, HandlerSceneCommandKind, cmd, err) + } + if ok { + bot.emitSceneTransition(ctx, scene, from, res) + } + return ok, err } } ctx.Text = text ctx.Args = nil ctx.Prefix = "" if ctx.sess.Step != "" { - res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.appData) - if err != nil { - return false, err - } - if matched { - return bot.applySceneResult(scene, ctx, res) + step := ctx.sess.Step + if _, ok := scene.steps[step]; ok { + startTime := time.Now() + bot.emitSceneStarted(ctx, scene, HandlerSceneStepKind, step) + res, _, err := scene.executeStep(step, ctx, bot.appData) + if err != nil { + bot.emitSceneFinished(ctx, scene, HandlerSceneStepKind, step, startTime, err) + bot.emitSceneError(ctx, scene, HandlerSceneStepKind, step, err) + return false, err + } + from := step + ok, err := bot.applySceneResult(scene, ctx, res) + bot.emitSceneFinished(ctx, scene, HandlerSceneStepKind, step, startTime, err) + if err != nil { + bot.emitSceneError(ctx, scene, HandlerSceneStepKind, from, err) + } + if ok { + bot.emitSceneTransition(ctx, scene, from, res) + } + return ok, err } } - res, matched, err := scene.executeMessage(ctx, bot.appData) - if err != nil { - return false, err - } - if matched { - return bot.applySceneResult(scene, ctx, res) + if scene.message != nil { + startTime := time.Now() + bot.emitSceneStarted(ctx, scene, HandlerSceneMessageKind, "message_fallback") + res, _, err := scene.executeMessage(ctx, bot.appData) + if err != nil { + bot.emitSceneFinished(ctx, scene, HandlerSceneMessageKind, "message_fallback", startTime, err) + bot.emitSceneError(ctx, scene, HandlerSceneMessageKind, "message_fallback", err) + return false, err + } + from := ctx.sess.Step + ok, err := bot.applySceneResult(scene, ctx, res) + bot.emitSceneFinished(ctx, scene, HandlerSceneMessageKind, "message_fallback", startTime, err) + if err != nil { + bot.emitSceneError(ctx, scene, HandlerSceneMessageKind, "message_fallback", err) + } + if ok { + bot.emitSceneTransition(ctx, scene, from, res) + } + return ok, err } return false, nil } + +func (bot *Bot[T]) emitSceneStarted(ctx *SceneContext, scene *Scene[T], kind HandlerEventKind, name string) { + bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{ + UpdateID: ctx.Update.UpdateID, + UpdateType: ctx.Update.Type, + Plugin: scene.PluginName, + HandlerKind: kind, + HandlerName: name, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + }) +} + +func (bot *Bot[T]) emitSceneFinished(ctx *SceneContext, scene *Scene[T], kind HandlerEventKind, name string, startedAt time.Time, err error) { + bot.safeEmitEvent(ctx.Context(), HandlerFinishedEvent{ + UpdateID: ctx.Update.UpdateID, + UpdateType: ctx.Update.Type, + Plugin: scene.PluginName, + HandlerKind: kind, + HandlerName: name, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + Duration: time.Since(startedAt), + Err: err, + UserFacing: IsUserError(err), + }) +} + +func (bot *Bot[T]) emitSceneError(ctx *SceneContext, scene *Scene[T], kind HandlerEventKind, name string, err error) { + bot.safeEmitEvent(ctx.Context(), ErrorEvent{ + UpdateID: ctx.Update.UpdateID, + UpdateType: ctx.Update.Type, + Plugin: scene.PluginName, + HandlerKind: kind, + HandlerName: name, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + Err: err, + UserFacing: IsUserError(err), + }) +} + +func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from string, result SceneResult) { + if result.Action == SceneActionPass { + return + } + + to := from + switch result.Action { + case SceneActionNext: + to = result.Next + case SceneActionExit: + to = "" + } + + bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{ + Plugin: scene.PluginName, + Scene: scene.Name, + From: from, + To: to, + Action: result.Action, + FromID: ctx.FromID, + ChatID: ctx.ChatID, + }) +} + func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result SceneResult) (bool, error) { switch result.Action { case SceneActionStay: diff --git a/scene_test.go b/scene_test.go index 0137ba8..ea9903b 100644 --- a/scene_test.go +++ b/scene_test.go @@ -266,6 +266,170 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { } } +func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { + observer := &recordingObserver{} + plugin := NewPlugin[NoData]("wizard") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { + return ctx.Stay(), nil + }). + OnCommand("cancel", func(ctx *SceneContext, db NoData) (SceneResult, error) { + return ctx.Exit(), nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + observer: observer, + } + bot.AddPlugins(plugin) + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 22, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 9, + Text: "/cancel", + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, + From: &tgapi.User{ID: 42}, + }, + }) + + if len(observer.started) != 1 { + t.Fatalf("expected one scene started event, got %d", len(observer.started)) + } + if got := observer.started[0]; got.HandlerKind != HandlerSceneCommandKind || got.HandlerName != "cancel" || got.Plugin != "wizard" { + t.Fatalf("unexpected scene started event: %#v", got) + } + if len(observer.finished) != 1 { + t.Fatalf("expected one scene finished event, got %d", len(observer.finished)) + } + if got := observer.finished[0]; got.HandlerKind != HandlerSceneCommandKind || got.HandlerName != "cancel" || got.Plugin != "wizard" || got.Err != nil { + t.Fatalf("unexpected scene finished event: %#v", got) + } +} + +func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { + observer := &recordingObserver{} + plugin := NewPlugin[NoData]("wizard") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { + return ctx.Stay(), nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + observer: observer, + } + bot.AddPlugins(plugin) + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 23, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 10, + Text: "hello there", + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, + From: &tgapi.User{ID: 42}, + }, + }) + + if len(observer.started) != 1 { + t.Fatalf("expected one scene started event, got %d", len(observer.started)) + } + if got := observer.started[0]; got.HandlerKind != HandlerSceneStepKind || got.HandlerName != "start" || got.Plugin != "wizard" { + t.Fatalf("unexpected scene step started event: %#v", got) + } + if len(observer.finished) != 1 { + t.Fatalf("expected one scene finished event, got %d", len(observer.finished)) + } + if got := observer.finished[0]; got.HandlerKind != HandlerSceneStepKind || got.HandlerName != "start" || got.Plugin != "wizard" || got.Err != nil { + t.Fatalf("unexpected scene step finished event: %#v", got) + } +} + +func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { + observer := &recordingObserver{} + plugin := NewPlugin[NoData]("wizard") + scene := plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { + return ctx.Stay(), nil + }). + OnMessage(func(ctx *SceneContext, db NoData) (SceneResult, error) { + return ctx.Exit(), nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + observer: observer, + } + bot.AddPlugins(plugin) + + key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, + FromID: 42, + }) + if !ok { + t.Fatal("expected scene key to be built") + } + if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.Name}); err != nil { + t.Fatalf("failed to seed scene session: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 24, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 11, + Text: "hello there", + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, + From: &tgapi.User{ID: 42}, + }, + }) + + if len(observer.started) != 1 { + t.Fatalf("expected one scene started event, got %d", len(observer.started)) + } + if got := observer.started[0]; got.HandlerKind != HandlerSceneMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "wizard" { + t.Fatalf("unexpected scene message started event: %#v", got) + } + if len(observer.finished) != 1 { + t.Fatalf("expected one scene finished event, got %d", len(observer.finished)) + } + if got := observer.finished[0]; got.HandlerKind != HandlerSceneMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "wizard" || got.Err != nil { + t.Fatalf("unexpected scene message finished event: %#v", got) + } +} + func TestScenePassDoesNotPersistSessionData(t *testing.T) { commandCalled := false diff --git a/update_context.go b/update_context.go index 99eee95..150e7ac 100644 --- a/update_context.go +++ b/update_context.go @@ -1,8 +1,13 @@ package laniakea -import "git.scuroneko.dev/scuroneko/laniakea/tgapi" +import ( + "time" -func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) bool { + handled := false for _, plugin := range bot.plugins { handler, ok := plugin.handlers[u.Type] if !ok { @@ -16,10 +21,49 @@ func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) { if !plugin.executeMiddlewares(pluginCtx, bot.appData) { continue } - if err := handler(pluginCtx, bot.appData); err != nil { + startTime := time.Now() + bot.safeEmitEvent(pluginCtx.Context(), HandlerStartedEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + Plugin: plugin.name, + HandlerKind: HandlerUpdateKind, + HandlerName: string(u.Type), + FromID: pluginCtx.FromID, + ChatID: pluginCtx.ChatID, + }) + err := handler(pluginCtx, bot.appData) + endEvent := HandlerFinishedEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + Plugin: plugin.name, + HandlerKind: HandlerUpdateKind, + HandlerName: string(u.Type), + FromID: pluginCtx.FromID, + ChatID: pluginCtx.ChatID, + Duration: time.Since(startTime), + } + if err != nil { + endEvent.Err = err + endEvent.UserFacing = IsUserError(err) + } + bot.safeEmitEvent(pluginCtx.Context(), endEvent) + if err != nil { + bot.safeEmitEvent(pluginCtx.Context(), ErrorEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + Plugin: plugin.name, + HandlerKind: HandlerUpdateKind, + HandlerName: string(u.Type), + FromID: pluginCtx.FromID, + ChatID: pluginCtx.ChatID, + Err: err, + UserFacing: IsUserError(err), + }) pluginCtx.error(err) } + handled = true } + return handled } func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { -- 2.54.0 From ba25dab6b15903f9280218a725d17e53811acb78 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 31 Mar 2026 16:57:08 +0300 Subject: [PATCH 70/99] Refine backlog policy and runtime guards Move speculative framework items into Ideas and bump rc.14 Guard tgapi API/uploader setup and nil plugin registrations Add missing observer Event godoc --- AGENTS.md | 7 +++++-- CHANGELOG.md | 5 +++++ TODO.md | 4 ++-- observer.go | 1 + plugins.go | 14 +++++++++++++- tgapi/api.go | 5 +++++ tgapi/uploader_api.go | 5 +++++ utils/version.go | 4 ++-- 8 files changed, 38 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ade1677..6c3eb99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,9 @@ Review the codebase with focus on: - Treat the wiki as the primary place for large design ideas, architectural drafts, and framework backlog notes. - If the agent identifies a substantial new concept or design direction, such as scenes, callback agents, a webhook model, or another framework-level abstraction, the agent must ask the user whether it should also formalize that idea as a draft wiki page. - When the user agrees, prefer paired wiki pages such as `Page.md` and `Page-RU.md`, and clearly mark draft design pages with `DRAFT` when the API is not implemented or not yet stable. -- Keep `TODO.md`, the wiki backlog pages, and `CHANGELOG.md` aligned when framework-level items move between planned and completed states. +- Keep `TODO.md`, the wiki backlog pages, and `CHANGELOG.md` aligned when framework-level items move between planned and completed states in the main repository. +- Wiki-only edits must never be added to `CHANGELOG.md`. +- `AGENTS.md`-only edits must never be added to `CHANGELOG.md`. ## Go review expectations Check for: @@ -90,7 +92,8 @@ Prefer the repository’s documented commands. If multiple choices exist, use th ## Versioning and changelog - After every code or documentation change in the main repository, update `CHANGELOG.md`. -- Changes made only inside the `.wiki/` repository do not require a `CHANGELOG.md` update. +- Changes made only inside the `.wiki/` repository must not be added to `CHANGELOG.md`. +- Changes made only in `AGENTS.md` must not be added to `CHANGELOG.md`. - Add changes only to the section for the next version after the latest published git tag. - The agent must check the latest published tag, `CHANGELOG.md`, and `utils/version.go` before editing the changelog. - The agent must verify that the target changelog version matches the version declared in `utils/version.go`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f87692..e24150d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v1.0.0-rc.14 + +### Changed +- Added missing godoc for the exported observer `Event` marker interface. + ## v1.0.0-rc.13 ### Added diff --git a/TODO.md b/TODO.md index ec11cf8..44cd369 100644 --- a/TODO.md +++ b/TODO.md @@ -12,8 +12,8 @@ Russian page: Current priority split: -- `Priority 2`: service layer and dependency graph model. -- `Partial`: webhook runtime model, plugin composition contract. +- `Partial`: webhook runtime model. +- `Ideas`: service layer and dependency graph model, plugin composition contract. Completed former high-priority items: diff --git a/observer.go b/observer.go index 65fd4e3..d5df215 100644 --- a/observer.go +++ b/observer.go @@ -32,6 +32,7 @@ const ( HandlerSceneMessageKind HandlerEventKind = "scene_message" ) +// Event is the marker interface implemented by all observer runtime events. type Event interface { isEvent() } diff --git a/plugins.go b/plugins.go index f988580..e61a223 100644 --- a/plugins.go +++ b/plugins.go @@ -18,7 +18,7 @@ const ( CommandValueStringType CommandValueType = "string" // CommandValueIntType expects a decimal integer (digits only). CommandValueIntType CommandValueType = "int" - // CommandValueBoolType is reserved for future use (not implemented). + // CommandValueBoolType expects a exact "true" or "false". CommandValueBoolType CommandValueType = "bool" // CommandValueAnyType accepts any input without validation. CommandValueAnyType CommandValueType = "any" @@ -193,6 +193,12 @@ func NewPlugin[T AppData](name string) *Plugin[T] { // AddCommand registers a command in the plugin. // The command's .command field is used as the key. func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { + if command == nil { + if p.logger != nil { + p.logger.Warnln("trying to add nil command") + } + return p + } p.commands[command.command] = command return p } @@ -208,6 +214,12 @@ func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ... // AddPayload registers a payload (e.g., callback query data) in the plugin. // Payloads are triggered by inline button callback_data, not by message text. func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { + if command == nil { + if p.logger != nil { + p.logger.Warnln("trying to add nil command") + } + return p + } p.payloads[command.command] = command return p } diff --git a/tgapi/api.go b/tgapi/api.go index e5ec1e9..79ceddd 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -98,6 +98,11 @@ type API struct { // Always call Close() when done to release resources. func NewAPI(opts *APIOpts) *API { l := utils.CreateLogger("API", utils.GetLoggerLevel()) + if opts == nil { + l.Errorln("Set API options") + _ = l.Close() + return nil + } client := opts.client if client == nil { diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 5012160..9f812c1 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -71,6 +71,11 @@ type Uploader struct { // NewUploader creates a multipart uploader bound to an API client. func NewUploader(api *API) *Uploader { logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel()) + if api == nil { + logger.Errorln("api is nil") + _ = logger.Close() + return nil + } return &Uploader{api, logger} } diff --git a/utils/version.go b/utils/version.go index 40e5010..22bdb51 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,7 +2,7 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0-rc.13" + VersionString = "1.0.0-rc.14" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. @@ -10,5 +10,5 @@ const ( // VersionPatch is the module patch version. VersionPatch = 0 // VersionBeta is the prerelease counter for the current version. - VersionBeta = 13 + VersionBeta = 14 ) -- 2.54.0 From d55f58c092eb6a32f8bbf8371d0cec19863328ee Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 1 Apr 2026 17:36:23 +0300 Subject: [PATCH 71/99] Add shared webhook runtime support Share queue and runner lifecycle between polling and webhook Keep Close() local-only and harden webhook startup validation Document runtime transitions and add webhook regression tests --- CHANGELOG.md | 15 ++ README.md | 24 +- README_RU.md | 24 +- TODO.md | 3 +- bot.go | 221 ++++-------------- bot_config.go | 2 +- bot_register.go | 3 +- bot_test.go | 35 +++ bot_utils.go | 178 +++++++++++++++ bot_webhook.go | 461 ++++++++++++++++++++++++++++++++++++++ bot_webhook_test.go | 293 ++++++++++++++++++++++++ doc.go | 2 +- keyboard.go | 2 + runners.go | 3 +- tgapi/methods.go | 2 +- tgapi/uploader_methods.go | 2 +- 16 files changed, 1084 insertions(+), 186 deletions(-) create mode 100644 bot_utils.go create mode 100644 bot_webhook.go create mode 100644 bot_webhook_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e24150d..f78f80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ ### Changed - Added missing godoc for the exported observer `Event` marker interface. +- Webhook execution now shares the bot's queued update-dispatch path with polling, including worker-pool delivery, runner startup, single-use run semantics, and default fallback to bot-level update type filters when webhook-specific filters are not set. +- Webhook godoc and the English and Russian READMEs now describe the bot-level webhook runtime, its single-use lifecycle, and the main `RunWebHookWithContext(...)` entry points more explicitly. +- `Bot.Close()` once again releases only local resources and no longer deletes remote webhook registrations implicitly; explicit remote webhook teardown remains opt-in through `CloseWebHook()`. +- Polling and webhook docs now explicitly state that a deployment must delete its webhook before switching from webhook delivery to long polling. +- Webhook startup now validates path shape and TLS file count before remote webhook setup, and the shared webhook mux now serves both HTTP and TLS runtime paths consistently. +- Webhook-related `tgapi` request params now use `int8` for `max_connections`, matching Telegram's `1..100` range and the higher-level webhook options API. +- Webhook startup now also requires a non-empty `SecretToken` when the optional `/status` endpoint is enabled, preventing anonymous exposure of webhook operational metadata. +- Webhook debug logging now records update metadata instead of dumping raw request bodies. +- Package docs, README guidance, and core wiki pages now align with the current public API and runtime model, including `NoData`, `SetAppData(...)`, `SetL10n(...)`, `AddAppDataLoggerWriter(...)`, shared runner startup semantics, and the webhook runtime entry points. + +### Tests +- Added regression coverage for webhook queue delivery, webhook runtime single-use behavior, runner startup in webhook mode, and default webhook `allowed_updates` inheritance from bot-level update type configuration. +- Added regression coverage proving `Bot.Close()` does not make remote webhook delete requests. +- Added webhook regression coverage for path validation, TLS file-count validation, oversized-body rejection, status-endpoint secret checks, and invalid TLS startup arguments. +- Added webhook regression coverage proving `/status` cannot be enabled without a non-empty `SecretToken`. ## v1.0.0-rc.13 diff --git a/README.md b/README.md index d8cabd4..69b3a1c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s * **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling). * **Context-Aware:** Pass custom application data or state contexts to your handlers. * **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`). +* **Polling and Webhook Runtime:** Run bots through long polling with `Run()` / `RunWithContext(...)` or through a bot-owned webhook server with `RunWebHookWithContext(...)`. --- @@ -116,7 +117,26 @@ func main() { 6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails. -9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session. +9. `RunWebHookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling. +10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebHookWithContext()` returns, create a new bot instance for the next session. + +## Webhook Runtime + +Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`. + +Use it when: +- Telegram should push updates to your HTTP endpoint instead of your bot polling for them. +- You want webhook-delivered updates to reuse the same internal queue, worker pool, runners, and single-use lifecycle as polling. +- You want Laniakea to register the webhook and own the local HTTP server. + +Production notes: +- Set `BotWebHookOpts.SecretToken` for request authentication. +- `BotWebHookOpts.SecretToken` is required when `BotWebHookOpts.UseStatusPath` is enabled. +- Keep `BotWebHookOpts.Path` specific instead of serving webhook traffic on `/`. +- If you switch an existing deployment from webhook mode to long polling, delete the webhook first with `CloseWebHook()` or `tgapi.DeleteWebhook(...)`. Telegram keeps webhook delivery active until it is removed. +- Use `RunWebHookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown. + +See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime) ## 📖 Core Concepts ### Plugins @@ -271,7 +291,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Localization**: `L10n` is safe for concurrent use once attached to the bot. - **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow. -- **Lifecycle**: `RunWithContext(...)` does not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run. +- **Lifecycle**: `RunWithContext(...)` and `RunWebHookWithContext(...)` do not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run. ## Telegram Update Handling - Commands and payloads are handled through plugins. diff --git a/README_RU.md b/README_RU.md index 5a76dd5..16852df 100644 --- a/README_RU.md +++ b/README_RU.md @@ -24,6 +24,7 @@ * **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). * **Контекст данных:** Передавайте общие данные приложения или state в обработчики. * **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`. +* **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebHookWithContext(...)`. --- @@ -117,7 +118,26 @@ func main() { 6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. -9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот. +9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling. +10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот. + +## Webhook Runtime + +Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`. + +Используй его, когда: +- Telegram должен сам отправлять update на твой HTTP endpoint вместо polling. +- Ты хочешь, чтобы webhook-update проходили через ту же внутреннюю очередь, тот же worker pool, тех же runners и тот же single-use lifecycle, что и polling. +- Ты хочешь, чтобы Laniakea сама регистрировала webhook и владела локальным HTTP server. + +Практические замечания: +- Задавай `BotWebHookOpts.SecretToken` для аутентификации запросов. +- Непустой `BotWebHookOpts.SecretToken` обязателен, если включён `BotWebHookOpts.UseStatusPath`. +- Используй явный `BotWebHookOpts.Path`, а не `/`. +- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebHook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него. +- Запускай `RunWebHookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`. + +Полное руководство есть в wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime-RU) ## 📖 Основные концепции ### Плагины (Plugins) @@ -268,7 +288,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. - **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. -- **Жизненный цикл**: `RunWithContext(...)` не вызывает `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. +- **Жизненный цикл**: `RunWithContext(...)` и `RunWebHookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. ## Обработка Telegram Updates - Команды и payload-ы обрабатываются через плагины. diff --git a/TODO.md b/TODO.md index 44cd369..d5bc7d9 100644 --- a/TODO.md +++ b/TODO.md @@ -12,11 +12,12 @@ Russian page: Current priority split: -- `Partial`: webhook runtime model. +- `Partial`: none. - `Ideas`: service layer and dependency graph model, plugin composition contract. Completed former high-priority items: +- `[v1.0.0-rc.14] Webhook runtime model.` - `[v1.0.0-rc.13] Observability model`: added first-class `Observer` events for update, command, payload, scene, policy, runner, polling, and centralized error flows, with safe event dispatch and regression coverage for the new runtime hooks. - `[v1.0.0-rc.13] Authorization and policy model`: added first-class `Policy[T]`, middleware integration through `RequirePolicy(...)`, plugin and bot policy registration helpers, built-in Telegram-aware policies, and composable `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` helpers with regression coverage. - `[v1.0.0-rc.13] Update schema contract`: documented and tested the normalized `MsgContext` update-routing contract, including routing categories and per-update field guarantees. diff --git a/bot.go b/bot.go index becf416..86d3a00 100644 --- a/bot.go +++ b/bot.go @@ -4,9 +4,6 @@ import ( "context" "errors" "fmt" - "maps" - "reflect" - "strings" "sync" "time" @@ -14,7 +11,6 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/slog" - "github.com/alitto/pond/v2" ) // AppData is the generic shared application data type injected into bots, @@ -63,7 +59,7 @@ var ( ErrNoPrefixes = errors.New("no prefixes defined") // ErrNoPlugins reports that the bot was started without any registered plugins. ErrNoPlugins = errors.New("no plugins defined") - // ErrBotAlreadyRun reports that Run or RunWithContext was called more than once. + // ErrBotAlreadyRun reports that Run, RunWithContext, or RunWebHookWithContext was called more than once. ErrBotAlreadyRun = errors.New("bot can only be run once") // ErrTokenRequired reports that BotOpts.Token was empty. @@ -81,8 +77,10 @@ var ( // - Logging and rate limiting // - Localization and draft message support // -// Runtime accessors are safe for concurrent use. Configure the bot before Run. -// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session. +// Runtime accessors are safe for concurrent use. Configure the bot before Run, +// RunWithContext, or RunWebHookWithContext. +// A Bot is single-use: after Run, RunWithContext, or RunWebHookWithContext returns, +// create a new Bot for the next session. type Bot[T AppData] struct { token string debug bool @@ -94,6 +92,7 @@ type Bot[T AppData] struct { logger *slog.Logger // Main bot logger (JSON stdout + optional file) RequestLogger *slog.Logger // Optional request-level API logging + webHookLogger *slog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext. extraLoggers extypes.Slice[*slog.Logger] // API, Uploader, and custom loggers plugins []Plugin[T] // Command/event handlers @@ -233,39 +232,59 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { // // Close shuts down, in order: // - Registered plugins via Plugin.Close +// - Webhook logger (if initialized) // - Uploader (waits for pending uploads) // - API client internals // - RequestLogger (if enabled) // - Main logger // -// RunWithContext does not call Close automatically. The caller is responsible -// for invoking Close after RunWithContext returns to release these resources. +// RunWithContext and RunWebHookWithContext do not call Close automatically. +// The caller is responsible for invoking Close after runtime returns to release +// these resources. // // Close returns a joined error containing all shutdown failures, if any. func (bot *Bot[T]) Close() error { var e []error + logCloseErr := func(err error) { + if err == nil { + return + } + if bot.logger != nil { + bot.logger.Errorln(err) + } + e = append(e, err) + } for _, p := range bot.plugins { if err := p.Close(); err != nil { e = append(e, err) } } - if err := bot.uploader.Close(); err != nil { - bot.logger.Errorln(err) - e = append(e, err) + if bot.webHookLogger != nil { + if err := bot.webHookLogger.Close(); err != nil { + logCloseErr(err) + } + bot.webHookLogger = nil } - if err := bot.api.Close(); err != nil { - bot.logger.Errorln(err) - e = append(e, err) + if bot.uploader != nil { + if err := bot.uploader.Close(); err != nil { + logCloseErr(err) + } + } + if bot.api != nil { + if err := bot.api.Close(); err != nil { + logCloseErr(err) + } } if bot.RequestLogger != nil { if err := bot.RequestLogger.Close(); err != nil { - bot.logger.Errorln(err) - e = append(e, err) + logCloseErr(err) } } - if err := bot.logger.Close(); err != nil { - e = append(e, err) + if bot.logger != nil { + if err := bot.logger.Close(); err != nil { + e = append(e, err) + } } return errors.Join(e...) } @@ -327,6 +346,10 @@ func (bot *Bot[T]) L10n(lang, key string) string { // - Finishes processing currently queued updates // - Waits for registered runners to exit // +// If you are switching an existing deployment from webhook delivery to polling, +// delete the current webhook first with CloseWebHook or tgapi.DeleteWebhook. +// Telegram keeps webhook delivery active until the webhook is removed. +// // RunWithContext does not close API, uploader, or logger resources on return. // The caller must invoke Close after RunWithContext finishes. // @@ -346,8 +369,6 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { bot.ExecRunners(ctx) - bot.logger.Infoln("Bot running. Press CTRL+C to exit.") - // Start update polling in a goroutine go func() { defer func() { @@ -398,10 +419,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { retryCount = 0 for _, update := range updates { - u := update // copy loop variable to avoid race condition - select { - case bot.updateQueue <- &u: - case <-ctx.Done(): + if err := bot.enqueueUpdate(ctx, update); err != nil { return } } @@ -409,15 +427,10 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { } }() + bot.logger.Infoln("Bot running. Press CTRL+C to exit.") // Start worker pool for concurrent update handling - pool := pond.NewPool(bot.maxWorkers) - for update := range bot.updateQueue { - u := update // capture loop variable - pool.Submit(func() { - bot.handle(ctx, u) - }) - } - pool.Stop() // Wait for all tasks to complete and stop the pool + bot.startUpdateWorkers(ctx) + bot.runnerOnceWG.Wait() bot.runnerBgWG.Wait() return nil @@ -432,145 +445,3 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { func (bot *Bot[T]) Run() error { return bot.RunWithContext(context.Background()) } - -func (bot *Bot[T]) initLoggers(opts *BotOpts) { - level := slog.FATAL - if opts.Debug { - level = slog.DEBUG - } - - bot.logger = utils.CreateLogger("BOT", level) - if opts.WriteToFile { - path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("BOT", level, path) - if err != nil { - bot.logger.Errorln(err) - } else { - bot.logger = logger - } - } - - if opts.UseRequestLogger { - bot.RequestLogger = utils.CreateLogger("REQUESTS", level) - if opts.WriteToFile { - path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("REQUESTS", level, path) - if err != nil { - bot.logger.Errorln(err) - } else { - bot.RequestLogger = logger - } - } - } -} - -func (bot *Bot[T]) beginRun() error { - bot.runStateMu.Lock() - defer bot.runStateMu.Unlock() - if bot.running || bot.ran { - return ErrBotAlreadyRun - } - bot.running = true - bot.ran = true - return nil -} - -func (bot *Bot[T]) finishRun() { - bot.runStateMu.Lock() - bot.running = false - bot.runStateMu.Unlock() -} - -func nextPollRetryDelay(prev time.Duration) time.Duration { - if prev <= 0 { - return time.Second - } - next := prev * 2 - if next > 30*time.Second { - return 30 * time.Second - } - return next -} - -func isNilValue[T any](v T) bool { - rv := reflect.ValueOf(v) - if !rv.IsValid() { - return true - } - switch rv.Kind() { - case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: - return rv.IsNil() - default: - return false - } -} - -func shouldWarnOnValueAppData[T any]() bool { - t := reflect.TypeFor[T]() - if t == reflect.TypeFor[NoData]() { - return false - } - switch t.Kind() { - case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan: - return false - default: - return true - } -} - -func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] { - cloned := Plugin[T]{ - name: p.name, - commands: make(map[string]*Command[T], len(p.commands)), - payloads: make(map[string]*Command[T], len(p.payloads)), - scenes: make(map[string]*Scene[T], len(p.scenes)), - middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), - skipAutoCmd: p.skipAutoCmd, - logger: p.logger, - handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), - onClose: p.onClose, - } - - for name, command := range p.commands { - cloned.commands[name] = cloneCommand(command) - } - for name, command := range p.payloads { - cloned.payloads[name] = cloneCommand(command) - } - for name, scene := range p.scenes { - cloned.scenes[name] = cloneScene(scene) - } - maps.Copy(cloned.handlers, p.handlers) - - return cloned -} - -func cloneCommand[T AppData](command *Command[T]) *Command[T] { - if command == nil { - return nil - } - - cloned := *command - cloned.args = append(extypes.Slice[CommandArg](nil), command.args...) - cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) - return &cloned -} - -func cloneScene[T AppData](scene *Scene[T]) *Scene[T] { - if scene == nil { - return nil - } - - cloned := *scene - cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) - cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) - - for name, handler := range scene.steps { - cloned.steps[name] = handler - } - for name, handler := range scene.commands { - cloned.commands[name] = handler - } - - return &cloned -} diff --git a/bot_config.go b/bot_config.go index 2be3f4c..851915c 100644 --- a/bot_config.go +++ b/bot_config.go @@ -9,7 +9,7 @@ import ( ) // AddPrefixes adds one or more command prefixes (e.g., "/", "!"). -// Must have at least one prefix before Run(). +// The bot must have at least one prefix before any runtime entry point starts. func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { if !bot.configMutable("AddPrefixes") { return bot diff --git a/bot_register.go b/bot_register.go index 6a7a4ee..eb97890 100644 --- a/bot_register.go +++ b/bot_register.go @@ -93,7 +93,8 @@ func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] { // - Metrics collection or health checks // - Scheduled tasks (e.g., daily announcements) // -// Runners are started immediately after Bot.Run() is called. +// Runners start from the bot runtime entry points, immediately after +// RunWithContext or RunWebHookWithContext begins. // // Example: // diff --git a/bot_test.go b/bot_test.go index e85c156..e37848c 100644 --- a/bot_test.go +++ b/bot_test.go @@ -278,6 +278,41 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) { } } +func TestCloseDoesNotDeleteWebhook(t *testing.T) { + requests := 0 + client := &http.Client{ + Transport: pollingRoundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("http://example.invalid"). + SetHTTPClient(client), + ) + uploader := tgapi.NewUploader(api) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + webHookLogger: slog.CreateLogger(), + api: api, + uploader: uploader, + } + + if err := bot.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + if requests != 0 { + t.Fatalf("Close performed unexpected remote requests: got %d want 0", requests) + } +} + func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/bot_utils.go b/bot_utils.go new file mode 100644 index 0000000..c6fa9b4 --- /dev/null +++ b/bot_utils.go @@ -0,0 +1,178 @@ +package laniakea + +import ( + "context" + "fmt" + "maps" + "reflect" + "strings" + "time" + + "git.scuroneko.dev/scuroneko/extypes" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/slog" + "github.com/alitto/pond/v2" +) + +func (bot *Bot[T]) enqueueUpdate(ctx context.Context, update tgapi.Update) error { + select { + case <-ctx.Done(): + return ctx.Err() + case bot.updateQueue <- new(update): + return nil + } +} + +func (bot *Bot[T]) startUpdateWorkers(ctx context.Context) { + pool := pond.NewPool(bot.maxWorkers) + for update := range bot.updateQueue { + u := update // capture loop variable + pool.Submit(func() { + bot.handle(ctx, u) + }) + } + pool.Stop() // Wait for all tasks to complete and stop the pool +} + +func (bot *Bot[T]) initLoggers(opts *BotOpts) { + level := slog.FATAL + if opts.Debug { + level = slog.DEBUG + } + + bot.logger = utils.CreateLogger("BOT", level) + if opts.WriteToFile { + path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) + logger, err := utils.CreateFileLogger("BOT", level, path) + if err != nil { + bot.logger.Errorln(err) + } else { + bot.logger = logger + } + } + + if opts.UseRequestLogger { + bot.RequestLogger = utils.CreateLogger("REQUESTS", level) + if opts.WriteToFile { + path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) + logger, err := utils.CreateFileLogger("REQUESTS", level, path) + if err != nil { + bot.logger.Errorln(err) + } else { + bot.RequestLogger = logger + } + } + } +} + +func (bot *Bot[T]) beginRun() error { + bot.runStateMu.Lock() + defer bot.runStateMu.Unlock() + if bot.running || bot.ran { + return ErrBotAlreadyRun + } + bot.running = true + bot.ran = true + return nil +} + +func (bot *Bot[T]) finishRun() { + bot.runStateMu.Lock() + bot.running = false + bot.runStateMu.Unlock() +} + +func nextPollRetryDelay(prev time.Duration) time.Duration { + if prev <= 0 { + return time.Second + } + next := prev * 2 + if next > 30*time.Second { + return 30 * time.Second + } + return next +} + +func isNilValue[T any](v T) bool { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return true + } + switch rv.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return rv.IsNil() + default: + return false + } +} + +func shouldWarnOnValueAppData[T any]() bool { + t := reflect.TypeFor[T]() + if t == reflect.TypeFor[NoData]() { + return false + } + switch t.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan: + return false + default: + return true + } +} + +func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] { + cloned := Plugin[T]{ + name: p.name, + commands: make(map[string]*Command[T], len(p.commands)), + payloads: make(map[string]*Command[T], len(p.payloads)), + scenes: make(map[string]*Scene[T], len(p.scenes)), + middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), + skipAutoCmd: p.skipAutoCmd, + logger: p.logger, + handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), + onClose: p.onClose, + } + + for name, command := range p.commands { + cloned.commands[name] = cloneCommand(command) + } + for name, command := range p.payloads { + cloned.payloads[name] = cloneCommand(command) + } + for name, scene := range p.scenes { + cloned.scenes[name] = cloneScene(scene) + } + maps.Copy(cloned.handlers, p.handlers) + + return cloned +} + +func cloneCommand[T AppData](command *Command[T]) *Command[T] { + if command == nil { + return nil + } + + cloned := *command + cloned.args = append(extypes.Slice[CommandArg](nil), command.args...) + cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) + return &cloned +} + +func cloneScene[T AppData](scene *Scene[T]) *Scene[T] { + if scene == nil { + return nil + } + + cloned := *scene + cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) + cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) + + for name, handler := range scene.steps { + cloned.steps[name] = handler + } + for name, handler := range scene.commands { + cloned.commands[name] = handler + } + + return &cloned +} diff --git a/bot_webhook.go b/bot_webhook.go new file mode 100644 index 0000000..752e9df --- /dev/null +++ b/bot_webhook.go @@ -0,0 +1,461 @@ +package laniakea + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/utils" +) + +// BotWebHookOpts configures Telegram webhook registration and the local HTTP server. +type BotWebHookOpts struct { + Path string + LocalPort int + UseStatusPath bool + + URL string + Certificate []byte + IPAddress string + MaxConnections int8 + AllowedUpdates []tgapi.UpdateType + DropPendingUpdates bool + SecretToken string +} + +// NewBotWebHookOpts returns webhook options with the default path, local port, and max connections. +func NewBotWebHookOpts() *BotWebHookOpts { + return &BotWebHookOpts{ + Path: "/", + LocalPort: 8080, + MaxConnections: 40, + } +} + +// SetPath sets the local HTTP path that receives Telegram webhook requests. +func (opts *BotWebHookOpts) SetPath(path string) *BotWebHookOpts { + opts.Path = path + return opts +} + +// SetLocalPort sets the local HTTP port used by the webhook server. +func (opts *BotWebHookOpts) SetLocalPort(port int) *BotWebHookOpts { + opts.LocalPort = port + return opts +} + +// SetUseStatusPath enables or disables the optional /status endpoint. +// A non-empty SecretToken is required when this endpoint is enabled. +func (opts *BotWebHookOpts) SetUseStatusPath(use bool) *BotWebHookOpts { + opts.UseStatusPath = use + return opts +} + +// SetURL sets the public base URL Telegram should call for incoming updates. +func (opts *BotWebHookOpts) SetURL(url string) *BotWebHookOpts { + opts.URL = url + return opts +} + +// SetCertificate sets the self-signed webhook certificate bytes to upload. +func (opts *BotWebHookOpts) SetCertificate(certificate []byte) *BotWebHookOpts { + opts.Certificate = certificate + return opts +} + +// MustLoadCertificate loads a webhook certificate from disk and panics on failure. +func (opts *BotWebHookOpts) MustLoadCertificate(filename string) *BotWebHookOpts { + f, err := os.Open(filename) + if err != nil { + panic(err) + } + defer func() { + _ = f.Close() + }() + opts.Certificate, err = io.ReadAll(f) + if err != nil { + panic(err) + } + return opts +} + +// SetIPAddress sets the fixed IP address Telegram should use for webhook delivery. +func (opts *BotWebHookOpts) SetIPAddress(ip string) *BotWebHookOpts { + opts.IPAddress = ip + return opts +} + +// SetMaxConnections sets Telegram's maximum number of simultaneous webhook connections. +func (opts *BotWebHookOpts) SetMaxConnections(max int8) *BotWebHookOpts { + opts.MaxConnections = max + return opts +} + +// SetAllowedUpdates sets the Telegram update types that should be delivered to the webhook. +func (opts *BotWebHookOpts) SetAllowedUpdates(updates ...tgapi.UpdateType) *BotWebHookOpts { + opts.AllowedUpdates = append([]tgapi.UpdateType(nil), updates...) + return opts +} + +// SetDropPendingUpdates configures whether Telegram should drop pending updates while setting the webhook. +func (opts *BotWebHookOpts) SetDropPendingUpdates(drop bool) *BotWebHookOpts { + opts.DropPendingUpdates = drop + return opts +} + +// SetSecretToken sets the secret token expected in Telegram webhook requests. +// The same token is also required to access /status when that endpoint is enabled. +func (opts *BotWebHookOpts) SetSecretToken(secretToken string) *BotWebHookOpts { + opts.SecretToken = secretToken + return opts +} + +// RunWebHookWithContext registers the webhook and serves incoming updates until ctx is canceled. +// +// The bot uses the same update queue, worker pool, runner startup, and single-use lifecycle +// guarantees as RunWithContext. When opts.AllowedUpdates is empty, the bot-level update types +// configured through SetUpdateTypes/AddUpdateType are used. When UseStatusPath is enabled, +// SecretToken must be non-empty so the operational endpoint is not left public. +// +// When two TLS files are provided, the method serves HTTPS using the existing key-then-cert +// argument order. +func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOpts, tlsFiles ...string) error { + if opts == nil { + return errors.New("nil BotWebHookOpts") + } + if len(bot.prefixes) == 0 { + return ErrNoPrefixes + } + if len(bot.plugins) == 0 { + return ErrNoPlugins + } + if opts.URL == "" { + return errors.New("empty BotWebHookOpts.URL") + } + if opts.MaxConnections > 100 || opts.MaxConnections <= 0 { + return errors.New("BotWebHookOpts.MaxConnections must between 1 and 100") + } + if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { + return err + } + if opts.UseStatusPath && opts.SecretToken == "" { + return errors.New("BotWebHookOpts.SecretToken required when status path is enabled") + } + if err := validateWebhookTLSFiles(tlsFiles); err != nil { + return err + } + + bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel()) + if opts.SecretToken == "" { + bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") + } + + if opts.Certificate != nil && bot.uploader == nil { + return errors.New("bot uploader nil, but certificate set") + } + + return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { + i, err := bot.api.GetWebhookInfoWithContext(runCtx) + if err != nil { + return err + } + if i.URL == "" { + bot.webHookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll") + } else { + _, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhookP{}) + if err != nil { + return err + } + bot.webHookLogger.Infof("Bot webhook deleted: %s", i.URL) + } + + allowedUpdates := bot.webhookAllowedUpdates(opts) + + var ok bool + if opts.Certificate != nil { + ok, err = bot.uploader.SetWebhookWithContext(runCtx, tgapi.UploadSetWebhookP{ + URL: fmt.Sprintf("%s%s", opts.URL, opts.Path), + IPAddress: opts.IPAddress, + MaxConnections: opts.MaxConnections, + AllowedUpdates: allowedUpdates, + DropPendingUpdates: opts.DropPendingUpdates, + SecretToken: opts.SecretToken, + }, tgapi.NewUploaderFile("certificate", opts.Certificate)) + } else { + ok, err = bot.api.SetWebhookWithContext(runCtx, tgapi.SetWebhookP{ + URL: fmt.Sprintf("%s%s", opts.URL, opts.Path), + IPAddress: opts.IPAddress, + MaxConnections: opts.MaxConnections, + AllowedUpdates: allowedUpdates, + DropPendingUpdates: opts.DropPendingUpdates, + SecretToken: opts.SecretToken, + }) + } + if err != nil { + return err + } + if !ok { + return errors.New("failed to set webhook") + } + + if len(tlsFiles) == 2 { + return bot.runWebHookTLS(runCtx, opts, tlsFiles[0], tlsFiles[1]) + } + + return bot.runWebHook(runCtx, opts) + }) +} + +// RunWebHook starts the webhook runtime with a background context. +// +// It is shorthand for RunWebHookWithContext(context.Background(), opts, tlsFiles...). +func (bot *Bot[T]) RunWebHook(opts *BotWebHookOpts, tlsFiles ...string) error { + return bot.RunWebHookWithContext(context.Background(), opts, tlsFiles...) +} + +// CloseWebHook removes the current Telegram webhook registration. +// +// It is separate from Close, which only releases local resources. +// Call it before switching a deployment from webhook delivery to polling. +func (bot *Bot[T]) CloseWebHook() error { + var e []error + if bot.api == nil { + e = append(e, errors.New("bot api nil")) + } else { + if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhookP{}); err != nil { + if bot.webHookLogger != nil { + bot.webHookLogger.Errorf("Failed to close webhook: %s", err.Error()) + } else if bot.logger != nil { + bot.logger.Errorf("Failed to close webhook: %s", err.Error()) + } + e = append(e, err) + } + } + if bot.webHookLogger != nil { + if err := bot.webHookLogger.Close(); err != nil { + e = append(e, err) + } + bot.webHookLogger = nil + } + return errors.Join(e...) +} + +func (bot *Bot[T]) webhookAllowedUpdates(opts *BotWebHookOpts) []tgapi.UpdateType { + if len(opts.AllowedUpdates) > 0 { + return append([]tgapi.UpdateType(nil), opts.AllowedUpdates...) + } + return bot.GetUpdateTypes() +} + +func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Context) error) error { + if err := bot.beginRun(); err != nil { + return err + } + defer bot.finishRun() + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + bot.ExecRunners(runCtx) + + workersDone := make(chan struct{}) + go func() { + bot.startUpdateWorkers(runCtx) + close(workersDone) + }() + + runErr := run(runCtx) + cancel() + close(bot.updateQueue) + <-workersDone + bot.runnerOnceWG.Wait() + bot.runnerBgWG.Wait() + + return runErr +} + +func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + _ = r.Body.Close() + }() + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if secret != "" && r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != secret { + w.WriteHeader(http.StatusForbidden) + return + } + + const maxWebhookBodySize = 256 << 10 // 256 KiB + r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodySize) + + data, err := io.ReadAll(r.Body) + if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusBadRequest) + return + } + if len(data) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + var up tgapi.Update + if err := json.Unmarshal(data, &up); err != nil { + w.WriteHeader(http.StatusBadRequest) + bot.webHookLogger.Errorln(err) + return + } + bot.webHookLogger.Debugf("UPDATE id=%d type=%s size=%d from=%s", up.UpdateID, up.Type, len(data), r.RemoteAddr) + if err := bot.enqueueUpdate(ctx, up); err != nil { + bot.webHookLogger.Errorln(err) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + } +} + +func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + auth := "" + if r.Header.Get("Authorization") != "" { + auth = r.Header.Get("Authorization") + } else if r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != "" { + auth = r.Header.Get("X-Telegram-Bot-Api-Secret-Token") + } + if auth != opts.SecretToken { + w.WriteHeader(http.StatusNotFound) + return + } + + i, err := bot.api.GetWebhookInfoWithContext(r.Context()) + if err != nil { + bot.webHookLogger.Errorln(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + data, err := json.MarshalIndent(i, "", " ") + if err != nil { + bot.webHookLogger.Errorln(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprint(w, string(data)); err != nil { + bot.webHookLogger.Errorln(err) + } + } +} + +func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *http.ServeMux { + r := http.NewServeMux() + if opts.UseStatusPath { + r.HandleFunc("/status", statusHandler[T](bot, opts)) + } + r.HandleFunc(opts.Path, updateHandler[T](ctx, bot, opts.SecretToken)) + return r +} +func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error { + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", opts.LocalPort), + Handler: bot.newWebHookMux(ctx, opts), + } + errCh := make(chan error, 1) + + go func() { + err := srv.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + bot.webHookLogger.Infoln(fmt.Sprintf("Bot WebHook started at %s; waiting for updates at %s", srv.Addr, opts.URL)) + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return err + } + + return <-errCh + + case err := <-errCh: + return err + } +} +func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key, cert string) error { + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", opts.LocalPort), + Handler: bot.newWebHookMux(ctx, opts), + } + errCh := make(chan error, 1) + + go func() { + err := srv.ListenAndServeTLS(cert, key) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + bot.webHookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL)) + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return err + } + + return <-errCh + + case err := <-errCh: + return err + } +} +func validateWebhookPath(path string, useStatusPath bool) error { + if path == "" { + return errors.New("empty BotWebHookOpts.Path") + } + if !strings.HasPrefix(path, "/") { + return errors.New("BotWebHookOpts.Path must start with '/'") + } + if strings.Contains(path, "?") || strings.Contains(path, "#") { + return errors.New("BotWebHookOpts.Path must not contain query or fragment") + } + if useStatusPath && path == "/status" { + return errors.New("BotWebHookOpts.Path must not be '/status' when status path is enabled") + } + return nil +} + +func validateWebhookTLSFiles(tlsFiles []string) error { + switch len(tlsFiles) { + case 0, 2: + return nil + case 1: + return errors.New("you must specify both private and public keys") + default: + return errors.New("too many files; you must specify only private and public keys") + } +} diff --git a/bot_webhook_test.go b/bot_webhook_test.go new file mode 100644 index 0000000..e58d06c --- /dev/null +++ b/bot_webhook_test.go @@ -0,0 +1,293 @@ +package laniakea + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/slog" +) + +func TestEnqueueUpdateCopiesValue(t *testing.T) { + bot := &Bot[NoData]{ + updateQueue: make(chan *tgapi.Update, 1), + } + + update := tgapi.Update{UpdateID: 42} + if err := bot.enqueueUpdate(context.Background(), update); err != nil { + t.Fatalf("enqueueUpdate returned error: %v", err) + } + + update.UpdateID = 99 + + got := <-bot.updateQueue + if got.UpdateID != 42 { + t.Fatalf("enqueueUpdate did not copy the update value: got %d want %d", got.UpdateID, 42) + } +} + +func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { + bot := &Bot[NoData]{ + updateQueue: make(chan *tgapi.Update, 1), + webHookLogger: slog.CreateLogger(), + } + t.Cleanup(func() { + _ = bot.webHookLogger.Close() + }) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":7,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"text":"/start"}}`)) + req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "secret") + rec := httptest.NewRecorder() + + updateHandler(context.Background(), bot, "secret").ServeHTTP(rec, req) + + if rec.Result().StatusCode != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) + } + + select { + case got := <-bot.updateQueue: + if got.UpdateID != 7 { + t.Fatalf("unexpected update id in queue: got %d want %d", got.UpdateID, 7) + } + if got.Type != tgapi.UpdateTypeMessage { + t.Fatalf("unexpected update type in queue: got %q want %q", got.Type, tgapi.UpdateTypeMessage) + } + default: + t.Fatal("expected webhook handler to enqueue an update") + } +} + +func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) { + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + } + t.Cleanup(func() { + _ = bot.logger.Close() + }) + + if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); err != nil { + t.Fatalf("first runWebhookRuntime returned error: %v", err) + } + if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); !errors.Is(err, ErrBotAlreadyRun) { + t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err) + } +} + +func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { + var calls atomic.Int32 + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + runners: []Runner[NoData]{ + NewRunner("runner", func(bot *Bot[NoData]) error { + calls.Add(1) + return nil + }).Onetime(true).Async(false), + }, + } + t.Cleanup(func() { + _ = bot.logger.Close() + }) + + if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); err != nil { + t.Fatalf("runWebhookRuntime returned error: %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("expected runner to execute once, got %d", got) + } +} + +func TestWebhookAllowedUpdatesUsesBotUpdateTypesByDefault(t *testing.T) { + bot := &Bot[NoData]{ + updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, + } + opts := NewBotWebHookOpts() + + got := bot.webhookAllowedUpdates(opts) + if len(got) != 2 { + t.Fatalf("unexpected allowed updates length: got %d want %d", len(got), 2) + } + if got[0] != tgapi.UpdateTypeMessage || got[1] != tgapi.UpdateTypeCallbackQuery { + t.Fatalf("unexpected allowed updates: %v", got) + } + + got[0] = tgapi.UpdateTypePoll + if bot.updateTypes[0] != tgapi.UpdateTypeMessage { + t.Fatalf("webhookAllowedUpdates exposed internal slice: got %v", bot.updateTypes) + } +} + +func TestValidateWebhookPath(t *testing.T) { + tests := []struct { + name string + path string + useStatusPath bool + wantErr bool + }{ + {name: "root", path: "/", wantErr: false}, + {name: "custom path", path: "/telegram", wantErr: false}, + {name: "empty", path: "", wantErr: true}, + {name: "missing slash", path: "telegram", wantErr: true}, + {name: "query", path: "/telegram?x=1", wantErr: true}, + {name: "fragment", path: "/telegram#main", wantErr: true}, + {name: "status collision", path: "/status", useStatusPath: true, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateWebhookPath(tc.path, tc.useStatusPath) + if tc.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateWebhookTLSFiles(t *testing.T) { + tests := []struct { + name string + files []string + wantErr bool + }{ + {name: "no tls", files: nil, wantErr: false}, + {name: "two files", files: []string{"key.pem", "cert.pem"}, wantErr: false}, + {name: "one file", files: []string{"cert.pem"}, wantErr: true}, + {name: "three files", files: []string{"a", "b", "c"}, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateWebhookTLSFiles(tc.files) + if tc.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { + bot := &Bot[NoData]{ + updateQueue: make(chan *tgapi.Update, 1), + webHookLogger: slog.CreateLogger(), + } + t.Cleanup(func() { + _ = bot.webHookLogger.Close() + }) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1))) + rec := httptest.NewRecorder() + + updateHandler(context.Background(), bot, "").ServeHTTP(rec, req) + + if rec.Result().StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusRequestEntityTooLarge) + } +} + +func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { + client := &http.Client{ + Transport: pollingRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"url":"https://bot.example.com/telegram"}}`)), + }, nil + }), + } + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIUrl("http://example.invalid"). + SetHTTPClient(client), + ) + defer func() { + _ = api.Close() + }() + + bot := &Bot[NoData]{ + api: api, + webHookLogger: slog.CreateLogger(), + } + t.Cleanup(func() { + _ = bot.webHookLogger.Close() + }) + + handler := statusHandler(bot, &BotWebHookOpts{SecretToken: "secret"}) + + tests := []struct { + name string + headerName string + headerVal string + wantStatus int + }{ + {name: "missing auth", wantStatus: http.StatusNotFound}, + {name: "wrong auth", headerName: "Authorization", headerVal: "wrong", wantStatus: http.StatusNotFound}, + {name: "matching telegram header", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secret", wantStatus: http.StatusOK}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/status", nil) + if tc.headerName != "" { + req.Header.Set(tc.headerName, tc.headerVal) + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Result().StatusCode != tc.wantStatus { + t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, tc.wantStatus) + } + }) + } +} + +func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) { + bot := &Bot[NoData]{ + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + } + opts := NewBotWebHookOpts().SetURL("https://bot.example.com") + + err := bot.RunWebHookWithContext(context.Background(), opts, "cert.pem") + if err == nil { + t.Fatal("expected tls validation error, got nil") + } + if !strings.Contains(err.Error(), "both private and public keys") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunWebHookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) { + bot := &Bot[NoData]{ + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + } + opts := NewBotWebHookOpts(). + SetURL("https://bot.example.com"). + SetUseStatusPath(true) + + err := bot.RunWebHookWithContext(context.Background(), opts) + if err == nil { + t.Fatal("expected status-path secret validation error, got nil") + } + if !strings.Contains(err.Error(), "SecretToken required") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/doc.go b/doc.go index 6cb8433..3be732d 100644 --- a/doc.go +++ b/doc.go @@ -27,7 +27,7 @@ Example usage: return bot.Run() -Configure bots, plugins, and localization before starting Run or RunWithContext. +Configure bots, plugins, and localization before starting Run, RunWithContext, or RunWebHookWithContext. Runtime accessors are safe for concurrent use unless stated otherwise. */ package laniakea diff --git a/keyboard.go b/keyboard.go index fc5635e..e251ddc 100644 --- a/keyboard.go +++ b/keyboard.go @@ -147,6 +147,8 @@ 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. func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard { in.maxRow = maxRow return in diff --git a/runners.go b/runners.go index 7d50ce5..7aa457c 100644 --- a/runners.go +++ b/runners.go @@ -88,7 +88,8 @@ func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] { // // Background runners listen for ctx.Done() and gracefully shut down when the context is canceled. // -// This method is typically called once during bot startup in RunWithContext. +// This method is typically called once during bot startup from RunWithContext or +// RunWebHookWithContext. func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { diff --git a/tgapi/methods.go b/tgapi/methods.go index a13f328..a4d5eb9 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -86,7 +86,7 @@ func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) type SetWebhookP struct { URL string `json:"url"` IPAddress string `json:"ip_address,omitempty"` - MaxConnections int `json:"max_connections,omitempty"` + MaxConnections int8 `json:"max_connections,omitempty"` AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` SecretToken string `json:"secret_token,omitempty"` diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index ba78abb..d88a534 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -333,7 +333,7 @@ func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadCha type UploadSetWebhookP struct { URL string `json:"url"` IPAddress string `json:"ip_address,omitempty"` - MaxConnections int `json:"max_connections,omitempty"` + MaxConnections int8 `json:"max_connections,omitempty"` AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` SecretToken string `json:"secret_token,omitempty"` -- 2.54.0 From 83bcab64154d0e51f1d211a11332add12b85b0df Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 6 Apr 2026 16:06:57 +0300 Subject: [PATCH 72/99] Rename tgapi request structs Add MaybeInaccessibleMessage tests and godoc Update wiki and Bot API docs for the new naming --- CHANGELOG.md | 45 ++++ bot_webhook.go | 8 +- cmd_generator.go | 8 +- drafts.go | 4 +- handler_test.go | 8 +- methods.go | 4 +- msg_context.go | 14 +- policy.go | 6 +- tgapi/attachments_methods.go | 72 +++--- tgapi/attachments_types.go | 248 ++++++++++++++++++++ tgapi/bot_methods.go | 76 +++--- tgapi/business_methods.go | 195 ++++++++-------- tgapi/chat_methods.go | 254 ++++++++++---------- tgapi/chat_types.go | 25 +- tgapi/forum_methods.go | 70 +++--- tgapi/forum_types.go | 16 ++ tgapi/games_methods.go | 24 +- tgapi/games_types.go | 10 + tgapi/inline_methods.go | 46 +++- tgapi/inline_types.go | 6 + tgapi/messages_methods.go | 256 ++++++++++---------- tgapi/messages_types.go | 436 ++++++++++++++++++++++++++++------- tgapi/methods.go | 68 ++++-- tgapi/methods_test.go | 57 +++++ tgapi/methods_types.go | 14 -- tgapi/parse_mode_test.go | 4 +- tgapi/passport_methods.go | 8 +- tgapi/passport_types.go | 61 ++++- tgapi/payments_methods.go | 32 +-- tgapi/payments_types.go | 80 +++++++ tgapi/stars_methods.go | 24 +- tgapi/stickers_methods.go | 128 +++++----- tgapi/stickers_types.go | 4 +- tgapi/types.go | 338 ++++++++++++++++----------- tgapi/types_test.go | 101 ++++++++ tgapi/uploader_api_test.go | 2 +- tgapi/uploader_methods.go | 72 +++--- tgapi/users_methods.go | 32 +-- tgapi/users_types.go | 1 + utils/multipart_test.go | 3 +- 40 files changed, 1946 insertions(+), 914 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f78f80c..fea96ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,50 @@ ## v1.0.0-rc.14 +### Bot API 9.6 + +#### Managed Bots +- Added the field can_manage_bots to the class User. +- Added the class KeyboardButtonRequestManagedBot and the field request_managed_bot to the class KeyboardButton. +- Added the class ManagedBotCreated and the field managed_bot_created to the class Message. +- Added updates about the creation of managed bots and the change of their token, represented by the class ManagedBotUpdated and the field managed_bot in the class Update. +- Added the methods getManagedBotToken and replaceManagedBotToken. +- Added the class PreparedKeyboardButton and the method savePreparedKeyboardButton, allowing bots to request users, chats and managed bots from Mini Apps. +- Added the method requestChat to the class WebApp. +- Added support for https://t.me/newbot/{manager_bot_username}/{suggested_bot_username}[?name={suggested_bot_name}] links, allowing bots to request the creation of a managed bot via a link. + +### Polls +- Added support for quizzes with multiple correct answers. +- Replaced the field correct_option_id with the field correct_option_ids in the class Poll. +- Replaced the parameter correct_option_id with the parameter correct_option_ids in the method sendPoll. +- Allowed to pass allows_multiple_answers for quizzes in the method sendPoll. +- Increased the maximum time for automatic poll closure to 2628000 seconds. +- Added the field allows_revoting to the class Poll. +- Added the parameter allows_revoting to the method sendPoll. +- Added the parameter shuffle_options to the method sendPoll. +- Added the parameter allow_adding_options to the method sendPoll. +- Added the parameter hide_results_until_closes to the method sendPoll. +- Added the fields description and description_entities to the class Poll. +- Added the parameters description, description_parse_mode, and description_entities to the method sendPoll. +- Added the field persistent_id to the class PollOption, representing a persistent identifier for the option. +- Added the field option_persistent_ids to the class PollAnswer. +- Added the fields added_by_user and added_by_chat to the class PollOption, denoting the user and the chat which added the option. +- Added the field addition_date to the class PollOption, describing the date when the option was added. +- Added the class PollOptionAdded and the field poll_option_added to the class Message. +- Added the class PollOptionDeleted and the field poll_option_deleted to the class Message. +- Added the field poll_option_id to the class ReplyParameters, allowing bots to reply to a specific poll option. +- Added the field reply_to_poll_option_id to the class Message. +- Allowed “date_time” entities in checklist title, checklist task text, TextQuote, ReplyParameters quote, sendGift, and giftPremiumSubscription. + +**More info**: https://core.telegram.org/bots/api#april-3-2026 + +### Breaking Changes +- Exported `tgapi` request parameter structs were renamed from the `*P` suffix to their method names. Update code such as `tgapi.SendMessageP{...}` to `tgapi.SendMessage{...}`. + ### Changed +- *Support for Bot API 9.6* +- Added missing godoc for recently introduced Telegram Bot API managed-bot, prepared-button, chat-owner, and video-quality exported declarations. +- Renamed exported `tgapi` request parameter structs from the `*P` suffix to their method names, for example `SendMessageP` -> `SendMessage` and `SetWebhookP` -> `SetWebhook`. - Added missing godoc for the exported observer `Event` marker interface. - Webhook execution now shares the bot's queued update-dispatch path with polling, including worker-pool delivery, runner startup, single-use run semantics, and default fallback to bot-level update type filters when webhook-specific filters are not set. - Webhook godoc and the English and Russian READMEs now describe the bot-level webhook runtime, its single-use lifecycle, and the main `RunWebHookWithContext(...)` entry points more explicitly. @@ -19,6 +62,8 @@ - Added regression coverage proving `Bot.Close()` does not make remote webhook delete requests. - Added webhook regression coverage for path validation, TLS file-count validation, oversized-body rejection, status-endpoint secret checks, and invalid TLS startup arguments. - Added webhook regression coverage proving `/status` cannot be enabled without a non-empty `SecretToken`. +- Added regression coverage for Bot API 9.6 poll decoding, `managed_bot` update decoding, and structured `setChatMenuButton(...)` request serialization. +- Added regression coverage for `MaybeInaccessibleMessage` accessible and inaccessible JSON decoding. ## v1.0.0-rc.13 diff --git a/bot_webhook.go b/bot_webhook.go index 752e9df..23f55f3 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -169,7 +169,7 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp if i.URL == "" { bot.webHookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll") } else { - _, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhookP{}) + _, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhook{}) if err != nil { return err } @@ -180,7 +180,7 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp var ok bool if opts.Certificate != nil { - ok, err = bot.uploader.SetWebhookWithContext(runCtx, tgapi.UploadSetWebhookP{ + ok, err = bot.uploader.SetWebhookWithContext(runCtx, tgapi.UploadSetWebhook{ URL: fmt.Sprintf("%s%s", opts.URL, opts.Path), IPAddress: opts.IPAddress, MaxConnections: opts.MaxConnections, @@ -189,7 +189,7 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp SecretToken: opts.SecretToken, }, tgapi.NewUploaderFile("certificate", opts.Certificate)) } else { - ok, err = bot.api.SetWebhookWithContext(runCtx, tgapi.SetWebhookP{ + ok, err = bot.api.SetWebhookWithContext(runCtx, tgapi.SetWebhook{ URL: fmt.Sprintf("%s%s", opts.URL, opts.Path), IPAddress: opts.IPAddress, MaxConnections: opts.MaxConnections, @@ -229,7 +229,7 @@ func (bot *Bot[T]) CloseWebHook() error { if bot.api == nil { e = append(e, errors.New("bot api nil")) } else { - if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhookP{}); err != nil { + if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil { if bot.webHookLogger != nil { bot.webHookLogger.Errorf("Failed to close webhook: %s", err.Error()) } else if bot.logger != nil { diff --git a/cmd_generator.go b/cmd_generator.go index cf8f572..253757b 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -112,7 +112,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error { } // Clear existing commands to avoid duplication or stale entries - _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) + _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommands{}) if err != nil { return fmt.Errorf("failed to delete existing commands: %w", err) } @@ -125,7 +125,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error { } for _, scope := range scopes { - _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{ + _, err = bot.api.SetMyCommands(tgapi.SetMyCommands{ Commands: commands, Scope: scope, }) @@ -159,12 +159,12 @@ func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) er return ErrTooManyCommands } - _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope}) + _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommands{Scope: scope}) if err != nil { return fmt.Errorf("failed to delete existing commands: %w", err) } - _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{ + _, err = bot.api.SetMyCommands(tgapi.SetMyCommands{ Commands: commands, Scope: scope, }) diff --git a/drafts.go b/drafts.go index d550c60..033957a 100644 --- a/drafts.go +++ b/drafts.go @@ -221,7 +221,7 @@ func (d *Draft) Flush() error { return err } - params := tgapi.SendMessageP{ + params := tgapi.SendMessage{ ChatID: d.chatID, ParseMode: d.parseMode, Entities: d.entities, @@ -247,7 +247,7 @@ func (d *Draft) push(text string) error { if err := validateMessageText(d.Message); err != nil { return err } - params := tgapi.SendMessageDraftP{ + params := tgapi.SendMessageDraft{ ChatID: d.chatID, DraftID: d.ID, Text: d.Message, diff --git a/handler_test.go b/handler_test.go index 757f14d..a42b6ef 100644 --- a/handler_test.go +++ b/handler_test.go @@ -9,10 +9,6 @@ import ( "git.scuroneko.dev/scuroneko/slog" ) -func ptr[T any](v T) *T { - return &v -} - type recordingObserver struct { started []HandlerStartedEvent finished []HandlerFinishedEvent @@ -230,7 +226,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { CallbackQuery: &tgapi.CallbackQuery{ ID: "cb-2", From: tgapi.User{ID: 107}, - InlineMessageID: ptr("inline-42"), + InlineMessageID: new("inline-42"), }, }, wantFrom: true, @@ -907,7 +903,7 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { ID: "cb-inline", Data: data, From: tgapi.User{ID: 8}, - InlineMessageID: ptr("inline-55"), + InlineMessageID: new("inline-55"), }, }) diff --git a/methods.go b/methods.go index 2a55b77..4d4ac04 100644 --- a/methods.go +++ b/methods.go @@ -43,8 +43,8 @@ import ( func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { offset := bot.GetUpdateOffset() params := tgapi.UpdateParams{ - Offset: Ptr(offset), - Timeout: Ptr(30), + Offset: new(offset), + Timeout: new(30), AllowedUpdates: bot.GetUpdateTypes(), } diff --git a/msg_context.go b/msg_context.go index 9edb8b2..8f8f8e7 100644 --- a/msg_context.go +++ b/msg_context.go @@ -100,7 +100,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard ctx.Logger.Errorln(err) return nil } - params := tgapi.EditMessageTextP{ + params := tgapi.EditMessageText{ Text: text, ParseMode: parseMode, } @@ -184,7 +184,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo ctx.Logger.Errorln(err) return nil } - params := tgapi.EditMessageCaptionP{ + params := tgapi.EditMessageCaption{ Caption: text, ParseMode: parseMode, } @@ -250,7 +250,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t ctx.Logger.Errorln(err) return nil } - params := tgapi.SendMessageP{ + params := tgapi.SendMessage{ ChatID: ctx.Msg.Chat.ID, Text: text, ParseMode: parseMode, @@ -380,7 +380,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par ctx.Logger.Errorln(err) return nil } - params := tgapi.SendPhotoP{ + params := tgapi.SendPhoto{ ChatID: ctx.Msg.Chat.ID, Caption: text, ParseMode: parseMode, @@ -452,7 +452,7 @@ func (ctx *MsgContext) delete(messageId int) { ctx.Logger.Errorln(ErrMessageContextNil) return } - _, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessageP{ + _, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{ ChatID: ctx.Msg.Chat.ID, MessageID: messageId, }) @@ -478,7 +478,7 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryId) == 0 { return } - _, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQueryP{ + _, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{ CallbackQueryID: ctx.CallbackQueryId, Text: text, ShowAlert: showAlert, URL: url, }) @@ -505,7 +505,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { ctx.Logger.Errorln("Can't send action without chat message context") return } - params := tgapi.SendChatActionP{ + params := tgapi.SendChatAction{ ChatID: ctx.Msg.Chat.ID, Action: action, } if ctx.Msg.MessageThreadID > 0 { diff --git a/policy.go b/policy.go index d93fe9d..c331a99 100644 --- a/policy.go +++ b/policy.go @@ -143,7 +143,7 @@ func RequireChatAdmin[T AppData]() Policy[T] { return AsInternalError(errors.New("chat-admin policy requires message chat context")) } - member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{ + member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{ ChatID: ctx.ChatID, UserID: ctx.FromID, }) @@ -166,7 +166,7 @@ func RequireChatCreator[T AppData]() Policy[T] { return AsInternalError(errors.New("chat-creator policy requires message chat context")) } - member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{ + member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{ ChatID: ctx.ChatID, UserID: ctx.FromID, }) @@ -194,7 +194,7 @@ func RequireBotAdmin[T AppData]() Policy[T] { return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err)) } - member, err := ctx.Api.GetChatMember(tgapi.GetChatMemberP{ + member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{ ChatID: ctx.ChatID, UserID: bot.ID, }) diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 790cf1a..c4d3a5b 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// SendPhotoP holds parameters for the sendPhoto method. +// SendPhoto holds parameters for the sendPhoto method. // See https://core.telegram.org/bots/api#sendphoto -type SendPhotoP struct { +type SendPhoto struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -29,7 +29,7 @@ type SendPhotoP struct { // SendPhoto sends a photo. // See https://core.telegram.org/bots/api#sendphoto -func (api *API) SendPhoto(params SendPhotoP) (Message, error) { +func (api *API) SendPhoto(params SendPhoto) (Message, error) { req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID) return req.Do(api) } @@ -37,14 +37,14 @@ func (api *API) SendPhoto(params SendPhotoP) (Message, error) { // SendPhotoWithContext is the context-aware variant of SendPhoto. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendphoto -func (api *API) SendPhotoWithContext(ctx context.Context, params SendPhotoP) (Message, error) { +func (api *API) SendPhotoWithContext(ctx context.Context, params SendPhoto) (Message, error) { req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendAudioP holds parameters for the sendAudio method. +// SendAudio holds parameters for the sendAudio method. // See https://core.telegram.org/bots/api#sendaudio -type SendAudioP struct { +type SendAudio struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -71,7 +71,7 @@ type SendAudioP struct { // SendAudio sends an audio file. // See https://core.telegram.org/bots/api#sendaudio -func (api *API) SendAudio(params SendAudioP) (Message, error) { +func (api *API) SendAudio(params SendAudio) (Message, error) { req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID) return req.Do(api) } @@ -79,14 +79,14 @@ func (api *API) SendAudio(params SendAudioP) (Message, error) { // SendAudioWithContext is the context-aware variant of SendAudio. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendaudio -func (api *API) SendAudioWithContext(ctx context.Context, params SendAudioP) (Message, error) { +func (api *API) SendAudioWithContext(ctx context.Context, params SendAudio) (Message, error) { req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendDocumentP holds parameters for the sendDocument method. +// SendDocument holds parameters for the sendDocument method. // See https://core.telegram.org/bots/api#senddocument -type SendDocumentP struct { +type SendDocument struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -111,7 +111,7 @@ type SendDocumentP struct { // SendDocument sends a document. // See https://core.telegram.org/bots/api#senddocument -func (api *API) SendDocument(params SendDocumentP) (Message, error) { +func (api *API) SendDocument(params SendDocument) (Message, error) { req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID) return req.Do(api) } @@ -119,14 +119,14 @@ func (api *API) SendDocument(params SendDocumentP) (Message, error) { // SendDocumentWithContext is the context-aware variant of SendDocument. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#senddocument -func (api *API) SendDocumentWithContext(ctx context.Context, params SendDocumentP) (Message, error) { +func (api *API) SendDocumentWithContext(ctx context.Context, params SendDocument) (Message, error) { req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendVideoP holds parameters for the sendVideo method. +// SendVideo holds parameters for the sendVideo method. // See https://core.telegram.org/bots/api#sendvideo -type SendVideoP struct { +type SendVideo struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -159,7 +159,7 @@ type SendVideoP struct { // SendVideo sends a video. // See https://core.telegram.org/bots/api#sendvideo -func (api *API) SendVideo(params SendVideoP) (Message, error) { +func (api *API) SendVideo(params SendVideo) (Message, error) { req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID) return req.Do(api) } @@ -167,14 +167,14 @@ func (api *API) SendVideo(params SendVideoP) (Message, error) { // SendVideoWithContext is the context-aware variant of SendVideo. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvideo -func (api *API) SendVideoWithContext(ctx context.Context, params SendVideoP) (Message, error) { +func (api *API) SendVideoWithContext(ctx context.Context, params SendVideo) (Message, error) { req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendAnimationP holds parameters for the sendAnimation method. +// SendAnimation holds parameters for the sendAnimation method. // See https://core.telegram.org/bots/api#sendanimation -type SendAnimationP struct { +type SendAnimation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -203,7 +203,7 @@ type SendAnimationP struct { // SendAnimation sends an animation file (GIF or H.264/MPEG-4 AVC video without sound). // See https://core.telegram.org/bots/api#sendanimation -func (api *API) SendAnimation(params SendAnimationP) (Message, error) { +func (api *API) SendAnimation(params SendAnimation) (Message, error) { req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID) return req.Do(api) } @@ -211,14 +211,14 @@ func (api *API) SendAnimation(params SendAnimationP) (Message, error) { // SendAnimationWithContext is the context-aware variant of SendAnimation. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendanimation -func (api *API) SendAnimationWithContext(ctx context.Context, params SendAnimationP) (Message, error) { +func (api *API) SendAnimationWithContext(ctx context.Context, params SendAnimation) (Message, error) { req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendVoiceP holds parameters for the sendVoice method. +// SendVoice holds parameters for the sendVoice method. // See https://core.telegram.org/bots/api#sendvoice -type SendVoiceP struct { +type SendVoice struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -241,7 +241,7 @@ type SendVoiceP struct { // SendVoice sends a voice note. // See https://core.telegram.org/bots/api#sendvoice -func (api *API) SendVoice(params SendVoiceP) (Message, error) { +func (api *API) SendVoice(params SendVoice) (Message, error) { req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) return req.Do(api) } @@ -249,14 +249,14 @@ func (api *API) SendVoice(params SendVoiceP) (Message, error) { // SendVoiceWithContext is the context-aware variant of SendVoice. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvoice -func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoiceP) (Message, error) { +func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoice) (Message, error) { req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendVideoNoteP holds parameters for the sendVideoNote method. +// SendVideoNote holds parameters for the sendVideoNote method. // See https://core.telegram.org/bots/api#sendvideonote -type SendVideoNoteP struct { +type SendVideoNote struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -278,7 +278,7 @@ type SendVideoNoteP struct { // SendVideoNote sends a video note (rounded video message). // See https://core.telegram.org/bots/api#sendvideonote -func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) { +func (api *API) SendVideoNote(params SendVideoNote) (Message, error) { req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID) return req.Do(api) } @@ -286,14 +286,14 @@ func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) { // SendVideoNoteWithContext is the context-aware variant of SendVideoNote. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvideonote -func (api *API) SendVideoNoteWithContext(ctx context.Context, params SendVideoNoteP) (Message, error) { +func (api *API) SendVideoNoteWithContext(ctx context.Context, params SendVideoNote) (Message, error) { req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendPaidMediaP holds parameters for the sendPaidMedia method. +// SendPaidMedia holds parameters for the sendPaidMedia method. // See https://core.telegram.org/bots/api#sendpaidmedia -type SendPaidMediaP struct { +type SendPaidMedia struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -317,7 +317,7 @@ type SendPaidMediaP struct { // SendPaidMedia sends paid media. // See https://core.telegram.org/bots/api#sendpaidmedia -func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) { +func (api *API) SendPaidMedia(params SendPaidMedia) (Message, error) { req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID) return req.Do(api) } @@ -325,14 +325,14 @@ func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) { // SendPaidMediaWithContext is the context-aware variant of SendPaidMedia. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendpaidmedia -func (api *API) SendPaidMediaWithContext(ctx context.Context, params SendPaidMediaP) (Message, error) { +func (api *API) SendPaidMediaWithContext(ctx context.Context, params SendPaidMedia) (Message, error) { req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendMediaGroupP holds parameters for the sendMediaGroup method. +// SendMediaGroup holds parameters for the sendMediaGroup method. // See https://core.telegram.org/bots/api#sendmediagroup -type SendMediaGroupP struct { +type SendMediaGroup struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -348,7 +348,7 @@ type SendMediaGroupP struct { // SendMediaGroup sends a group of photos, videos, documents or audios as an album. // See https://core.telegram.org/bots/api#sendmediagroup -func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) { +func (api *API) SendMediaGroup(params SendMediaGroup) ([]Message, error) { req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID) return req.Do(api) } @@ -356,7 +356,7 @@ func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) { // SendMediaGroupWithContext is the context-aware variant of SendMediaGroup. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendmediagroup -func (api *API) SendMediaGroupWithContext(ctx context.Context, params SendMediaGroupP) ([]Message, error) { +func (api *API) SendMediaGroupWithContext(ctx context.Context, params SendMediaGroup) ([]Message, error) { req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID) return req.DoWithContext(ctx, api) } diff --git a/tgapi/attachments_types.go b/tgapi/attachments_types.go index c442a15..02944b5 100644 --- a/tgapi/attachments_types.go +++ b/tgapi/attachments_types.go @@ -1,5 +1,253 @@ package tgapi +type Animation struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` + + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + FileName string `json:"file_name"` + MimeType string `json:"mime_type"` + FileSize int `json:"file_size"` +} + +// Audio represents an audio file to be treated as music by the Telegram clients. +// See https://core.telegram.org/bots/api#audio +type Audio struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Duration int `json:"duration"` + + Performer string `json:"performer,omitempty"` + Title string `json:"title,omitempty"` + FileName string `json:"file_name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + FileSize int64 `json:"file_size,omitempty"` + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` +} + +type Document struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + FileName string `json:"file_name"` + MimeType string `json:"mime_type"` + FileSize int `json:"file_size,omitempty"` +} + +// Story represents a story. +type Story struct { + Chat Chat `json:"chat"` + ID int `json:"id"` +} + +type Video struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` + + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + Cover []PhotoSize `json:"cover,omitempty"` + StartTimestamp int64 `json:"start_timestamp"` + Qualities []VideoQuality `json:"qualities,omitempty"` + FileName string `json:"file_name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + FileSize int64 `json:"file_size,omitempty"` +} + +// VideoQuality describes an alternative quality for a video. +// See https://core.telegram.org/bots/api#videoquality +type VideoQuality struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Width int `json:"width"` + Height int `json:"height"` + Codec string `json:"codec"` + FileSize int64 `json:"file_size,omitempty"` +} + +type VideoNote struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Length int `json:"length"` + Duration int `json:"duration"` + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + FileSize int64 `json:"file_size,omitempty"` +} + +type Voice struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Duration int `json:"duration"` + MimeType string `json:"mime_type,omitempty"` + FileSize int `json:"file_size,omitempty"` +} + +type PaidMediaInfo struct { + StarCount int `json:"star_count"` + PaidMedia []PaidMedia `json:"paid_media"` +} +type PaidMediaType string + +const ( + PaidMediaPreviewType PaidMediaType = "preview" + PaidMediaPhotoType PaidMediaType = "photo" + PaidMediaVideoType PaidMediaType = "video" +) + +type PaidMedia struct { + Type PaidMediaType `json:"type,omitempty"` + + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration int `json:"duration,omitempty"` + + Photo []PhotoSize `json:"photo,omitempty"` + + Video *Video `json:"video,omitempty"` +} + +type Contact struct { + PhoneNumber string `json:"phone_number"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + UserID int64 `json:"user_id,omitempty"` + Vcard string `json:"vcard,omitempty"` +} + +type Dice struct { + Emoji string `json:"emoji"` + Value int `json:"value"` +} + +// PollOption contains information about one answer option in a poll. +// See https://core.telegram.org/bots/api#polloption +type PollOption struct { + PersistentID string `json:"persistent_id"` + Text string `json:"text"` + TextEntities []MessageEntity `json:"text_entities"` + VoterCount int `json:"voter_count"` + + AddedByUser *User `json:"added_by_user,omitempty"` + AddedByChat *Chat `json:"added_by_chat,omitempty"` + AdditionDate int `json:"addition_date,omitempty"` +} + +// InputPollOption contains information about one answer option in a poll to be sent. +// See https://core.telegram.org/bots/api#inputpolloption +type InputPollOption struct { + Text string `json:"text"` + TextParseMode ParseMode `json:"text_parse_mode,omitempty"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` +} + +type PollOptionAdded struct { + PollMessage *InaccessibleMessage `json:"poll_message,omitempty"` + OptionPersistentID string `json:"option_persistent_id"` + OptionText string `json:"option_text"` + OptionTextEntities []MessageEntity `json:"option_text_entities,omitempty"` +} + +type PollOptionDeleted struct { + PollMessage *InaccessibleMessage `json:"poll_message,omitempty"` + OptionPersistentID string `json:"option_persistent_id"` + OptionText string `json:"option_text"` + OptionTextEntities []MessageEntity `json:"option_text_entities,omitempty"` +} + +// PollType represents the type of a poll. +type PollType string + +const ( + // PollTypeRegular identifies a regular poll. + PollTypeRegular PollType = "regular" + // PollTypeQuiz identifies a quiz poll. + PollTypeQuiz PollType = "quiz" +) + +// PollAnswer represents an answer of a user in a poll. +// See https://core.telegram.org/bots/api#pollanswer +type PollAnswer struct { + PollID string `json:"poll_id"` + VoterChat Chat `json:"voter_chat"` + User User `json:"user"` + OptionIDs []int `json:"option_ids"` + OptionPersistentIDs []string `json:"option_persistent_ids"` +} + +// Poll contains information about a poll. +// See https://core.telegram.org/bots/api#poll +type Poll struct { + ID string `json:"id"` + Question string `json:"question"` + QuestionEntities []MessageEntity `json:"question_entities"` + Options []PollOption `json:"options"` + TotalVoterCount int `json:"total_voter_count"` + IsClosed bool `json:"is_closed"` + IsAnonymous bool `json:"is_anonymous"` + Type PollType `json:"type"` + + AllowsMultipleAnswers bool `json:"allows_multiple_answers"` + AllowsRevoting bool `json:"allows_revoting"` + CorrectOptionIDs []int `json:"correct_option_ids,omitempty"` + Explanation string `json:"explanation,omitempty"` + ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"` + OpenPeriod int `json:"open_period,omitempty"` + CloseDate int `json:"close_date,omitempty"` + Description string `json:"description,omitempty"` + DescriptionEntities []MessageEntity `json:"description_entities,omitempty"` +} + +type ChecklistTask struct { + ID int `json:"id"` + Text string `json:"text"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` + CompletedByUser *User `json:"completed_by_user,omitempty"` + CompletedByChat *Chat `json:"completed_by_chat,omitempty"` + CompletionDate int `json:"completion_date,omitempty"` +} + +type Checklist struct { + Title string `json:"title"` + TitleEntities []MessageEntity `json:"title_entities,omitempty"` + Tasks []ChecklistTask `json:"tasks"` + OthersCanAddTasks bool `json:"others_can_add_tasks,omitempty"` + OthersCanMarkTasksAsDone bool `json:"others_can_mark_tasks_as_done,omitempty"` +} + +// InputChecklistTask describes a task in a checklist. +type InputChecklistTask struct { + ID int `json:"id"` + Text string `json:"text"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` +} + +// InputChecklist represents a checklist to be sent. +type InputChecklist struct { + Title string `json:"title"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + TitleEntities []MessageEntity `json:"title_entities,omitempty"` + Tasks []InputChecklistTask `json:"tasks"` + OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"` + OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"` +} + +type ChecklistTaskDone struct { + ChecklistMessage *Message `json:"checklist_message,omitempty"` + MarkedAsDoneTaskIDs []int `json:"marked_as_done_task_ids,omitempty"` + MarkedAsNotDoneTaskIDs []int `json:"marked_as_not_done_task_ids,omitempty"` +} + +type ChecklistTasksAdded struct { + ChecklistMessage *Message `json:"checklist_message,omitempty"` + Tasks []ChecklistTask `json:"tasks"` +} + // InputMediaType represents the type of input media. type InputMediaType string diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index a22afd2..c79fc08 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// SetMyCommandsP holds parameters for the setMyCommands method. +// SetMyCommands holds parameters for the setMyCommands method. // See https://core.telegram.org/bots/api#setmycommands -type SetMyCommandsP struct { +type SetMyCommands struct { Commands []BotCommand `json:"commands"` Scope *BotCommandScope `json:"scope,omitempty"` Language string `json:"language_code,omitempty"` @@ -13,7 +13,7 @@ type SetMyCommandsP struct { // SetMyCommands changes the list of the bot's commands. // Returns true on success. // See https://core.telegram.org/bots/api#setmycommands -func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) { +func (api *API) SetMyCommands(params SetMyCommands) (bool, error) { req := NewRequest[bool]("setMyCommands", params) return req.Do(api) } @@ -21,14 +21,14 @@ func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) { // SetMyCommandsWithContext is the context-aware variant of SetMyCommands. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmycommands -func (api *API) SetMyCommandsWithContext(ctx context.Context, params SetMyCommandsP) (bool, error) { +func (api *API) SetMyCommandsWithContext(ctx context.Context, params SetMyCommands) (bool, error) { req := NewRequest[bool]("setMyCommands", params) return req.DoWithContext(ctx, api) } -// DeleteMyCommandsP holds parameters for the deleteMyCommands method. +// DeleteMyCommands holds parameters for the deleteMyCommands method. // See https://core.telegram.org/bots/api#deletemycommands -type DeleteMyCommandsP struct { +type DeleteMyCommands struct { Scope *BotCommandScope `json:"scope,omitempty"` Language string `json:"language_code,omitempty"` } @@ -36,7 +36,7 @@ type DeleteMyCommandsP struct { // DeleteMyCommands deletes the list of the bot's commands for the given scope and user language. // Returns true on success. // See https://core.telegram.org/bots/api#deletemycommands -func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) { +func (api *API) DeleteMyCommands(params DeleteMyCommands) (bool, error) { req := NewRequest[bool]("deleteMyCommands", params) return req.Do(api) } @@ -44,7 +44,7 @@ func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) { // DeleteMyCommandsWithContext is the context-aware variant of DeleteMyCommands. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletemycommands -func (api *API) DeleteMyCommandsWithContext(ctx context.Context, params DeleteMyCommandsP) (bool, error) { +func (api *API) DeleteMyCommandsWithContext(ctx context.Context, params DeleteMyCommands) (bool, error) { req := NewRequest[bool]("deleteMyCommands", params) return req.DoWithContext(ctx, api) } @@ -203,16 +203,16 @@ func (api *API) GetMyShortDescriptionWithContext(ctx context.Context, params Get return req.DoWithContext(ctx, api) } -// SetMyProfilePhotoP holds parameters for the setMyProfilePhoto method. +// SetMyProfilePhoto holds parameters for the setMyProfilePhoto method. // See https://core.telegram.org/bots/api#setmyprofilephoto -type SetMyProfilePhotoP struct { +type SetMyProfilePhoto struct { Photo InputProfilePhoto `json:"photo"` } // SetMyProfilePhoto changes the bot's profile photo. // Returns true on success. // See https://core.telegram.org/bots/api#setmyprofilephoto -func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) { +func (api *API) SetMyProfilePhoto(params SetMyProfilePhoto) (bool, error) { req := NewRequest[bool]("setMyProfilePhoto", params) return req.Do(api) } @@ -220,7 +220,7 @@ func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) { // SetMyProfilePhotoWithContext is the context-aware variant of SetMyProfilePhoto. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmyprofilephoto -func (api *API) SetMyProfilePhotoWithContext(ctx context.Context, params SetMyProfilePhotoP) (bool, error) { +func (api *API) SetMyProfilePhotoWithContext(ctx context.Context, params SetMyProfilePhoto) (bool, error) { req := NewRequest[bool]("setMyProfilePhoto", params) return req.DoWithContext(ctx, api) } @@ -241,17 +241,17 @@ func (api *API) RemoveMyProfilePhotoWithContext(ctx context.Context) (bool, erro return req.DoWithContext(ctx, api) } -// SetChatMenuButtonP holds parameters for the setChatMenuButton method. +// SetChatMenuButton holds parameters for the setChatMenuButton method. // See https://core.telegram.org/bots/api#setchatmenubutton -type SetChatMenuButtonP struct { - ChatID int64 `json:"chat_id,omitempty"` - MenuButton MenuButtonType `json:"menu_button"` +type SetChatMenuButton struct { + ChatID int64 `json:"chat_id,omitempty"` + MenuButton *MenuButton `json:"menu_button,omitempty"` } // SetChatMenuButton changes the menu button for a given chat or the default menu button. // Returns true on success. // See https://core.telegram.org/bots/api#setchatmenubutton -func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) { +func (api *API) SetChatMenuButton(params SetChatMenuButton) (bool, error) { req := NewRequest[bool]("setChatMenuButton", params) return req.Do(api) } @@ -259,20 +259,20 @@ func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) { // SetChatMenuButtonWithContext is the context-aware variant of SetChatMenuButton. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatmenubutton -func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChatMenuButtonP) (bool, error) { +func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChatMenuButton) (bool, error) { req := NewRequest[bool]("setChatMenuButton", params) return req.DoWithContext(ctx, api) } -// GetChatMenuButtonP holds parameters for the getChatMenuButton method. +// GetChatMenuButton holds parameters for the getChatMenuButton method. // See https://core.telegram.org/bots/api#getchatmenubutton -type GetChatMenuButtonP struct { +type GetChatMenuButton struct { ChatID int64 `json:"chat_id,omitempty"` } // GetChatMenuButton returns the current menu button for the given chat. // See https://core.telegram.org/bots/api#getchatmenubutton -func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (MenuButton, error) { +func (api *API) GetChatMenuButton(params GetChatMenuButton) (MenuButton, error) { req := NewRequest[MenuButton]("getChatMenuButton", params) return req.Do(api) } @@ -280,14 +280,14 @@ func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (MenuButton, error) // GetChatMenuButtonWithContext is the context-aware variant of GetChatMenuButton. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatmenubutton -func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (MenuButton, error) { +func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButton) (MenuButton, error) { req := NewRequest[MenuButton]("getChatMenuButton", params) return req.DoWithContext(ctx, api) } -// SetMyDefaultAdministratorRightsP holds parameters for the setMyDefaultAdministratorRights method. +// SetMyDefaultAdministratorRights holds parameters for the setMyDefaultAdministratorRights method. // See https://core.telegram.org/bots/api#setmydefaultadministratorrights -type SetMyDefaultAdministratorRightsP struct { +type SetMyDefaultAdministratorRights struct { Rights *ChatAdministratorRights `json:"rights"` ForChannels bool `json:"for_channels"` } @@ -295,7 +295,7 @@ type SetMyDefaultAdministratorRightsP struct { // SetMyDefaultAdministratorRights changes the default administrator rights for the bot. // Returns true on success. // See https://core.telegram.org/bots/api#setmydefaultadministratorrights -func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministratorRightsP) (bool, error) { +func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministratorRights) (bool, error) { req := NewRequest[bool]("setMyDefaultAdministratorRights", params) return req.Do(api) } @@ -303,20 +303,20 @@ func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministrator // SetMyDefaultAdministratorRightsWithContext is the context-aware variant of SetMyDefaultAdministratorRights. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmydefaultadministratorrights -func (api *API) SetMyDefaultAdministratorRightsWithContext(ctx context.Context, params SetMyDefaultAdministratorRightsP) (bool, error) { +func (api *API) SetMyDefaultAdministratorRightsWithContext(ctx context.Context, params SetMyDefaultAdministratorRights) (bool, error) { req := NewRequest[bool]("setMyDefaultAdministratorRights", params) return req.DoWithContext(ctx, api) } -// GetMyDefaultAdministratorRightsP holds parameters for the getMyDefaultAdministratorRights method. +// GetMyDefaultAdministratorRights holds parameters for the getMyDefaultAdministratorRights method. // See https://core.telegram.org/bots/api#getmydefaultadministratorrights -type GetMyDefaultAdministratorRightsP struct { +type GetMyDefaultAdministratorRights struct { ForChannels bool `json:"for_channels"` } // GetMyDefaultAdministratorRights returns the current default administrator rights for the bot. // See https://core.telegram.org/bots/api#getmydefaultadministratorrights -func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministratorRightsP) (ChatAdministratorRights, error) { +func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministratorRights) (ChatAdministratorRights, error) { req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params) return req.Do(api) } @@ -324,7 +324,7 @@ func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministrator // GetMyDefaultAdministratorRightsWithContext is the context-aware variant of GetMyDefaultAdministratorRights. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getmydefaultadministratorrights -func (api *API) GetMyDefaultAdministratorRightsWithContext(ctx context.Context, params GetMyDefaultAdministratorRightsP) (ChatAdministratorRights, error) { +func (api *API) GetMyDefaultAdministratorRightsWithContext(ctx context.Context, params GetMyDefaultAdministratorRights) (ChatAdministratorRights, error) { req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params) return req.DoWithContext(ctx, api) } @@ -344,9 +344,9 @@ func (api *API) GetAvailableGiftsWithContext(ctx context.Context) (Gifts, error) return req.DoWithContext(ctx, api) } -// SendGiftP holds parameters for the sendGift method. +// SendGift holds parameters for the sendGift method. // See https://core.telegram.org/bots/api#sendgift -type SendGiftP struct { +type SendGift struct { UserID int64 `json:"user_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` GiftID string `json:"gift_id"` @@ -359,7 +359,7 @@ type SendGiftP struct { // SendGift sends a gift to the given user or chat. // Returns true on success. // See https://core.telegram.org/bots/api#sendgift -func (api *API) SendGift(params SendGiftP) (bool, error) { +func (api *API) SendGift(params SendGift) (bool, error) { req := NewRequest[bool]("sendGift", params) return req.Do(api) } @@ -367,14 +367,14 @@ func (api *API) SendGift(params SendGiftP) (bool, error) { // SendGiftWithContext is the context-aware variant of SendGift. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendgift -func (api *API) SendGiftWithContext(ctx context.Context, params SendGiftP) (bool, error) { +func (api *API) SendGiftWithContext(ctx context.Context, params SendGift) (bool, error) { req := NewRequest[bool]("sendGift", params) return req.DoWithContext(ctx, api) } -// GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method. +// GiftPremiumSubscription holds parameters for the giftPremiumSubscription method. // See https://core.telegram.org/bots/api#giftpremiumsubscription -type GiftPremiumSubscriptionP struct { +type GiftPremiumSubscription struct { UserID int64 `json:"user_id"` MonthCount int `json:"month_count"` StarCount int `json:"star_count"` @@ -386,7 +386,7 @@ type GiftPremiumSubscriptionP struct { // GiftPremiumSubscription gifts a Telegram Premium subscription to the user. // Returns true on success. // See https://core.telegram.org/bots/api#giftpremiumsubscription -func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool, error) { +func (api *API) GiftPremiumSubscription(params GiftPremiumSubscription) (bool, error) { req := NewRequest[bool]("giftPremiumSubscription", params) return req.Do(api) } @@ -394,7 +394,7 @@ func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool, // GiftPremiumSubscriptionWithContext is the context-aware variant of GiftPremiumSubscription. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#giftpremiumsubscription -func (api *API) GiftPremiumSubscriptionWithContext(ctx context.Context, params GiftPremiumSubscriptionP) (bool, error) { +func (api *API) GiftPremiumSubscriptionWithContext(ctx context.Context, params GiftPremiumSubscription) (bool, error) { req := NewRequest[bool]("giftPremiumSubscription", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/business_methods.go b/tgapi/business_methods.go index e86a4c7..e3f8274 100644 --- a/tgapi/business_methods.go +++ b/tgapi/business_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// VerifyUserP holds parameters for the verifyUser method. +// VerifyUser holds parameters for the verifyUser method. // See https://core.telegram.org/bots/api#verifyuser -type VerifyUserP struct { +type VerifyUser struct { UserID int64 `json:"user_id"` CustomDescription string `json:"custom_description,omitempty"` } @@ -12,7 +12,7 @@ type VerifyUserP struct { // VerifyUser verifies a user. // Returns true on success. // See https://core.telegram.org/bots/api#verifyuser -func (api *API) VerifyUser(params VerifyUserP) (bool, error) { +func (api *API) VerifyUser(params VerifyUser) (bool, error) { req := NewRequest[bool]("verifyUser", params) return req.Do(api) } @@ -20,14 +20,14 @@ func (api *API) VerifyUser(params VerifyUserP) (bool, error) { // VerifyUserWithContext is the context-aware variant of VerifyUser. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#verifyuser -func (api *API) VerifyUserWithContext(ctx context.Context, params VerifyUserP) (bool, error) { +func (api *API) VerifyUserWithContext(ctx context.Context, params VerifyUser) (bool, error) { req := NewRequest[bool]("verifyUser", params) return req.DoWithContext(ctx, api) } -// VerifyChatP holds parameters for the verifyChat method. +// VerifyChat holds parameters for the verifyChat method. // See https://core.telegram.org/bots/api#verifychat -type VerifyChatP struct { +type VerifyChat struct { ChatID int64 `json:"chat_id"` CustomDescription string `json:"custom_description,omitempty"` } @@ -35,7 +35,7 @@ type VerifyChatP struct { // VerifyChat verifies a chat. // Returns true on success. // See https://core.telegram.org/bots/api#verifychat -func (api *API) VerifyChat(params VerifyChatP) (bool, error) { +func (api *API) VerifyChat(params VerifyChat) (bool, error) { req := NewRequest[bool]("verifyChat", params) return req.Do(api) } @@ -43,21 +43,21 @@ func (api *API) VerifyChat(params VerifyChatP) (bool, error) { // VerifyChatWithContext is the context-aware variant of VerifyChat. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#verifychat -func (api *API) VerifyChatWithContext(ctx context.Context, params VerifyChatP) (bool, error) { +func (api *API) VerifyChatWithContext(ctx context.Context, params VerifyChat) (bool, error) { req := NewRequest[bool]("verifyChat", params) return req.DoWithContext(ctx, api) } -// RemoveUserVerificationP holds parameters for the removeUserVerification method. +// RemoveUserVerification holds parameters for the removeUserVerification method. // See https://core.telegram.org/bots/api#removeuserverification -type RemoveUserVerificationP struct { +type RemoveUserVerification struct { UserID int64 `json:"user_id"` } // RemoveUserVerification removes a user's verification. // Returns true on success. // See https://core.telegram.org/bots/api#removeuserverification -func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, error) { +func (api *API) RemoveUserVerification(params RemoveUserVerification) (bool, error) { req := NewRequest[bool]("removeUserVerification", params) return req.Do(api) } @@ -65,21 +65,21 @@ func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, er // RemoveUserVerificationWithContext is the context-aware variant of RemoveUserVerification. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#removeuserverification -func (api *API) RemoveUserVerificationWithContext(ctx context.Context, params RemoveUserVerificationP) (bool, error) { +func (api *API) RemoveUserVerificationWithContext(ctx context.Context, params RemoveUserVerification) (bool, error) { req := NewRequest[bool]("removeUserVerification", params) return req.DoWithContext(ctx, api) } -// RemoveChatVerificationP holds parameters for the removeChatVerification method. +// RemoveChatVerification holds parameters for the removeChatVerification method. // See https://core.telegram.org/bots/api#removechatverification -type RemoveChatVerificationP struct { +type RemoveChatVerification struct { ChatID int64 `json:"chat_id"` } // RemoveChatVerification removes a chat's verification. // Returns true on success. // See https://core.telegram.org/bots/api#removechatverification -func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, error) { +func (api *API) RemoveChatVerification(params RemoveChatVerification) (bool, error) { req := NewRequest[bool]("removeChatVerification", params) return req.Do(api) } @@ -87,14 +87,14 @@ func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, er // RemoveChatVerificationWithContext is the context-aware variant of RemoveChatVerification. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#removechatverification -func (api *API) RemoveChatVerificationWithContext(ctx context.Context, params RemoveChatVerificationP) (bool, error) { +func (api *API) RemoveChatVerificationWithContext(ctx context.Context, params RemoveChatVerification) (bool, error) { req := NewRequest[bool]("removeChatVerification", params) return req.DoWithContext(ctx, api) } -// ReadBusinessMessageP holds parameters for the readBusinessMessage method. +// ReadBusinessMessage holds parameters for the readBusinessMessage method. // See https://core.telegram.org/bots/api#readbusinessmessage -type ReadBusinessMessageP struct { +type ReadBusinessMessage struct { BusinessConnectionID string `json:"business_connection_id"` ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` @@ -103,7 +103,7 @@ type ReadBusinessMessageP struct { // ReadBusinessMessage marks a business message as read. // Returns true on success. // See https://core.telegram.org/bots/api#readbusinessmessage -func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) { +func (api *API) ReadBusinessMessage(params ReadBusinessMessage) (bool, error) { req := NewRequest[bool]("readBusinessMessage", params) return req.Do(api) } @@ -111,20 +111,20 @@ func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) { // ReadBusinessMessageWithContext is the context-aware variant of ReadBusinessMessage. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#readbusinessmessage -func (api *API) ReadBusinessMessageWithContext(ctx context.Context, params ReadBusinessMessageP) (bool, error) { +func (api *API) ReadBusinessMessageWithContext(ctx context.Context, params ReadBusinessMessage) (bool, error) { req := NewRequest[bool]("readBusinessMessage", params) return req.DoWithContext(ctx, api) } -// GetBusinessConnectionP holds parameters for the getBusinessConnection method. +// GetBusinessConnection holds parameters for the getBusinessConnection method. // See https://core.telegram.org/bots/api#getbusinessconnection -type GetBusinessConnectionP struct { +type GetBusinessConnection struct { BusinessConnectionID string `json:"business_connection_id"` } // GetBusinessConnection returns information about a business connection. // See https://core.telegram.org/bots/api#getbusinessconnection -func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessConnection, error) { +func (api *API) GetBusinessConnection(params GetBusinessConnection) (BusinessConnection, error) { req := NewRequest[BusinessConnection]("getBusinessConnection", params) return req.Do(api) } @@ -132,14 +132,14 @@ func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessCo // GetBusinessConnectionWithContext is the context-aware variant of GetBusinessConnection. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getbusinessconnection -func (api *API) GetBusinessConnectionWithContext(ctx context.Context, params GetBusinessConnectionP) (BusinessConnection, error) { +func (api *API) GetBusinessConnectionWithContext(ctx context.Context, params GetBusinessConnection) (BusinessConnection, error) { req := NewRequest[BusinessConnection]("getBusinessConnection", params) return req.DoWithContext(ctx, api) } -// DeleteBusinessMessagesP holds parameters for the deleteBusinessMessages method. +// DeleteBusinessMessages holds parameters for the deleteBusinessMessages method. // See https://core.telegram.org/bots/api#deletebusinessmessages -type DeleteBusinessMessagesP struct { +type DeleteBusinessMessages struct { BusinessConnectionID string `json:"business_connection_id"` MessageIDs []int `json:"message_ids"` } @@ -147,7 +147,7 @@ type DeleteBusinessMessagesP struct { // DeleteBusinessMessages deletes business messages. // Returns true on success. // See https://core.telegram.org/bots/api#deletebusinessmessages -func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, error) { +func (api *API) DeleteBusinessMessages(params DeleteBusinessMessages) (bool, error) { req := NewRequest[bool]("deleteBusinessMessages", params) return req.Do(api) } @@ -155,14 +155,14 @@ func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, er // DeleteBusinessMessagesWithContext is the context-aware variant of DeleteBusinessMessages. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletebusinessmessages -func (api *API) DeleteBusinessMessagesWithContext(ctx context.Context, params DeleteBusinessMessagesP) (bool, error) { +func (api *API) DeleteBusinessMessagesWithContext(ctx context.Context, params DeleteBusinessMessages) (bool, error) { req := NewRequest[bool]("deleteBusinessMessages", params) return req.DoWithContext(ctx, api) } -// SetBusinessAccountNameP holds parameters for the setBusinessAccountName method. +// SetBusinessAccountName holds parameters for the setBusinessAccountName method. // See https://core.telegram.org/bots/api#setbusinessaccountname -type SetBusinessAccountNameP struct { +type SetBusinessAccountName struct { BusinessConnectionID string `json:"business_connection_id"` FirstName string `json:"first_name"` LastName string `json:"last_name,omitempty"` @@ -171,7 +171,7 @@ type SetBusinessAccountNameP struct { // SetBusinessAccountName sets the first and last name of a business account. // Returns true on success. // See https://core.telegram.org/bots/api#setbusinessaccountname -func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, error) { +func (api *API) SetBusinessAccountName(params SetBusinessAccountName) (bool, error) { req := NewRequest[bool]("setBusinessAccountName", params) return req.Do(api) } @@ -179,14 +179,14 @@ func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, er // SetBusinessAccountNameWithContext is the context-aware variant of SetBusinessAccountName. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setbusinessaccountname -func (api *API) SetBusinessAccountNameWithContext(ctx context.Context, params SetBusinessAccountNameP) (bool, error) { +func (api *API) SetBusinessAccountNameWithContext(ctx context.Context, params SetBusinessAccountName) (bool, error) { req := NewRequest[bool]("setBusinessAccountName", params) return req.DoWithContext(ctx, api) } -// SetBusinessAccountUsernameP holds parameters for the setBusinessAccountUsername method. +// SetBusinessAccountUsername holds parameters for the setBusinessAccountUsername method. // See https://core.telegram.org/bots/api#setbusinessaccountusername -type SetBusinessAccountUsernameP struct { +type SetBusinessAccountUsername struct { BusinessConnectionID string `json:"business_connection_id"` Username string `json:"username,omitempty"` } @@ -194,7 +194,7 @@ type SetBusinessAccountUsernameP struct { // SetBusinessAccountUsername sets the username of a business account. // Returns true on success. // See https://core.telegram.org/bots/api#setbusinessaccountusername -func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) (bool, error) { +func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsername) (bool, error) { req := NewRequest[bool]("setBusinessAccountUsername", params) return req.Do(api) } @@ -202,14 +202,14 @@ func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) ( // SetBusinessAccountUsernameWithContext is the context-aware variant of SetBusinessAccountUsername. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setbusinessaccountusername -func (api *API) SetBusinessAccountUsernameWithContext(ctx context.Context, params SetBusinessAccountUsernameP) (bool, error) { +func (api *API) SetBusinessAccountUsernameWithContext(ctx context.Context, params SetBusinessAccountUsername) (bool, error) { req := NewRequest[bool]("setBusinessAccountUsername", params) return req.DoWithContext(ctx, api) } -// SetBusinessAccountBioP holds parameters for the setBusinessAccountBio method. +// SetBusinessAccountBio holds parameters for the setBusinessAccountBio method. // See https://core.telegram.org/bots/api#setbusinessaccountbio -type SetBusinessAccountBioP struct { +type SetBusinessAccountBio struct { BusinessConnectionID string `json:"business_connection_id"` Bio string `json:"bio,omitempty"` } @@ -217,7 +217,7 @@ type SetBusinessAccountBioP struct { // SetBusinessAccountBio sets the bio of a business account. // Returns true on success. // See https://core.telegram.org/bots/api#setbusinessaccountbio -func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, error) { +func (api *API) SetBusinessAccountBio(params SetBusinessAccountBio) (bool, error) { req := NewRequest[bool]("setBusinessAccountBio", params) return req.Do(api) } @@ -225,7 +225,7 @@ func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, erro // SetBusinessAccountBioWithContext is the context-aware variant of SetBusinessAccountBio. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setbusinessaccountbio -func (api *API) SetBusinessAccountBioWithContext(ctx context.Context, params SetBusinessAccountBioP) (bool, error) { +func (api *API) SetBusinessAccountBioWithContext(ctx context.Context, params SetBusinessAccountBio) (bool, error) { req := NewRequest[bool]("setBusinessAccountBio", params) return req.DoWithContext(ctx, api) } @@ -254,9 +254,9 @@ func (api *API) SetBusinessAccountProfilePhotoWithContext(ctx context.Context, p return req.DoWithContext(ctx, api) } -// RemoveBusinessAccountProfilePhotoP holds parameters for the removeBusinessAccountProfilePhoto method. +// RemoveBusinessAccountProfilePhoto holds parameters for the removeBusinessAccountProfilePhoto method. // See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto -type RemoveBusinessAccountProfilePhotoP struct { +type RemoveBusinessAccountProfilePhoto struct { BusinessConnectionID string `json:"business_connection_id"` IsPublic bool `json:"is_public,omitempty"` } @@ -264,7 +264,7 @@ type RemoveBusinessAccountProfilePhotoP struct { // RemoveBusinessAccountProfilePhoto removes the profile photo of a business account. // Returns true on success. // See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto -func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountProfilePhotoP) (bool, error) { +func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountProfilePhoto) (bool, error) { req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params) return req.Do(api) } @@ -272,14 +272,14 @@ func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountPr // RemoveBusinessAccountProfilePhotoWithContext is the context-aware variant of RemoveBusinessAccountProfilePhoto. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto -func (api *API) RemoveBusinessAccountProfilePhotoWithContext(ctx context.Context, params RemoveBusinessAccountProfilePhotoP) (bool, error) { +func (api *API) RemoveBusinessAccountProfilePhotoWithContext(ctx context.Context, params RemoveBusinessAccountProfilePhoto) (bool, error) { req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params) return req.DoWithContext(ctx, api) } -// SetBusinessAccountGiftSettingsP holds parameters for the setBusinessAccountGiftSettings method. +// SetBusinessAccountGiftSettings holds parameters for the setBusinessAccountGiftSettings method. // See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings -type SetBusinessAccountGiftSettingsP struct { +type SetBusinessAccountGiftSettings struct { BusinessConnectionID string `json:"business_connection_id"` ShowGiftButton bool `json:"show_gift_button"` AcceptedGiftTypes AcceptedGiftTypes `json:"accepted_gift_types"` @@ -288,7 +288,7 @@ type SetBusinessAccountGiftSettingsP struct { // SetBusinessAccountGiftSettings sets gift settings for a business account. // Returns true on success. // See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings -func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSettingsP) (bool, error) { +func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSettings) (bool, error) { req := NewRequest[bool]("setBusinessAccountGiftSettings", params) return req.Do(api) } @@ -296,20 +296,20 @@ func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSett // SetBusinessAccountGiftSettingsWithContext is the context-aware variant of SetBusinessAccountGiftSettings. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings -func (api *API) SetBusinessAccountGiftSettingsWithContext(ctx context.Context, params SetBusinessAccountGiftSettingsP) (bool, error) { +func (api *API) SetBusinessAccountGiftSettingsWithContext(ctx context.Context, params SetBusinessAccountGiftSettings) (bool, error) { req := NewRequest[bool]("setBusinessAccountGiftSettings", params) return req.DoWithContext(ctx, api) } -// GetBusinessAccountStarBalanceP holds parameters for the getBusinessAccountStarBalance method. +// GetBusinessAccountStarBalance holds parameters for the getBusinessAccountStarBalance method. // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance -type GetBusinessAccountStarBalanceP struct { +type GetBusinessAccountStarBalance struct { BusinessConnectionID string `json:"business_connection_id"` } // GetBusinessAccountStarBalance returns the star balance of a business account. // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance -func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalanceP) (StarAmount, error) { +func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalance) (StarAmount, error) { req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params) return req.Do(api) } @@ -317,14 +317,14 @@ func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalan // GetBusinessAccountStarBalanceWithContext is the context-aware variant of GetBusinessAccountStarBalance. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance -func (api *API) GetBusinessAccountStarBalanceWithContext(ctx context.Context, params GetBusinessAccountStarBalanceP) (StarAmount, error) { +func (api *API) GetBusinessAccountStarBalanceWithContext(ctx context.Context, params GetBusinessAccountStarBalance) (StarAmount, error) { req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params) return req.DoWithContext(ctx, api) } -// TransferBusinessAccountStarsP holds parameters for the transferBusinessAccountStars method. +// TransferBusinessAccountStars holds parameters for the transferBusinessAccountStars method. // See https://core.telegram.org/bots/api#transferbusinessaccountstars -type TransferBusinessAccountStarsP struct { +type TransferBusinessAccountStars struct { BusinessConnectionID string `json:"business_connection_id"` StarCount int `json:"star_count"` } @@ -332,7 +332,7 @@ type TransferBusinessAccountStarsP struct { // TransferBusinessAccountStars transfers stars from a business account. // Returns true on success. // See https://core.telegram.org/bots/api#transferbusinessaccountstars -func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStarsP) (bool, error) { +func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars) (bool, error) { req := NewRequest[bool]("transferBusinessAccountStars", params) return req.Do(api) } @@ -340,14 +340,14 @@ func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars // TransferBusinessAccountStarsWithContext is the context-aware variant of TransferBusinessAccountStars. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#transferbusinessaccountstars -func (api *API) TransferBusinessAccountStarsWithContext(ctx context.Context, params TransferBusinessAccountStarsP) (bool, error) { +func (api *API) TransferBusinessAccountStarsWithContext(ctx context.Context, params TransferBusinessAccountStars) (bool, error) { req := NewRequest[bool]("transferBusinessAccountStars", params) return req.DoWithContext(ctx, api) } -// GetBusinessAccountGiftsP holds parameters for the getBusinessAccountGifts method. +// GetBusinessAccountGifts holds parameters for the getBusinessAccountGifts method. // See https://core.telegram.org/bots/api#getbusinessaccountgifts -type GetBusinessAccountGiftsP struct { +type GetBusinessAccountGifts struct { BusinessConnectionID string `json:"business_connection_id"` ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"` ExcludeSaved bool `json:"exclude_saved,omitempty"` @@ -363,7 +363,7 @@ type GetBusinessAccountGiftsP struct { // GetBusinessAccountGifts returns gifts owned by a business account. // See https://core.telegram.org/bots/api#getbusinessaccountgifts -func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedGifts, error) { +func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGifts) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params) return req.Do(api) } @@ -371,14 +371,14 @@ func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedG // GetBusinessAccountGiftsWithContext is the context-aware variant of GetBusinessAccountGifts. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getbusinessaccountgifts -func (api *API) GetBusinessAccountGiftsWithContext(ctx context.Context, params GetBusinessAccountGiftsP) (OwnedGifts, error) { +func (api *API) GetBusinessAccountGiftsWithContext(ctx context.Context, params GetBusinessAccountGifts) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params) return req.DoWithContext(ctx, api) } -// ConvertGiftToStarsP holds parameters for the convertGiftToStars method. +// ConvertGiftToStars holds parameters for the convertGiftToStars method. // See https://core.telegram.org/bots/api#convertgifttostars -type ConvertGiftToStarsP struct { +type ConvertGiftToStars struct { BusinessConnectionID string `json:"business_connection_id"` OwnedGiftID string `json:"owned_gift_id"` } @@ -386,7 +386,7 @@ type ConvertGiftToStarsP struct { // ConvertGiftToStars converts a gift to Telegram Stars. // Returns true on success. // See https://core.telegram.org/bots/api#convertgifttostars -func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) { +func (api *API) ConvertGiftToStars(params ConvertGiftToStars) (bool, error) { req := NewRequest[bool]("convertGiftToStars", params) return req.Do(api) } @@ -394,14 +394,14 @@ func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) { // ConvertGiftToStarsWithContext is the context-aware variant of ConvertGiftToStars. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#convertgifttostars -func (api *API) ConvertGiftToStarsWithContext(ctx context.Context, params ConvertGiftToStarsP) (bool, error) { +func (api *API) ConvertGiftToStarsWithContext(ctx context.Context, params ConvertGiftToStars) (bool, error) { req := NewRequest[bool]("convertGiftToStars", params) return req.DoWithContext(ctx, api) } -// UpgradeGiftP holds parameters for the upgradeGift method. +// UpgradeGift holds parameters for the upgradeGift method. // See https://core.telegram.org/bots/api#upgradegift -type UpgradeGiftP struct { +type UpgradeGift struct { BusinessConnectionID string `json:"business_connection_id"` OwnedGiftID string `json:"owned_gift_id"` KeepOriginalDetails bool `json:"keep_original_details,omitempty"` @@ -411,7 +411,7 @@ type UpgradeGiftP struct { // UpgradeGift upgrades a gift. // Returns true on success. // See https://core.telegram.org/bots/api#upgradegift -func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) { +func (api *API) UpgradeGift(params UpgradeGift) (bool, error) { req := NewRequest[bool]("upgradeGift", params) return req.Do(api) } @@ -419,14 +419,14 @@ func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) { // UpgradeGiftWithContext is the context-aware variant of UpgradeGift. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#upgradegift -func (api *API) UpgradeGiftWithContext(ctx context.Context, params UpgradeGiftP) (bool, error) { +func (api *API) UpgradeGiftWithContext(ctx context.Context, params UpgradeGift) (bool, error) { req := NewRequest[bool]("upgradeGift", params) return req.DoWithContext(ctx, api) } -// TransferGiftP holds parameters for the transferGift method. +// TransferGift holds parameters for the transferGift method. // See https://core.telegram.org/bots/api#transfergift -type TransferGiftP struct { +type TransferGift struct { BusinessConnectionID string `json:"business_connection_id"` OwnedGiftID string `json:"owned_gift_id"` NewOwnerChatID int64 `json:"new_owner_chat_id"` @@ -436,7 +436,7 @@ type TransferGiftP struct { // TransferGift transfers a gift to another chat. // Returns true on success. // See https://core.telegram.org/bots/api#transfergift -func (api *API) TransferGift(params TransferGiftP) (bool, error) { +func (api *API) TransferGift(params TransferGift) (bool, error) { req := NewRequest[bool]("transferGift", params) return req.Do(api) } @@ -444,14 +444,14 @@ func (api *API) TransferGift(params TransferGiftP) (bool, error) { // TransferGiftWithContext is the context-aware variant of TransferGift. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#transfergift -func (api *API) TransferGiftWithContext(ctx context.Context, params TransferGiftP) (bool, error) { +func (api *API) TransferGiftWithContext(ctx context.Context, params TransferGift) (bool, error) { req := NewRequest[bool]("transferGift", params) return req.DoWithContext(ctx, api) } -// PostStoryP holds parameters for the postStory method. +// PostStory holds parameters for the postStory method. // See https://core.telegram.org/bots/api#poststory -type PostStoryP struct { +type PostStory struct { BusinessConnectionID string `json:"business_connection_id"` Content InputStoryContent `json:"content"` ActivePeriod int `json:"active_period"` @@ -465,39 +465,24 @@ type PostStoryP struct { ProtectContent bool `json:"protect_content,omitempty"` } -// PostStoryPhoto posts a story with a photo. +// PostStory posts a story with a photo. // See https://core.telegram.org/bots/api#poststory -func (api *API) PostStoryPhoto(params PostStoryP) (Story, error) { +func (api *API) PostStory(params PostStory) (Story, error) { req := NewRequest[Story]("postStory", params) return req.Do(api) } -// PostStoryPhotoWithContext is the context-aware variant of PostStoryPhoto. +// PostStoryWithContext is the context-aware variant of PostStoryPhoto. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#poststory -func (api *API) PostStoryPhotoWithContext(ctx context.Context, params PostStoryP) (Story, error) { +func (api *API) PostStoryWithContext(ctx context.Context, params PostStory) (Story, error) { req := NewRequest[Story]("postStory", params) return req.DoWithContext(ctx, api) } -// PostStoryVideo posts a story with a video. -// See https://core.telegram.org/bots/api#poststory -func (api *API) PostStoryVideo(params PostStoryP) (Story, error) { - req := NewRequest[Story]("postStory", params) - return req.Do(api) -} - -// PostStoryVideoWithContext is the context-aware variant of PostStoryVideo. -// It executes the same request but uses ctx for cancellation and deadlines. -// See https://core.telegram.org/bots/api#poststory -func (api *API) PostStoryVideoWithContext(ctx context.Context, params PostStoryP) (Story, error) { - req := NewRequest[Story]("postStory", params) - return req.DoWithContext(ctx, api) -} - -// RepostStoryP holds parameters for the repostStory method. +// RepostStory holds parameters for the repostStory method. // See https://core.telegram.org/bots/api#repoststory -type RepostStoryP struct { +type RepostStory struct { BusinessConnectionID string `json:"business_connection_id"` FromChatID int64 `json:"from_chat_id"` FromStoryID int `json:"from_story_id"` @@ -509,7 +494,7 @@ type RepostStoryP struct { // RepostStory reposts a story from another chat. // Returns the reposted story. // See https://core.telegram.org/bots/api#repoststory -func (api *API) RepostStory(params RepostStoryP) (Story, error) { +func (api *API) RepostStory(params RepostStory) (Story, error) { req := NewRequest[Story]("repostStory", params) return req.Do(api) } @@ -517,14 +502,14 @@ func (api *API) RepostStory(params RepostStoryP) (Story, error) { // RepostStoryWithContext is the context-aware variant of RepostStory. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#repoststory -func (api *API) RepostStoryWithContext(ctx context.Context, params RepostStoryP) (Story, error) { +func (api *API) RepostStoryWithContext(ctx context.Context, params RepostStory) (Story, error) { req := NewRequest[Story]("repostStory", params) return req.DoWithContext(ctx, api) } -// EditStoryP holds parameters for the editStory method. +// EditStory holds parameters for the editStory method. // See https://core.telegram.org/bots/api#editstory -type EditStoryP struct { +type EditStory struct { BusinessConnectionID string `json:"business_connection_id"` StoryID int `json:"story_id"` Content InputStoryContent `json:"content"` @@ -538,7 +523,7 @@ type EditStoryP struct { // EditStory edits an existing story. // Returns the updated story. // See https://core.telegram.org/bots/api#editstory -func (api *API) EditStory(params EditStoryP) (Story, error) { +func (api *API) EditStory(params EditStory) (Story, error) { req := NewRequest[Story]("editStory", params) return req.Do(api) } @@ -546,14 +531,14 @@ func (api *API) EditStory(params EditStoryP) (Story, error) { // EditStoryWithContext is the context-aware variant of EditStory. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editstory -func (api *API) EditStoryWithContext(ctx context.Context, params EditStoryP) (Story, error) { +func (api *API) EditStoryWithContext(ctx context.Context, params EditStory) (Story, error) { req := NewRequest[Story]("editStory", params) return req.DoWithContext(ctx, api) } -// DeleteStoryP holds parameters for the deleteStory method. +// DeleteStory holds parameters for the deleteStory method. // See https://core.telegram.org/bots/api#deletestory -type DeleteStoryP struct { +type DeleteStory struct { BusinessConnectionID string `json:"business_connection_id"` StoryID int `json:"story_id"` } @@ -561,7 +546,7 @@ type DeleteStoryP struct { // DeleteStory deletes a story. // Returns true on success. // See https://core.telegram.org/bots/api#deletestory -func (api *API) DeleteStory(params DeleteStoryP) (bool, error) { +func (api *API) DeleteStory(params DeleteStory) (bool, error) { req := NewRequest[bool]("deleteStory", params) return req.Do(api) } @@ -569,7 +554,7 @@ func (api *API) DeleteStory(params DeleteStoryP) (bool, error) { // DeleteStoryWithContext is the context-aware variant of DeleteStory. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletestory -func (api *API) DeleteStoryWithContext(ctx context.Context, params DeleteStoryP) (bool, error) { +func (api *API) DeleteStoryWithContext(ctx context.Context, params DeleteStory) (bool, error) { req := NewRequest[bool]("deleteStory", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index 9799984..d33b720 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// BanChatMemberP holds parameters for the banChatMember method. +// BanChatMember holds parameters for the banChatMember method. // See https://core.telegram.org/bots/api#banchatmember -type BanChatMemberP struct { +type BanChatMember struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` UntilDate int `json:"until_date,omitempty"` @@ -14,7 +14,7 @@ type BanChatMemberP struct { // BanChatMember bans a user in a chat. // Returns True on success. // See https://core.telegram.org/bots/api#banchatmember -func (api *API) BanChatMember(params BanChatMemberP) (bool, error) { +func (api *API) BanChatMember(params BanChatMember) (bool, error) { req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID) return req.Do(api) } @@ -22,14 +22,14 @@ func (api *API) BanChatMember(params BanChatMemberP) (bool, error) { // BanChatMemberWithContext is the context-aware variant of BanChatMember. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#banchatmember -func (api *API) BanChatMemberWithContext(ctx context.Context, params BanChatMemberP) (bool, error) { +func (api *API) BanChatMemberWithContext(ctx context.Context, params BanChatMember) (bool, error) { req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID) return req.DoWithContext(ctx, api) } -// UnbanChatMemberP holds parameters for the unbanChatMember method. +// UnbanChatMember holds parameters for the unbanChatMember method. // See https://core.telegram.org/bots/api#unbanchatmember -type UnbanChatMemberP struct { +type UnbanChatMember struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` OnlyIfBanned bool `json:"only_if_banned"` @@ -38,7 +38,7 @@ type UnbanChatMemberP struct { // UnbanChatMember unbans a previously banned user in a chat. // Returns True on success. // See https://core.telegram.org/bots/api#unbanchatmember -func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) { +func (api *API) UnbanChatMember(params UnbanChatMember) (bool, error) { req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID) return req.Do(api) } @@ -46,14 +46,14 @@ func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) { // UnbanChatMemberWithContext is the context-aware variant of UnbanChatMember. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unbanchatmember -func (api *API) UnbanChatMemberWithContext(ctx context.Context, params UnbanChatMemberP) (bool, error) { +func (api *API) UnbanChatMemberWithContext(ctx context.Context, params UnbanChatMember) (bool, error) { req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID) return req.DoWithContext(ctx, api) } -// RestrictChatMemberP holds parameters for the restrictChatMember method. +// RestrictChatMember holds parameters for the restrictChatMember method. // See https://core.telegram.org/bots/api#restrictchatmember -type RestrictChatMemberP struct { +type RestrictChatMember struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` Permissions ChatPermissions `json:"permissions"` @@ -64,7 +64,7 @@ type RestrictChatMemberP struct { // RestrictChatMember restricts a user in a chat. // Returns True on success. // See https://core.telegram.org/bots/api#restrictchatmember -func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) { +func (api *API) RestrictChatMember(params RestrictChatMember) (bool, error) { req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID) return req.Do(api) } @@ -72,7 +72,7 @@ func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) { // RestrictChatMemberWithContext is the context-aware variant of RestrictChatMember. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#restrictchatmember -func (api *API) RestrictChatMemberWithContext(ctx context.Context, params RestrictChatMemberP) (bool, error) { +func (api *API) RestrictChatMemberWithContext(ctx context.Context, params RestrictChatMember) (bool, error) { req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -118,9 +118,9 @@ func (api *API) PromoteChatMemberWithContext(ctx context.Context, params Promote return req.DoWithContext(ctx, api) } -// SetChatAdministratorCustomTitleP holds parameters for the setChatAdministratorCustomTitle method. +// SetChatAdministratorCustomTitle holds parameters for the setChatAdministratorCustomTitle method. // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle -type SetChatAdministratorCustomTitleP struct { +type SetChatAdministratorCustomTitle struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` CustomTitle string `json:"custom_title"` @@ -129,7 +129,7 @@ type SetChatAdministratorCustomTitleP struct { // SetChatAdministratorCustomTitle sets a custom title for an administrator. // Returns True on success. // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle -func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCustomTitleP) (bool, error) { +func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCustomTitle) (bool, error) { req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID) return req.Do(api) } @@ -137,14 +137,14 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto // SetChatAdministratorCustomTitleWithContext is the context-aware variant of SetChatAdministratorCustomTitle. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle -func (api *API) SetChatAdministratorCustomTitleWithContext(ctx context.Context, params SetChatAdministratorCustomTitleP) (bool, error) { +func (api *API) SetChatAdministratorCustomTitleWithContext(ctx context.Context, params SetChatAdministratorCustomTitle) (bool, error) { req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SetChatMemberTagP holds parameters for the setChatMemberTag method. +// SetChatMemberTag holds parameters for the setChatMemberTag method. // See https://core.telegram.org/bots/api#setchatmembertag -type SetChatMemberTagP struct { +type SetChatMemberTag struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` Tag string `json:"tag,omitempty"` @@ -153,7 +153,7 @@ type SetChatMemberTagP struct { // SetChatMemberTag sets a tag for a chat member. // Returns True on success. // See https://core.telegram.org/bots/api#setchatmembertag -func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) { +func (api *API) SetChatMemberTag(params SetChatMemberTag) (bool, error) { req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID) return req.Do(api) } @@ -161,14 +161,14 @@ func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) { // SetChatMemberTagWithContext is the context-aware variant of SetChatMemberTag. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatmembertag -func (api *API) SetChatMemberTagWithContext(ctx context.Context, params SetChatMemberTagP) (bool, error) { +func (api *API) SetChatMemberTagWithContext(ctx context.Context, params SetChatMemberTag) (bool, error) { req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID) return req.DoWithContext(ctx, api) } -// BanChatSenderChatP holds parameters for the banChatSenderChat method. +// BanChatSenderChat holds parameters for the banChatSenderChat method. // See https://core.telegram.org/bots/api#banchatsenderchat -type BanChatSenderChatP struct { +type BanChatSenderChat struct { ChatID int64 `json:"chat_id"` SenderChatID int64 `json:"sender_chat_id"` } @@ -176,7 +176,7 @@ type BanChatSenderChatP struct { // BanChatSenderChat bans a channel chat in a supergroup or channel. // Returns True on success. // See https://core.telegram.org/bots/api#banchatsenderchat -func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) { +func (api *API) BanChatSenderChat(params BanChatSenderChat) (bool, error) { req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID) return req.Do(api) } @@ -184,14 +184,14 @@ func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) { // BanChatSenderChatWithContext is the context-aware variant of BanChatSenderChat. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#banchatsenderchat -func (api *API) BanChatSenderChatWithContext(ctx context.Context, params BanChatSenderChatP) (bool, error) { +func (api *API) BanChatSenderChatWithContext(ctx context.Context, params BanChatSenderChat) (bool, error) { req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID) return req.DoWithContext(ctx, api) } -// UnbanChatSenderChatP holds parameters for the unbanChatSenderChat method. +// UnbanChatSenderChat holds parameters for the unbanChatSenderChat method. // See https://core.telegram.org/bots/api#unbanchatsenderchat -type UnbanChatSenderChatP struct { +type UnbanChatSenderChat struct { ChatID int64 `json:"chat_id"` SenderChatID int64 `json:"sender_chat_id"` } @@ -199,7 +199,7 @@ type UnbanChatSenderChatP struct { // UnbanChatSenderChat unbans a previously banned channel chat. // Returns True on success. // See https://core.telegram.org/bots/api#unbanchatsenderchat -func (api *API) UnbanChatSenderChat(params UnbanChatSenderChatP) (bool, error) { +func (api *API) UnbanChatSenderChat(params UnbanChatSenderChat) (bool, error) { req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID) return req.Do(api) } @@ -207,14 +207,14 @@ func (api *API) UnbanChatSenderChat(params UnbanChatSenderChatP) (bool, error) { // UnbanChatSenderChatWithContext is the context-aware variant of UnbanChatSenderChat. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unbanchatsenderchat -func (api *API) UnbanChatSenderChatWithContext(ctx context.Context, params UnbanChatSenderChatP) (bool, error) { +func (api *API) UnbanChatSenderChatWithContext(ctx context.Context, params UnbanChatSenderChat) (bool, error) { req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SetChatPermissionsP holds parameters for the setChatPermissions method. +// SetChatPermissions holds parameters for the setChatPermissions method. // See https://core.telegram.org/bots/api#setchatpermissions -type SetChatPermissionsP struct { +type SetChatPermissions struct { ChatID int64 `json:"chat_id"` Permissions ChatPermissions `json:"permissions"` UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"` @@ -223,7 +223,7 @@ type SetChatPermissionsP struct { // SetChatPermissions sets default chat permissions for all members. // Returns True on success. // See https://core.telegram.org/bots/api#setchatpermissions -func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) { +func (api *API) SetChatPermissions(params SetChatPermissions) (bool, error) { req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID) return req.Do(api) } @@ -231,21 +231,21 @@ func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) { // SetChatPermissionsWithContext is the context-aware variant of SetChatPermissions. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatpermissions -func (api *API) SetChatPermissionsWithContext(ctx context.Context, params SetChatPermissionsP) (bool, error) { +func (api *API) SetChatPermissionsWithContext(ctx context.Context, params SetChatPermissions) (bool, error) { req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID) return req.DoWithContext(ctx, api) } -// ExportChatInviteLinkP holds parameters for the exportChatInviteLink method. +// ExportChatInviteLink holds parameters for the exportChatInviteLink method. // See https://core.telegram.org/bots/api#exportchatinvitelink -type ExportChatInviteLinkP struct { +type ExportChatInviteLink struct { ChatID int64 `json:"chat_id"` } // ExportChatInviteLink generates a new primary invite link for a chat. // Returns the new invite link as string. // See https://core.telegram.org/bots/api#exportchatinvitelink -func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, error) { +func (api *API) ExportChatInviteLink(params ExportChatInviteLink) (string, error) { req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID) return req.Do(api) } @@ -253,14 +253,14 @@ func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, erro // ExportChatInviteLinkWithContext is the context-aware variant of ExportChatInviteLink. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#exportchatinvitelink -func (api *API) ExportChatInviteLinkWithContext(ctx context.Context, params ExportChatInviteLinkP) (string, error) { +func (api *API) ExportChatInviteLinkWithContext(ctx context.Context, params ExportChatInviteLink) (string, error) { req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID) return req.DoWithContext(ctx, api) } -// CreateChatInviteLinkP holds parameters for the createChatInviteLink method. +// CreateChatInviteLink holds parameters for the createChatInviteLink method. // See https://core.telegram.org/bots/api#createchatinvitelink -type CreateChatInviteLinkP struct { +type CreateChatInviteLink struct { ChatID int64 `json:"chat_id"` Name *string `json:"name,omitempty"` ExpireDate int `json:"expire_date,omitempty"` @@ -271,7 +271,7 @@ type CreateChatInviteLinkP struct { // CreateChatInviteLink creates an additional invite link for a chat. // Returns the created invite link. // See https://core.telegram.org/bots/api#createchatinvitelink -func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLink, error) { +func (api *API) CreateChatInviteLink(params CreateChatInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID) return req.Do(api) } @@ -279,14 +279,14 @@ func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLi // CreateChatInviteLinkWithContext is the context-aware variant of CreateChatInviteLink. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createchatinvitelink -func (api *API) CreateChatInviteLinkWithContext(ctx context.Context, params CreateChatInviteLinkP) (ChatInviteLink, error) { +func (api *API) CreateChatInviteLinkWithContext(ctx context.Context, params CreateChatInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID) return req.DoWithContext(ctx, api) } -// EditChatInviteLinkP holds parameters for the editChatInviteLink method. +// EditChatInviteLink holds parameters for the editChatInviteLink method. // See https://core.telegram.org/bots/api#editchatinvitelink -type EditChatInviteLinkP struct { +type EditChatInviteLink struct { ChatID int64 `json:"chat_id"` InviteLink string `json:"invite_link"` @@ -299,7 +299,7 @@ type EditChatInviteLinkP struct { // EditChatInviteLink edits a non‑primary invite link. // Returns the edited invite link. // See https://core.telegram.org/bots/api#editchatinvitelink -func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink, error) { +func (api *API) EditChatInviteLink(params EditChatInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID) return req.Do(api) } @@ -307,14 +307,14 @@ func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink, // EditChatInviteLinkWithContext is the context-aware variant of EditChatInviteLink. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editchatinvitelink -func (api *API) EditChatInviteLinkWithContext(ctx context.Context, params EditChatInviteLinkP) (ChatInviteLink, error) { +func (api *API) EditChatInviteLinkWithContext(ctx context.Context, params EditChatInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID) return req.DoWithContext(ctx, api) } -// CreateChatSubscriptionInviteLinkP holds parameters for the createChatSubscriptionInviteLink method. +// CreateChatSubscriptionInviteLink holds parameters for the createChatSubscriptionInviteLink method. // See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink -type CreateChatSubscriptionInviteLinkP struct { +type CreateChatSubscriptionInviteLink struct { ChatID int64 `json:"chat_id"` Name string `json:"name,omitempty"` SubscriptionPeriod int `json:"subscription_period,omitempty"` @@ -324,7 +324,7 @@ type CreateChatSubscriptionInviteLinkP struct { // CreateChatSubscriptionInviteLink creates a subscription invite link for a channel chat. // Returns the created invite link. // See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink -func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) { +func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID) return req.Do(api) } @@ -332,14 +332,14 @@ func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionIn // CreateChatSubscriptionInviteLinkWithContext is the context-aware variant of CreateChatSubscriptionInviteLink. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink -func (api *API) CreateChatSubscriptionInviteLinkWithContext(ctx context.Context, params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) { +func (api *API) CreateChatSubscriptionInviteLinkWithContext(ctx context.Context, params CreateChatSubscriptionInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID) return req.DoWithContext(ctx, api) } -// EditChatSubscriptionInviteLinkP holds parameters for the editChatSubscriptionInviteLink method. +// EditChatSubscriptionInviteLink holds parameters for the editChatSubscriptionInviteLink method. // See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink -type EditChatSubscriptionInviteLinkP struct { +type EditChatSubscriptionInviteLink struct { ChatID int64 `json:"chat_id"` InviteLink string `json:"invite_link"` Name string `json:"name,omitempty"` @@ -348,7 +348,7 @@ type EditChatSubscriptionInviteLinkP struct { // EditChatSubscriptionInviteLink edits a subscription invite link. // Returns the edited invite link. // See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink -func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) { +func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID) return req.Do(api) } @@ -356,14 +356,14 @@ func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInvite // EditChatSubscriptionInviteLinkWithContext is the context-aware variant of EditChatSubscriptionInviteLink. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink -func (api *API) EditChatSubscriptionInviteLinkWithContext(ctx context.Context, params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) { +func (api *API) EditChatSubscriptionInviteLinkWithContext(ctx context.Context, params EditChatSubscriptionInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID) return req.DoWithContext(ctx, api) } -// RevokeChatInviteLinkP holds parameters for the revokeChatInviteLink method. +// RevokeChatInviteLink holds parameters for the revokeChatInviteLink method. // See https://core.telegram.org/bots/api#revokechatinvitelink -type RevokeChatInviteLinkP struct { +type RevokeChatInviteLink struct { ChatID int64 `json:"chat_id"` InviteLink string `json:"invite_link"` } @@ -371,7 +371,7 @@ type RevokeChatInviteLinkP struct { // RevokeChatInviteLink revokes an invite link. // Returns the revoked invite link object. // See https://core.telegram.org/bots/api#revokechatinvitelink -func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLink, error) { +func (api *API) RevokeChatInviteLink(params RevokeChatInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID) return req.Do(api) } @@ -379,14 +379,14 @@ func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLi // RevokeChatInviteLinkWithContext is the context-aware variant of RevokeChatInviteLink. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#revokechatinvitelink -func (api *API) RevokeChatInviteLinkWithContext(ctx context.Context, params RevokeChatInviteLinkP) (ChatInviteLink, error) { +func (api *API) RevokeChatInviteLinkWithContext(ctx context.Context, params RevokeChatInviteLink) (ChatInviteLink, error) { req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID) return req.DoWithContext(ctx, api) } -// ApproveChatJoinRequestP holds parameters for the approveChatJoinRequest method. +// ApproveChatJoinRequest holds parameters for the approveChatJoinRequest method. // See https://core.telegram.org/bots/api#approvechatjoinrequest -type ApproveChatJoinRequestP struct { +type ApproveChatJoinRequest struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` } @@ -394,7 +394,7 @@ type ApproveChatJoinRequestP struct { // ApproveChatJoinRequest approves a chat join request. // Returns True on success. // See https://core.telegram.org/bots/api#approvechatjoinrequest -func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, error) { +func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequest) (bool, error) { req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID) return req.Do(api) } @@ -402,14 +402,14 @@ func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, er // ApproveChatJoinRequestWithContext is the context-aware variant of ApproveChatJoinRequest. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#approvechatjoinrequest -func (api *API) ApproveChatJoinRequestWithContext(ctx context.Context, params ApproveChatJoinRequestP) (bool, error) { +func (api *API) ApproveChatJoinRequestWithContext(ctx context.Context, params ApproveChatJoinRequest) (bool, error) { req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID) return req.DoWithContext(ctx, api) } -// DeclineChatJoinRequestP holds parameters for the declineChatJoinRequest method. +// DeclineChatJoinRequest holds parameters for the declineChatJoinRequest method. // See https://core.telegram.org/bots/api#declinechatjoinrequest -type DeclineChatJoinRequestP struct { +type DeclineChatJoinRequest struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` } @@ -417,7 +417,7 @@ type DeclineChatJoinRequestP struct { // DeclineChatJoinRequest declines a chat join request. // Returns True on success. // See https://core.telegram.org/bots/api#declinechatjoinrequest -func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, error) { +func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequest) (bool, error) { req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID) return req.Do(api) } @@ -425,14 +425,14 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, er // DeclineChatJoinRequestWithContext is the context-aware variant of DeclineChatJoinRequest. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#declinechatjoinrequest -func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params DeclineChatJoinRequestP) (bool, error) { +func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params DeclineChatJoinRequest) (bool, error) { req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SetChatPhotoP holds parameters for the setChatPhoto method. +// SetChatPhoto holds parameters for the setChatPhoto method. // See https://core.telegram.org/bots/api#setchatphoto -type SetChatPhotoP struct { +type SetChatPhoto struct { ChatID int64 `json:"chat_id"` } @@ -440,7 +440,7 @@ type SetChatPhotoP struct { // photo is the file to upload as the new photo. // Returns True on success. // See https://core.telegram.org/bots/api#setchatphoto -func (api *API) SetChatPhoto(params SetChatPhotoP, photo UploaderFile) (bool, error) { +func (api *API) SetChatPhoto(params SetChatPhoto, photo UploaderFile) (bool, error) { uploader := NewUploader(api) defer func() { _ = uploader.Close() @@ -449,16 +449,16 @@ func (api *API) SetChatPhoto(params SetChatPhotoP, photo UploaderFile) (bool, er return req.Do(uploader) } -// DeleteChatPhotoP holds parameters for the deleteChatPhoto method. +// DeleteChatPhoto holds parameters for the deleteChatPhoto method. // See https://core.telegram.org/bots/api#deletechatphoto -type DeleteChatPhotoP struct { +type DeleteChatPhoto struct { ChatID int64 `json:"chat_id"` } // DeleteChatPhoto deletes a chat photo. // Returns True on success. // See https://core.telegram.org/bots/api#deletechatphoto -func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) { +func (api *API) DeleteChatPhoto(params DeleteChatPhoto) (bool, error) { req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID) return req.Do(api) } @@ -466,14 +466,14 @@ func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) { // DeleteChatPhotoWithContext is the context-aware variant of DeleteChatPhoto. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletechatphoto -func (api *API) DeleteChatPhotoWithContext(ctx context.Context, params DeleteChatPhotoP) (bool, error) { +func (api *API) DeleteChatPhotoWithContext(ctx context.Context, params DeleteChatPhoto) (bool, error) { req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SetChatTitleP holds parameters for the setChatTitle method. +// SetChatTitle holds parameters for the setChatTitle method. // See https://core.telegram.org/bots/api#setchattitle -type SetChatTitleP struct { +type SetChatTitle struct { ChatID int64 `json:"chat_id"` Title string `json:"title"` } @@ -481,7 +481,7 @@ type SetChatTitleP struct { // SetChatTitle changes the chat title. // Returns True on success. // See https://core.telegram.org/bots/api#setchattitle -func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) { +func (api *API) SetChatTitle(params SetChatTitle) (bool, error) { req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID) return req.Do(api) } @@ -489,14 +489,14 @@ func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) { // SetChatTitleWithContext is the context-aware variant of SetChatTitle. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchattitle -func (api *API) SetChatTitleWithContext(ctx context.Context, params SetChatTitleP) (bool, error) { +func (api *API) SetChatTitleWithContext(ctx context.Context, params SetChatTitle) (bool, error) { req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SetChatDescriptionP holds parameters for the setChatDescription method. +// SetChatDescription holds parameters for the setChatDescription method. // See https://core.telegram.org/bots/api#setchatdescription -type SetChatDescriptionP struct { +type SetChatDescription struct { ChatID int64 `json:"chat_id"` Description string `json:"description"` } @@ -504,7 +504,7 @@ type SetChatDescriptionP struct { // SetChatDescription changes the chat description. // Returns True on success. // See https://core.telegram.org/bots/api#setchatdescription -func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) { +func (api *API) SetChatDescription(params SetChatDescription) (bool, error) { req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID) return req.Do(api) } @@ -512,14 +512,14 @@ func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) { // SetChatDescriptionWithContext is the context-aware variant of SetChatDescription. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatdescription -func (api *API) SetChatDescriptionWithContext(ctx context.Context, params SetChatDescriptionP) (bool, error) { +func (api *API) SetChatDescriptionWithContext(ctx context.Context, params SetChatDescription) (bool, error) { req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID) return req.DoWithContext(ctx, api) } -// PinChatMessageP holds parameters for the pinChatMessage method. +// PinChatMessage holds parameters for the pinChatMessage method. // See https://core.telegram.org/bots/api#pinchatmessage -type PinChatMessageP struct { +type PinChatMessage struct { BusinessConnectionID *string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` @@ -529,7 +529,7 @@ type PinChatMessageP struct { // PinChatMessage pins a message in a chat. // Returns True on success. // See https://core.telegram.org/bots/api#pinchatmessage -func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) { +func (api *API) PinChatMessage(params PinChatMessage) (bool, error) { req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID) return req.Do(api) } @@ -537,14 +537,14 @@ func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) { // PinChatMessageWithContext is the context-aware variant of PinChatMessage. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#pinchatmessage -func (api *API) PinChatMessageWithContext(ctx context.Context, params PinChatMessageP) (bool, error) { +func (api *API) PinChatMessageWithContext(ctx context.Context, params PinChatMessage) (bool, error) { req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID) return req.DoWithContext(ctx, api) } -// UnpinChatMessageP holds parameters for the unpinChatMessage method. +// UnpinChatMessage holds parameters for the unpinChatMessage method. // See https://core.telegram.org/bots/api#unpinchatmessage -type UnpinChatMessageP struct { +type UnpinChatMessage struct { BusinessConnectionID *string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` @@ -553,7 +553,7 @@ type UnpinChatMessageP struct { // UnpinChatMessage unpins a message in a chat. // Returns True on success. // See https://core.telegram.org/bots/api#unpinchatmessage -func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) { +func (api *API) UnpinChatMessage(params UnpinChatMessage) (bool, error) { req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID) return req.Do(api) } @@ -561,21 +561,21 @@ func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) { // UnpinChatMessageWithContext is the context-aware variant of UnpinChatMessage. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unpinchatmessage -func (api *API) UnpinChatMessageWithContext(ctx context.Context, params UnpinChatMessageP) (bool, error) { +func (api *API) UnpinChatMessageWithContext(ctx context.Context, params UnpinChatMessage) (bool, error) { req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID) return req.DoWithContext(ctx, api) } -// UnpinAllChatMessagesP holds parameters for the unpinAllChatMessages method. +// UnpinAllChatMessages holds parameters for the unpinAllChatMessages method. // See https://core.telegram.org/bots/api#unpinallchatmessages -type UnpinAllChatMessagesP struct { +type UnpinAllChatMessages struct { ChatID int64 `json:"chat_id"` } // UnpinAllChatMessages unpins all pinned messages in a chat. // Returns True on success. // See https://core.telegram.org/bots/api#unpinallchatmessages -func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error) { +func (api *API) UnpinAllChatMessages(params UnpinAllChatMessages) (bool, error) { req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID) return req.Do(api) } @@ -583,21 +583,21 @@ func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error) // UnpinAllChatMessagesWithContext is the context-aware variant of UnpinAllChatMessages. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unpinallchatmessages -func (api *API) UnpinAllChatMessagesWithContext(ctx context.Context, params UnpinAllChatMessagesP) (bool, error) { +func (api *API) UnpinAllChatMessagesWithContext(ctx context.Context, params UnpinAllChatMessages) (bool, error) { req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID) return req.DoWithContext(ctx, api) } -// LeaveChatP holds parameters for the leaveChat method. +// LeaveChat holds parameters for the leaveChat method. // See https://core.telegram.org/bots/api#leavechat -type LeaveChatP struct { +type LeaveChat struct { ChatID int64 `json:"chat_id"` } // LeaveChat makes the bot leave a chat. // Returns True on success. // See https://core.telegram.org/bots/api#leavechat -func (api *API) LeaveChat(params LeaveChatP) (bool, error) { +func (api *API) LeaveChat(params LeaveChat) (bool, error) { req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name return req.Do(api) } @@ -605,20 +605,20 @@ func (api *API) LeaveChat(params LeaveChatP) (bool, error) { // LeaveChatWithContext is the context-aware variant of LeaveChat. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#leavechat -func (api *API) LeaveChatWithContext(ctx context.Context, params LeaveChatP) (bool, error) { +func (api *API) LeaveChatWithContext(ctx context.Context, params LeaveChat) (bool, error) { req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name return req.DoWithContext(ctx, api) } -// GetChatP holds parameters for the getChat method. +// GetChat holds parameters for the getChat method. // See https://core.telegram.org/bots/api#getchat -type GetChatP struct { +type GetChat struct { ChatID int64 `json:"chat_id"` } // GetChat gets up‑to‑date information about a chat. // See https://core.telegram.org/bots/api#getchat -func (api *API) GetChat(params GetChatP) (ChatFullInfo, error) { +func (api *API) GetChat(params GetChat) (ChatFullInfo, error) { req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name return req.Do(api) } @@ -626,20 +626,20 @@ func (api *API) GetChat(params GetChatP) (ChatFullInfo, error) { // GetChatWithContext is the context-aware variant of GetChat. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchat -func (api *API) GetChatWithContext(ctx context.Context, params GetChatP) (ChatFullInfo, error) { +func (api *API) GetChatWithContext(ctx context.Context, params GetChat) (ChatFullInfo, error) { req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name return req.DoWithContext(ctx, api) } -// GetChatAdministratorsP holds parameters for the getChatAdministrators method. +// GetChatAdministrators holds parameters for the getChatAdministrators method. // See https://core.telegram.org/bots/api#getchatadministrators -type GetChatAdministratorsP struct { +type GetChatAdministrators struct { ChatID int64 `json:"chat_id"` } // GetChatAdministrators returns a list of administrators in a chat. // See https://core.telegram.org/bots/api#getchatadministrators -func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMember, error) { +func (api *API) GetChatAdministrators(params GetChatAdministrators) ([]ChatMember, error) { req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID) return req.Do(api) } @@ -647,20 +647,20 @@ func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMemb // GetChatAdministratorsWithContext is the context-aware variant of GetChatAdministrators. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatadministrators -func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params GetChatAdministratorsP) ([]ChatMember, error) { +func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params GetChatAdministrators) ([]ChatMember, error) { req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID) return req.DoWithContext(ctx, api) } -// GetChatMembersCountP holds parameters for the getChatMemberCount method. +// GetChatMembersCount holds parameters for the getChatMemberCount method. // See https://core.telegram.org/bots/api#getchatmembercount -type GetChatMembersCountP struct { +type GetChatMembersCount struct { ChatID int64 `json:"chat_id"` } // GetChatMemberCount returns the number of members in a chat. // See https://core.telegram.org/bots/api#getchatmembercount -func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) { +func (api *API) GetChatMemberCount(params GetChatMembersCount) (int, error) { req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) return req.Do(api) } @@ -668,21 +668,21 @@ func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) { // GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatmembercount -func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMembersCountP) (int, error) { +func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMembersCount) (int, error) { req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) return req.DoWithContext(ctx, api) } -// GetChatMemberP holds parameters for the getChatMember method. +// GetChatMember holds parameters for the getChatMember method. // See https://core.telegram.org/bots/api#getchatmember -type GetChatMemberP struct { +type GetChatMember struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` } // GetChatMember returns information about a member of a chat. // See https://core.telegram.org/bots/api#getchatmember -func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) { +func (api *API) GetChatMember(params GetChatMember) (ChatMember, error) { req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID) return req.Do(api) } @@ -690,14 +690,14 @@ func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) { // GetChatMemberWithContext is the context-aware variant of GetChatMember. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatmember -func (api *API) GetChatMemberWithContext(ctx context.Context, params GetChatMemberP) (ChatMember, error) { +func (api *API) GetChatMemberWithContext(ctx context.Context, params GetChatMember) (ChatMember, error) { req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SetChatStickerSetP holds parameters for the setChatStickerSet method. +// SetChatStickerSet holds parameters for the setChatStickerSet method. // See https://core.telegram.org/bots/api#setchatstickerset -type SetChatStickerSetP struct { +type SetChatStickerSet struct { ChatID int64 `json:"chat_id"` StickerSetName string `json:"sticker_set_name"` } @@ -705,7 +705,7 @@ type SetChatStickerSetP struct { // SetChatStickerSet associates a sticker set with a supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#setchatstickerset -func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) { +func (api *API) SetChatStickerSet(params SetChatStickerSet) (bool, error) { req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID) return req.Do(api) } @@ -713,21 +713,21 @@ func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) { // SetChatStickerSetWithContext is the context-aware variant of SetChatStickerSet. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatstickerset -func (api *API) SetChatStickerSetWithContext(ctx context.Context, params SetChatStickerSetP) (bool, error) { +func (api *API) SetChatStickerSetWithContext(ctx context.Context, params SetChatStickerSet) (bool, error) { req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID) return req.DoWithContext(ctx, api) } -// DeleteChatStickerSetP holds parameters for the deleteChatStickerSet method. +// DeleteChatStickerSet holds parameters for the deleteChatStickerSet method. // See https://core.telegram.org/bots/api#deletechatstickerset -type DeleteChatStickerSetP struct { +type DeleteChatStickerSet struct { ChatID int64 `json:"chat_id"` } // DeleteChatStickerSet deletes a sticker set from a supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#deletechatstickerset -func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) { +func (api *API) DeleteChatStickerSet(params DeleteChatStickerSet) (bool, error) { req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID) return req.Do(api) } @@ -735,21 +735,21 @@ func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) // DeleteChatStickerSetWithContext is the context-aware variant of DeleteChatStickerSet. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletechatstickerset -func (api *API) DeleteChatStickerSetWithContext(ctx context.Context, params DeleteChatStickerSetP) (bool, error) { +func (api *API) DeleteChatStickerSetWithContext(ctx context.Context, params DeleteChatStickerSet) (bool, error) { req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID) return req.DoWithContext(ctx, api) } -// GetUserChatBoostsP holds parameters for the getUserChatBoosts method. +// GetUserChatBoosts holds parameters for the getUserChatBoosts method. // See https://core.telegram.org/bots/api#getuserchatboosts -type GetUserChatBoostsP struct { +type GetUserChatBoosts struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` } // GetUserChatBoosts returns the list of boosts a user has given to a chat. // See https://core.telegram.org/bots/api#getuserchatboosts -func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, error) { +func (api *API) GetUserChatBoosts(params GetUserChatBoosts) (UserChatBoosts, error) { req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID) return req.Do(api) } @@ -757,14 +757,14 @@ func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, er // GetUserChatBoostsWithContext is the context-aware variant of GetUserChatBoosts. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getuserchatboosts -func (api *API) GetUserChatBoostsWithContext(ctx context.Context, params GetUserChatBoostsP) (UserChatBoosts, error) { +func (api *API) GetUserChatBoostsWithContext(ctx context.Context, params GetUserChatBoosts) (UserChatBoosts, error) { req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID) return req.DoWithContext(ctx, api) } -// GetChatGiftsP holds parameters for the getChatGifts method. +// GetChatGifts holds parameters for the getChatGifts method. // See https://core.telegram.org/bots/api#getchatgifts -type GetChatGiftsP struct { +type GetChatGifts struct { ChatID int64 `json:"chat_id"` ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"` ExcludeSaved bool `json:"exclude_saved,omitempty"` @@ -780,7 +780,7 @@ type GetChatGiftsP struct { // GetChatGifts returns gifts owned by a chat. // See https://core.telegram.org/bots/api#getchatgifts -func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) { +func (api *API) GetChatGifts(params GetChatGifts) (OwnedGifts, error) { req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID) return req.Do(api) } @@ -788,7 +788,7 @@ func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) { // GetChatGiftsWithContext is the context-aware variant of GetChatGifts. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatgifts -func (api *API) GetChatGiftsWithContext(ctx context.Context, params GetChatGiftsP) (OwnedGifts, error) { +func (api *API) GetChatGiftsWithContext(ctx context.Context, params GetChatGifts) (OwnedGifts, error) { req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID) return req.DoWithContext(ctx, api) } diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index 9dd6273..a78f684 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -107,11 +107,12 @@ type ChatPermissions struct { CanSendAudios bool `json:"can_send_audios"` CanSendDocuments bool `json:"can_send_documents"` CanSendPhotos bool `json:"can_send_photos"` + CanSendVideos bool `json:"can_send_videos"` CanSendVideoNotes bool `json:"can_send_video_notes"` CanSendVoiceNotes bool `json:"can_send_voice_notes"` CanSendPolls bool `json:"can_send_polls"` CanSendOtherMessages bool `json:"can_send_other_messages"` - CanAddWebPagePreview bool `json:"can_add_web_page_preview"` + CanAddWebPagePreview bool `json:"can_add_web_page_previews"` CanEditTag bool `json:"can_edit_tag"` CanChangeInfo bool `json:"can_change_info"` CanInviteUsers bool `json:"can_invite_users"` @@ -200,12 +201,13 @@ type ChatMember struct { CanSendMessages *bool `json:"can_send_messages,omitempty"` CanSendAudios *bool `json:"can_send_audios,omitempty"` CanSendDocuments *bool `json:"can_send_documents,omitempty"` + CanSendPhotos *bool `json:"can_send_photos,omitempty"` CanSendVideos *bool `json:"can_send_videos,omitempty"` CanSendVideoNotes *bool `json:"can_send_video_notes,omitempty"` CanSendVoiceNotes *bool `json:"can_send_voice_notes,omitempty"` CanSendPolls *bool `json:"can_send_polls,omitempty"` CanSendOtherMessages *bool `json:"can_send_other_messages,omitempty"` - CanAddWebPagePreview *bool `json:"can_add_web_page_preview,omitempty"` + CanAddWebPagePreview *bool `json:"can_add_web_page_previews,omitempty"` CanEditTag *bool `json:"can_edit_tag,omitempty"` } @@ -235,6 +237,25 @@ type ChatBoost struct { type UserChatBoosts struct { Boosts []ChatBoost `json:"boosts"` } +type ChatBoostAdded struct { + BoostCount int `json:"boost_count"` +} + +type ChatBackground struct { + Type BackgroundType `json:"type"` +} + +// ChatOwnerLeft describes a service message about a chat owner leaving. +// See https://core.telegram.org/bots/api#chatownerleft +type ChatOwnerLeft struct { + NewOwner *User `json:"new_owner,omitempty"` +} + +// ChatOwnerChanged describes a service message about a chat owner change. +// See https://core.telegram.org/bots/api#chatownerchanged +type ChatOwnerChanged struct { + NewOwner User `json:"new_owner"` +} // ChatAdministratorRights represents the rights of an administrator in a chat. // See https://core.telegram.org/bots/api#chatadministratorrights diff --git a/tgapi/forum_methods.go b/tgapi/forum_methods.go index bbd8c44..822e085 100644 --- a/tgapi/forum_methods.go +++ b/tgapi/forum_methods.go @@ -2,8 +2,8 @@ package tgapi import "context" -// BaseForumTopicP contains common fields for forum topic operations that require a chat ID and a message thread ID. -type BaseForumTopicP struct { +// BaseForumTopic contains common fields for forum topic operations that require a chat ID and a message thread ID. +type BaseForumTopic struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id"` } @@ -23,9 +23,9 @@ func (api *API) GetForumTopicIconStickersWithContext(ctx context.Context) ([]Sti return req.DoWithContext(ctx, api) } -// CreateForumTopicP holds parameters for the createForumTopic method. +// CreateForumTopic holds parameters for the createForumTopic method. // See https://core.telegram.org/bots/api#createforumtopic -type CreateForumTopicP struct { +type CreateForumTopic struct { ChatID int64 `json:"chat_id"` Name string `json:"name"` IconColor ForumTopicIconColor `json:"icon_color"` @@ -35,7 +35,7 @@ type CreateForumTopicP struct { // CreateForumTopic creates a topic in a forum supergroup. // Returns the created ForumTopic on success. // See https://core.telegram.org/bots/api#createforumtopic -func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) { +func (api *API) CreateForumTopic(params CreateForumTopic) (ForumTopic, error) { req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID) return req.Do(api) } @@ -43,15 +43,15 @@ func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) { // CreateForumTopicWithContext is the context-aware variant of CreateForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createforumtopic -func (api *API) CreateForumTopicWithContext(ctx context.Context, params CreateForumTopicP) (ForumTopic, error) { +func (api *API) CreateForumTopicWithContext(ctx context.Context, params CreateForumTopic) (ForumTopic, error) { req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } -// EditForumTopicP holds parameters for the editForumTopic method. +// EditForumTopic holds parameters for the editForumTopic method. // See https://core.telegram.org/bots/api#editforumtopic -type EditForumTopicP struct { - BaseForumTopicP +type EditForumTopic struct { + BaseForumTopic Name string `json:"name"` IconCustomEmojiID string `json:"icon_custom_emoji_id"` } @@ -59,7 +59,7 @@ type EditForumTopicP struct { // EditForumTopic edits name and icon of a forum topic. // Returns True on success. // See https://core.telegram.org/bots/api#editforumtopic -func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) { +func (api *API) EditForumTopic(params EditForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID) return req.Do(api) } @@ -67,7 +67,7 @@ func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) { // EditForumTopicWithContext is the context-aware variant of EditForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editforumtopic -func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumTopicP) (bool, error) { +func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -75,7 +75,7 @@ func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumT // CloseForumTopic closes an open forum topic. // Returns True on success. // See https://core.telegram.org/bots/api#closeforumtopic -func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) { +func (api *API) CloseForumTopic(params BaseForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID) return req.Do(api) } @@ -83,7 +83,7 @@ func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) { // CloseForumTopicWithContext is the context-aware variant of CloseForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#closeforumtopic -func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) { +func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -91,7 +91,7 @@ func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForum // ReopenForumTopic reopens a closed forum topic. // Returns True on success. // See https://core.telegram.org/bots/api#reopenforumtopic -func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) { +func (api *API) ReopenForumTopic(params BaseForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID) return req.Do(api) } @@ -99,7 +99,7 @@ func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) { // ReopenForumTopicWithContext is the context-aware variant of ReopenForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#reopenforumtopic -func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) { +func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -107,7 +107,7 @@ func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForu // DeleteForumTopic deletes a forum topic. // Returns True on success. // See https://core.telegram.org/bots/api#deleteforumtopic -func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) { +func (api *API) DeleteForumTopic(params BaseForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID) return req.Do(api) } @@ -115,7 +115,7 @@ func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) { // DeleteForumTopicWithContext is the context-aware variant of DeleteForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deleteforumtopic -func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) { +func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -123,7 +123,7 @@ func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForu // UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic. // Returns True on success. // See https://core.telegram.org/bots/api#unpinallforumtopicmessages -func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error) { +func (api *API) UnpinAllForumTopicMessages(params BaseForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID) return req.Do(api) } @@ -131,19 +131,19 @@ func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error) // UnpinAllForumTopicMessagesWithContext is the context-aware variant of UnpinAllForumTopicMessages. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unpinallforumtopicmessages -func (api *API) UnpinAllForumTopicMessagesWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) { +func (api *API) UnpinAllForumTopicMessagesWithContext(ctx context.Context, params BaseForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID) return req.DoWithContext(ctx, api) } -// BaseGeneralForumTopicP contains common fields for general forum topic operations that require a chat ID. -type BaseGeneralForumTopicP struct { +// BaseGeneralForumTopic contains common fields for general forum topic operations that require a chat ID. +type BaseGeneralForumTopic struct { ChatID int64 `json:"chat_id"` } -// EditGeneralForumTopicP holds parameters for the editGeneralForumTopic method. +// EditGeneralForumTopic holds parameters for the editGeneralForumTopic method. // See https://core.telegram.org/bots/api#editgeneralforumtopic -type EditGeneralForumTopicP struct { +type EditGeneralForumTopic struct { ChatID int64 `json:"chat_id"` Name string `json:"name"` } @@ -151,7 +151,7 @@ type EditGeneralForumTopicP struct { // EditGeneralForumTopic edits the name of the 'General' topic in a forum supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#editgeneralforumtopic -func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, error) { +func (api *API) EditGeneralForumTopic(params EditGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID) return req.Do(api) } @@ -159,7 +159,7 @@ func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, erro // EditGeneralForumTopicWithContext is the context-aware variant of EditGeneralForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editgeneralforumtopic -func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params EditGeneralForumTopicP) (bool, error) { +func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params EditGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -167,7 +167,7 @@ func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params Edi // CloseGeneralForumTopic closes the 'General' topic in a forum supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#closegeneralforumtopic -func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID) return req.Do(api) } @@ -175,7 +175,7 @@ func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, err // CloseGeneralForumTopicWithContext is the context-aware variant of CloseGeneralForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#closegeneralforumtopic -func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { +func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -183,7 +183,7 @@ func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params Ba // ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#reopengeneralforumtopic -func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID) return req.Do(api) } @@ -191,7 +191,7 @@ func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er // ReopenGeneralForumTopicWithContext is the context-aware variant of ReopenGeneralForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#reopengeneralforumtopic -func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { +func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -199,7 +199,7 @@ func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params B // HideGeneralForumTopic hides the 'General' topic in a forum supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#hidegeneralforumtopic -func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID) return req.Do(api) } @@ -207,7 +207,7 @@ func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, erro // HideGeneralForumTopicWithContext is the context-aware variant of HideGeneralForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#hidegeneralforumtopic -func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { +func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -215,7 +215,7 @@ func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params Bas // UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup. // Returns True on success. // See https://core.telegram.org/bots/api#unhidegeneralforumtopic -func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID) return req.Do(api) } @@ -223,7 +223,7 @@ func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er // UnhideGeneralForumTopicWithContext is the context-aware variant of UnhideGeneralForumTopic. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unhidegeneralforumtopic -func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { +func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID) return req.DoWithContext(ctx, api) } @@ -231,7 +231,7 @@ func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params B // UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic. // Returns True on success. // See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages -func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP) (bool, error) { +func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID) return req.Do(api) } @@ -239,7 +239,7 @@ func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP) // UnpinAllGeneralForumTopicMessagesWithContext is the context-aware variant of UnpinAllGeneralForumTopicMessages. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages -func (api *API) UnpinAllGeneralForumTopicMessagesWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) { +func (api *API) UnpinAllGeneralForumTopicMessagesWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID) return req.DoWithContext(ctx, api) } diff --git a/tgapi/forum_types.go b/tgapi/forum_types.go index 6fd72f1..9223eb3 100644 --- a/tgapi/forum_types.go +++ b/tgapi/forum_types.go @@ -19,3 +19,19 @@ const ( // ForumTopicIconColorBlue is the blue color for forum topic icons (value 7322096). ForumTopicIconColorBlue ForumTopicIconColor = 7322096 ) + +type ForumTopicCreated struct { + Name string `json:"name"` + IconColor int `json:"icon_color"` + IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` + IsNameImplicit bool `json:"is_name_implicit,omitempty"` +} +type ForumTopicEdited struct { + Name string `json:"name,omitempty"` + IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` +} +type ForumTopicClosed struct{} +type ForumTopicReopened struct{} +type GeneralForumTopicHidden struct{} +type GeneralForumTopicUnhidden struct { +} diff --git a/tgapi/games_methods.go b/tgapi/games_methods.go index 0aa060f..afa8615 100644 --- a/tgapi/games_methods.go +++ b/tgapi/games_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// SendGameP holds parameters for the sendGame method. +// SendGame holds parameters for the sendGame method. // See https://core.telegram.org/bots/api#sendgame -type SendGameP struct { +type SendGame struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -21,7 +21,7 @@ type SendGameP struct { // SendGame sends a game message. // See https://core.telegram.org/bots/api#sendgame -func (api *API) SendGame(params SendGameP) (Message, error) { +func (api *API) SendGame(params SendGame) (Message, error) { req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID) return req.Do(api) } @@ -29,14 +29,14 @@ func (api *API) SendGame(params SendGameP) (Message, error) { // SendGameWithContext is the context-aware variant of SendGame. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendgame -func (api *API) SendGameWithContext(ctx context.Context, params SendGameP) (Message, error) { +func (api *API) SendGameWithContext(ctx context.Context, params SendGame) (Message, error) { req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SetGameScoreP holds parameters for the setGameScore method. +// SetGameScore holds parameters for the setGameScore method. // See https://core.telegram.org/bots/api#setgamescore -type SetGameScoreP struct { +type SetGameScore struct { UserID int64 `json:"user_id"` Score int `json:"score"` Force bool `json:"force,omitempty"` @@ -50,7 +50,7 @@ type SetGameScoreP struct { // If inline_message_id is provided, returns a boolean success flag. // Otherwise returns the edited Message. // See https://core.telegram.org/bots/api#setgamescore -func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) { +func (api *API) SetGameScore(params SetGameScore) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID) @@ -65,7 +65,7 @@ func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) { // SetGameScoreWithContext is the context-aware variant of SetGameScore. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setgamescore -func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScoreP) (Message, bool, error) { +func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScore) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID) @@ -77,9 +77,9 @@ func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScore return res, false, err } -// GetGameHighScoresP holds parameters for the getGameHighScores method. +// GetGameHighScores holds parameters for the getGameHighScores method. // See https://core.telegram.org/bots/api#getgamehighscores -type GetGameHighScoresP struct { +type GetGameHighScores struct { UserID int64 `json:"user_id"` ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` @@ -88,7 +88,7 @@ type GetGameHighScoresP struct { // GetGameHighScores returns game high score data for a user. // See https://core.telegram.org/bots/api#getgamehighscores -func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, error) { +func (api *API) GetGameHighScores(params GetGameHighScores) ([]GameHighScore, error) { req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID) return req.Do(api) } @@ -96,7 +96,7 @@ func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, e // GetGameHighScoresWithContext is the context-aware variant of GetGameHighScores. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getgamehighscores -func (api *API) GetGameHighScoresWithContext(ctx context.Context, params GetGameHighScoresP) ([]GameHighScore, error) { +func (api *API) GetGameHighScoresWithContext(ctx context.Context, params GetGameHighScores) ([]GameHighScore, error) { req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID) return req.DoWithContext(ctx, api) } diff --git a/tgapi/games_types.go b/tgapi/games_types.go index f076a13..ab2f420 100644 --- a/tgapi/games_types.go +++ b/tgapi/games_types.go @@ -1,5 +1,15 @@ package tgapi +type Game struct { + Title string `json:"title"` + Description string `json:"description"` + Photo []PhotoSize `json:"photo"` + Text string `json:"text,omitempty"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` + Animation *Animation `json:"animation,omitempty"` +} +type CallbackGame struct{} + // GameHighScore represents one row in a game high score table. // See https://core.telegram.org/bots/api#gamehighscore type GameHighScore struct { diff --git a/tgapi/inline_methods.go b/tgapi/inline_methods.go index 8b8232e..420c812 100644 --- a/tgapi/inline_methods.go +++ b/tgapi/inline_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// AnswerInlineQueryP holds parameters for the answerInlineQuery method. +// AnswerInlineQuery holds parameters for the answerInlineQuery method. // See https://core.telegram.org/bots/api#answerinlinequery -type AnswerInlineQueryP struct { +type AnswerInlineQuery struct { InlineQueryID string `json:"inline_query_id"` Results []InlineQueryResult `json:"results"` CacheTime int `json:"cache_time,omitempty"` @@ -16,7 +16,7 @@ type AnswerInlineQueryP struct { // AnswerInlineQuery sends answers to an inline query. // Returns true on success. // See https://core.telegram.org/bots/api#answerinlinequery -func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) { +func (api *API) AnswerInlineQuery(params AnswerInlineQuery) (bool, error) { req := NewRequest[bool]("answerInlineQuery", params) return req.Do(api) } @@ -24,21 +24,21 @@ func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) { // AnswerInlineQueryWithContext is the context-aware variant of AnswerInlineQuery. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answerinlinequery -func (api *API) AnswerInlineQueryWithContext(ctx context.Context, params AnswerInlineQueryP) (bool, error) { +func (api *API) AnswerInlineQueryWithContext(ctx context.Context, params AnswerInlineQuery) (bool, error) { req := NewRequest[bool]("answerInlineQuery", params) return req.DoWithContext(ctx, api) } -// AnswerWebAppQueryP holds parameters for the answerWebAppQuery method. +// AnswerWebAppQuery holds parameters for the answerWebAppQuery method. // See https://core.telegram.org/bots/api#answerwebappquery -type AnswerWebAppQueryP struct { +type AnswerWebAppQuery struct { WebAppQueryID string `json:"web_app_query_id"` Result InlineQueryResult `json:"result"` } // AnswerWebAppQuery sets the result of a Web App interaction. // See https://core.telegram.org/bots/api#answerwebappquery -func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage, error) { +func (api *API) AnswerWebAppQuery(params AnswerWebAppQuery) (SentWebAppMessage, error) { req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params) return req.Do(api) } @@ -46,14 +46,14 @@ func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage, // AnswerWebAppQueryWithContext is the context-aware variant of AnswerWebAppQuery. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answerwebappquery -func (api *API) AnswerWebAppQueryWithContext(ctx context.Context, params AnswerWebAppQueryP) (SentWebAppMessage, error) { +func (api *API) AnswerWebAppQueryWithContext(ctx context.Context, params AnswerWebAppQuery) (SentWebAppMessage, error) { req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params) return req.DoWithContext(ctx, api) } -// SavePreparedInlineMessageP holds parameters for the savePreparedInlineMessage method. +// SavePreparedInlineMessage holds parameters for the savePreparedInlineMessage method. // See https://core.telegram.org/bots/api#savepreparedinlinemessage -type SavePreparedInlineMessageP struct { +type SavePreparedInlineMessage struct { UserID int64 `json:"user_id"` Result InlineQueryResult `json:"result"` AllowUserChats bool `json:"allow_user_chats,omitempty"` @@ -64,7 +64,7 @@ type SavePreparedInlineMessageP struct { // SavePreparedInlineMessage stores a prepared message for Mini App users. // See https://core.telegram.org/bots/api#savepreparedinlinemessage -func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (PreparedInlineMessage, error) { +func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessage) (PreparedInlineMessage, error) { req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params) return req.Do(api) } @@ -72,7 +72,29 @@ func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (Pr // SavePreparedInlineMessageWithContext is the context-aware variant of SavePreparedInlineMessage. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#savepreparedinlinemessage -func (api *API) SavePreparedInlineMessageWithContext(ctx context.Context, params SavePreparedInlineMessageP) (PreparedInlineMessage, error) { +func (api *API) SavePreparedInlineMessageWithContext(ctx context.Context, params SavePreparedInlineMessage) (PreparedInlineMessage, error) { req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params) return req.DoWithContext(ctx, api) } + +// SavePreparedKeyboardButton holds parameters for the savePreparedKeyboardButton method. +// See https://core.telegram.org/bots/api#savepreparedkeyboardbutton +type SavePreparedKeyboardButton struct { + UserID int64 `json:"user_id"` + Button KeyboardButton `json:"button"` +} + +// SavePreparedKeyboardButton stores a prepared keyboard button for Mini App users. +// See https://core.telegram.org/bots/api#savepreparedkeyboardbutton +func (api *API) SavePreparedKeyboardButton(params SavePreparedKeyboardButton) (PreparedKeyboardButton, error) { + req := NewRequest[PreparedKeyboardButton]("savePreparedKeyboardButton", params) + return req.Do(api) +} + +// SavePreparedKeyboardButtonWithContext is the context-aware variant of SavePreparedKeyboardButton. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#savepreparedkeyboardbutton +func (api *API) SavePreparedKeyboardButtonWithContext(ctx context.Context, params SavePreparedKeyboardButton) (PreparedKeyboardButton, error) { + req := NewRequest[PreparedKeyboardButton]("savePreparedKeyboardButton", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/inline_types.go b/tgapi/inline_types.go index 8dcab32..2c592be 100644 --- a/tgapi/inline_types.go +++ b/tgapi/inline_types.go @@ -24,3 +24,9 @@ type PreparedInlineMessage struct { ID string `json:"id"` ExpirationDate int `json:"expiration_date"` } + +// PreparedKeyboardButton describes a prepared keyboard button. +// See https://core.telegram.org/bots/api#preparedkeyboardbutton +type PreparedKeyboardButton struct { + ID string `json:"id"` +} diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index 69ced5c..bf1b786 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// SendMessageP holds parameters for the sendMessage method. +// SendMessage holds parameters for the sendMessage method. // See https://core.telegram.org/bots/api#sendmessage -type SendMessageP struct { +type SendMessage struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -26,22 +26,22 @@ type SendMessageP struct { // SendMessage sends a text message. // See https://core.telegram.org/bots/api#sendmessage -func (api *API) SendMessage(params SendMessageP) (Message, error) { - req := NewRequestWithChatID[Message, SendMessageP]("sendMessage", params, params.ChatID) +func (api *API) SendMessage(params SendMessage) (Message, error) { + req := NewRequestWithChatID[Message, SendMessage]("sendMessage", params, params.ChatID) return req.Do(api) } // SendMessageWithContext is the context-aware variant of SendMessage. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendmessage -func (api *API) SendMessageWithContext(ctx context.Context, params SendMessageP) (Message, error) { - req := NewRequestWithChatID[Message, SendMessageP]("sendMessage", params, params.ChatID) +func (api *API) SendMessageWithContext(ctx context.Context, params SendMessage) (Message, error) { + req := NewRequestWithChatID[Message, SendMessage]("sendMessage", params, params.ChatID) return req.DoWithContext(ctx, api) } -// ForwardMessageP holds parameters for the forwardMessage method. +// ForwardMessage holds parameters for the forwardMessage method. // See https://core.telegram.org/bots/api#forwardmessage -type ForwardMessageP struct { +type ForwardMessage struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -58,7 +58,7 @@ type ForwardMessageP struct { // ForwardMessage forwards a message. // See https://core.telegram.org/bots/api#forwardmessage -func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) { +func (api *API) ForwardMessage(params ForwardMessage) (Message, error) { req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID) return req.Do(api) } @@ -66,14 +66,14 @@ func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) { // ForwardMessageWithContext is the context-aware variant of ForwardMessage. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#forwardmessage -func (api *API) ForwardMessageWithContext(ctx context.Context, params ForwardMessageP) (Message, error) { +func (api *API) ForwardMessageWithContext(ctx context.Context, params ForwardMessage) (Message, error) { req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID) return req.DoWithContext(ctx, api) } -// ForwardMessagesP holds parameters for the forwardMessages method. +// ForwardMessages holds parameters for the forwardMessages method. // See https://core.telegram.org/bots/api#forwardmessages -type ForwardMessagesP struct { +type ForwardMessages struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -87,7 +87,7 @@ type ForwardMessagesP struct { // ForwardMessages forwards multiple messages. // Returns an array of message IDs of the sent messages. // See https://core.telegram.org/bots/api#forwardmessages -func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) { +func (api *API) ForwardMessages(params ForwardMessages) ([]MessageID, error) { req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID) return req.Do(api) } @@ -95,14 +95,14 @@ func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) { // ForwardMessagesWithContext is the context-aware variant of ForwardMessages. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#forwardmessages -func (api *API) ForwardMessagesWithContext(ctx context.Context, params ForwardMessagesP) ([]MessageID, error) { +func (api *API) ForwardMessagesWithContext(ctx context.Context, params ForwardMessages) ([]MessageID, error) { req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID) return req.DoWithContext(ctx, api) } -// CopyMessageP holds parameters for the copyMessage method. +// CopyMessage holds parameters for the copyMessage method. // See https://core.telegram.org/bots/api#copymessage -type CopyMessageP struct { +type CopyMessage struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -128,7 +128,7 @@ type CopyMessageP struct { // CopyMessage copies a message. // Returns the MessageID of the sent copy. // See https://core.telegram.org/bots/api#copymessage -func (api *API) CopyMessage(params CopyMessageP) (int, error) { +func (api *API) CopyMessage(params CopyMessage) (int, error) { msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).Do(api) if err != nil { return 0, err @@ -139,7 +139,7 @@ func (api *API) CopyMessage(params CopyMessageP) (int, error) { // CopyMessageWithContext is the context-aware variant of CopyMessage. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#copymessage -func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessageP) (int, error) { +func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessage) (int, error) { msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).DoWithContext(ctx, api) if err != nil { return 0, err @@ -147,9 +147,9 @@ func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessageP) return msgID.MessageID, nil } -// CopyMessagesP holds parameters for the copyMessages method. +// CopyMessages holds parameters for the copyMessages method. // See https://core.telegram.org/bots/api#copymessages -type CopyMessagesP struct { +type CopyMessages struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -164,7 +164,7 @@ type CopyMessagesP struct { // CopyMessages copies multiple messages. // Returns an array of message IDs of the sent copies. // See https://core.telegram.org/bots/api#copymessages -func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) { +func (api *API) CopyMessages(params CopyMessages) ([]MessageID, error) { req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID) return req.Do(api) } @@ -172,14 +172,14 @@ func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) { // CopyMessagesWithContext is the context-aware variant of CopyMessages. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#copymessages -func (api *API) CopyMessagesWithContext(ctx context.Context, params CopyMessagesP) ([]MessageID, error) { +func (api *API) CopyMessagesWithContext(ctx context.Context, params CopyMessages) ([]MessageID, error) { req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendLocationP holds parameters for the sendLocation method. +// SendLocation holds parameters for the sendLocation method. // See https://core.telegram.org/bots/api#sendlocation -type SendLocationP struct { +type SendLocation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -204,7 +204,7 @@ type SendLocationP struct { // SendLocation sends a point on the map. // See https://core.telegram.org/bots/api#sendlocation -func (api *API) SendLocation(params SendLocationP) (Message, error) { +func (api *API) SendLocation(params SendLocation) (Message, error) { req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID) return req.Do(api) } @@ -212,14 +212,14 @@ func (api *API) SendLocation(params SendLocationP) (Message, error) { // SendLocationWithContext is the context-aware variant of SendLocation. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendlocation -func (api *API) SendLocationWithContext(ctx context.Context, params SendLocationP) (Message, error) { +func (api *API) SendLocationWithContext(ctx context.Context, params SendLocation) (Message, error) { req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendVenueP holds parameters for the sendVenue method. +// SendVenue holds parameters for the sendVenue method. // See https://core.telegram.org/bots/api#sendvenue -type SendVenueP struct { +type SendVenue struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -246,7 +246,7 @@ type SendVenueP struct { // SendVenue sends information about a venue. // See https://core.telegram.org/bots/api#sendvenue -func (api *API) SendVenue(params SendVenueP) (Message, error) { +func (api *API) SendVenue(params SendVenue) (Message, error) { req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID) return req.Do(api) } @@ -254,14 +254,14 @@ func (api *API) SendVenue(params SendVenueP) (Message, error) { // SendVenueWithContext is the context-aware variant of SendVenue. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvenue -func (api *API) SendVenueWithContext(ctx context.Context, params SendVenueP) (Message, error) { +func (api *API) SendVenueWithContext(ctx context.Context, params SendVenue) (Message, error) { req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendContactP holds parameters for the sendContact method. +// SendContact holds parameters for the sendContact method. // See https://core.telegram.org/bots/api#sendcontact -type SendContactP struct { +type SendContact struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -284,7 +284,7 @@ type SendContactP struct { // SendContact sends a phone contact. // See https://core.telegram.org/bots/api#sendcontact -func (api *API) SendContact(params SendContactP) (Message, error) { +func (api *API) SendContact(params SendContact) (Message, error) { req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID) return req.Do(api) } @@ -292,32 +292,40 @@ func (api *API) SendContact(params SendContactP) (Message, error) { // SendContactWithContext is the context-aware variant of SendContact. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendcontact -func (api *API) SendContactWithContext(ctx context.Context, params SendContactP) (Message, error) { +func (api *API) SendContactWithContext(ctx context.Context, params SendContact) (Message, error) { req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendPollP holds parameters for the sendPoll method. +// SendPoll holds parameters for the sendPoll method. // See https://core.telegram.org/bots/api#sendpoll -type SendPollP struct { +type SendPoll struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` - Question string `json:"question"` - QuestionParseMode ParseMode `json:"question_parse_mode,omitempty"` - QuestionEntities []MessageEntity `json:"question_entities,omitempty"` - Options []InputPollOption `json:"options"` - IsAnonymous bool `json:"is_anonymous,omitempty"` - Type PollType `json:"type"` - AllowsMultipleAnswers bool `json:"allows_multiple_answers,omitempty"` - CorrectOptionID int `json:"correct_option_id,omitempty"` - Explanation string `json:"explanation,omitempty"` - ExplanationParseMode ParseMode `json:"explanation_parse_mode,omitempty"` - ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"` - OpenPeriod int `json:"open_period,omitempty"` - CloseDate int `json:"close_date"` - IsClosed bool `json:"is_closed,omitempty"` + Question string `json:"question"` + QuestionParseMode ParseMode `json:"question_parse_mode,omitempty"` + QuestionEntities []MessageEntity `json:"question_entities,omitempty"` + Options []InputPollOption `json:"options"` + IsAnonymous bool `json:"is_anonymous,omitempty"` + Type PollType `json:"type"` + AllowsMultipleAnswers bool `json:"allows_multiple_answers,omitempty"` + AllowsRevoting bool `json:"allows_revoting,omitempty"` + ShuffleOptions bool `json:"shuffle_options,omitempty"` + AllowAddingOptions bool `json:"allow_adding_options,omitempty"` + HideResultsUntilCloses bool `json:"hide_results_until_closes,omitempty"` + CorrectOptionIDs []int `json:"correct_option_ids,omitempty"` + Explanation string `json:"explanation,omitempty"` + ExplanationParseMode ParseMode `json:"explanation_parse_mode,omitempty"` + ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"` + OpenPeriod int `json:"open_period,omitempty"` + CloseDate int `json:"close_date"` + IsClosed bool `json:"is_closed,omitempty"` + + Description string `json:"description"` + DescriptionParseMode ParseMode `json:"description_parse_mode,omitempty"` + DescriptionEntities []MessageEntity `json:"description_entities,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` @@ -330,7 +338,7 @@ type SendPollP struct { // SendPoll sends a native poll. // See https://core.telegram.org/bots/api#sendpoll -func (api *API) SendPoll(params SendPollP) (Message, error) { +func (api *API) SendPoll(params SendPoll) (Message, error) { req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID) return req.Do(api) } @@ -338,14 +346,14 @@ func (api *API) SendPoll(params SendPollP) (Message, error) { // SendPollWithContext is the context-aware variant of SendPoll. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendpoll -func (api *API) SendPollWithContext(ctx context.Context, params SendPollP) (Message, error) { +func (api *API) SendPollWithContext(ctx context.Context, params SendPoll) (Message, error) { req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendChecklistP holds parameters for the sendChecklist method. +// SendChecklist holds parameters for the sendChecklist method. // See https://core.telegram.org/bots/api#sendchecklist -type SendChecklistP struct { +type SendChecklist struct { BusinessConnectionID string `json:"business_connection_id"` ChatID int64 `json:"chat_id"` Checklist InputChecklist `json:"checklist"` @@ -360,7 +368,7 @@ type SendChecklistP struct { // SendChecklist sends a checklist. // See https://core.telegram.org/bots/api#sendchecklist -func (api *API) SendChecklist(params SendChecklistP) (Message, error) { +func (api *API) SendChecklist(params SendChecklist) (Message, error) { req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID) return req.Do(api) } @@ -368,14 +376,14 @@ func (api *API) SendChecklist(params SendChecklistP) (Message, error) { // SendChecklistWithContext is the context-aware variant of SendChecklist. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendchecklist -func (api *API) SendChecklistWithContext(ctx context.Context, params SendChecklistP) (Message, error) { +func (api *API) SendChecklistWithContext(ctx context.Context, params SendChecklist) (Message, error) { req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendDiceP holds parameters for the sendDice method. +// SendDice holds parameters for the sendDice method. // See https://core.telegram.org/bots/api#senddice -type SendDiceP struct { +type SendDice struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -395,7 +403,7 @@ type SendDiceP struct { // SendDice sends a dice, which will have a random value. // See https://core.telegram.org/bots/api#senddice -func (api *API) SendDice(params SendDiceP) (Message, error) { +func (api *API) SendDice(params SendDice) (Message, error) { req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID) return req.Do(api) } @@ -403,14 +411,14 @@ func (api *API) SendDice(params SendDiceP) (Message, error) { // SendDiceWithContext is the context-aware variant of SendDice. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#senddice -func (api *API) SendDiceWithContext(ctx context.Context, params SendDiceP) (Message, error) { +func (api *API) SendDiceWithContext(ctx context.Context, params SendDice) (Message, error) { req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendMessageDraftP holds parameters for the sendMessageDraft method. +// SendMessageDraft holds parameters for the sendMessageDraft method. // See https://core.telegram.org/bots/api#sendmessagedraft -type SendMessageDraftP struct { +type SendMessageDraft struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DraftID uint64 `json:"draft_id"` @@ -422,7 +430,7 @@ type SendMessageDraftP struct { // SendMessageDraft sends or updates a draft message in the target chat. // Returns True on success. // See https://core.telegram.org/bots/api#sendmessagedraft -func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) { +func (api *API) SendMessageDraft(params SendMessageDraft) (bool, error) { req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID) return req.Do(api) } @@ -430,14 +438,14 @@ func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) { // SendMessageDraftWithContext is the context-aware variant of SendMessageDraft. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendmessagedraft -func (api *API) SendMessageDraftWithContext(ctx context.Context, params SendMessageDraftP) (bool, error) { +func (api *API) SendMessageDraftWithContext(ctx context.Context, params SendMessageDraft) (bool, error) { req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SendChatActionP holds parameters for the sendChatAction method. +// SendChatAction holds parameters for the sendChatAction method. // See https://core.telegram.org/bots/api#sendchataction -type SendChatActionP struct { +type SendChatAction struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -447,7 +455,7 @@ type SendChatActionP struct { // SendChatAction sends a chat action (typing, uploading photo, etc.). // Returns True on success. // See https://core.telegram.org/bots/api#sendchataction -func (api *API) SendChatAction(params SendChatActionP) (bool, error) { +func (api *API) SendChatAction(params SendChatAction) (bool, error) { req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID) return req.Do(api) } @@ -455,14 +463,14 @@ func (api *API) SendChatAction(params SendChatActionP) (bool, error) { // SendChatActionWithContext is the context-aware variant of SendChatAction. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendchataction -func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatActionP) (bool, error) { +func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatAction) (bool, error) { req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID) return req.DoWithContext(ctx, api) } -// SetMessageReactionP holds parameters for the setMessageReaction method. +// SetMessageReaction holds parameters for the setMessageReaction method. // See https://core.telegram.org/bots/api#setmessagereaction -type SetMessageReactionP struct { +type SetMessageReaction struct { ChatID int64 `json:"chat_id"` MessageId int `json:"message_id"` Reaction []ReactionType `json:"reaction"` @@ -472,7 +480,7 @@ type SetMessageReactionP struct { // SetMessageReaction changes the chosen reaction on a message. // Returns True on success. // See https://core.telegram.org/bots/api#setmessagereaction -func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) { +func (api *API) SetMessageReaction(params SetMessageReaction) (bool, error) { req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID) return req.Do(api) } @@ -480,14 +488,14 @@ func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) { // SetMessageReactionWithContext is the context-aware variant of SetMessageReaction. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmessagereaction -func (api *API) SetMessageReactionWithContext(ctx context.Context, params SetMessageReactionP) (bool, error) { +func (api *API) SetMessageReactionWithContext(ctx context.Context, params SetMessageReaction) (bool, error) { req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID) return req.DoWithContext(ctx, api) } -// EditMessageTextP holds parameters for the editMessageText method. +// EditMessageText holds parameters for the editMessageText method. // See https://core.telegram.org/bots/api#editmessagetext -type EditMessageTextP struct { +type EditMessageText struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` @@ -503,7 +511,7 @@ type EditMessageTextP struct { // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagetext -func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error) { +func (api *API) EditMessageText(params EditMessageText) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID) @@ -518,7 +526,7 @@ func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error) // EditMessageTextWithContext is the context-aware variant of EditMessageText. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagetext -func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessageTextP) (Message, bool, error) { +func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessageText) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID) @@ -530,9 +538,9 @@ func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessa return res, false, err } -// EditMessageCaptionP holds parameters for the editMessageCaption method. +// EditMessageCaption holds parameters for the editMessageCaption method. // See https://core.telegram.org/bots/api#editmessagecaption -type EditMessageCaptionP struct { +type EditMessageCaption struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` @@ -548,7 +556,7 @@ type EditMessageCaptionP struct { // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagecaption -func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, error) { +func (api *API) EditMessageCaption(params EditMessageCaption) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID) @@ -563,7 +571,7 @@ func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, e // EditMessageCaptionWithContext is the context-aware variant of EditMessageCaption. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagecaption -func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMessageCaptionP) (Message, bool, error) { +func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMessageCaption) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID) @@ -575,9 +583,9 @@ func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMe return res, false, err } -// EditMessageMediaP holds parameters for the editMessageMedia method. +// EditMessageMedia holds parameters for the editMessageMedia method. // See https://core.telegram.org/bots/api#editmessagemedia -type EditMessageMediaP struct { +type EditMessageMedia struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` @@ -590,7 +598,7 @@ type EditMessageMediaP struct { // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagemedia -func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error) { +func (api *API) EditMessageMedia(params EditMessageMedia) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID) @@ -605,7 +613,7 @@ func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error // EditMessageMediaWithContext is the context-aware variant of EditMessageMedia. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagemedia -func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMessageMediaP) (Message, bool, error) { +func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMessageMedia) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID) @@ -617,9 +625,9 @@ func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMess return res, false, err } -// EditMessageLiveLocationP holds parameters for the editMessageLiveLocation method. +// EditMessageLiveLocation holds parameters for the editMessageLiveLocation method. // See https://core.telegram.org/bots/api#editmessagelivelocation -type EditMessageLiveLocationP struct { +type EditMessageLiveLocation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` @@ -638,7 +646,7 @@ type EditMessageLiveLocationP struct { // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagelivelocation -func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Message, bool, error) { +func (api *API) EditMessageLiveLocation(params EditMessageLiveLocation) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID) @@ -653,7 +661,7 @@ func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Messag // EditMessageLiveLocationWithContext is the context-aware variant of EditMessageLiveLocation. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagelivelocation -func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params EditMessageLiveLocationP) (Message, bool, error) { +func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params EditMessageLiveLocation) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID) @@ -665,9 +673,9 @@ func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params E return res, false, err } -// StopMessageLiveLocationP holds parameters for the stopMessageLiveLocation method. +// StopMessageLiveLocation holds parameters for the stopMessageLiveLocation method. // See https://core.telegram.org/bots/api#stopmessagelivelocation -type StopMessageLiveLocationP struct { +type StopMessageLiveLocation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` @@ -679,7 +687,7 @@ type StopMessageLiveLocationP struct { // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#stopmessagelivelocation -func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Message, bool, error) { +func (api *API) StopMessageLiveLocation(params StopMessageLiveLocation) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID) @@ -694,7 +702,7 @@ func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Messag // StopMessageLiveLocationWithContext is the context-aware variant of StopMessageLiveLocation. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#stopmessagelivelocation -func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params StopMessageLiveLocationP) (Message, bool, error) { +func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params StopMessageLiveLocation) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID) @@ -706,8 +714,8 @@ func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params S return res, false, err } -// EditMessageChecklistP holds parameters for the editMessageChecklist method. -type EditMessageChecklistP struct { +// EditMessageChecklist holds parameters for the editMessageChecklist method. +type EditMessageChecklist struct { BusinessConnectionID string `json:"business_connection_id"` ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` @@ -717,7 +725,7 @@ type EditMessageChecklistP struct { // EditMessageChecklist edits a checklist message. // See https://core.telegram.org/bots/api#editmessagechecklist -func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, error) { +func (api *API) EditMessageChecklist(params EditMessageChecklist) (Message, error) { req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID) return req.Do(api) } @@ -725,14 +733,14 @@ func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, err // EditMessageChecklistWithContext is the context-aware variant of EditMessageChecklist. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagechecklist -func (api *API) EditMessageChecklistWithContext(ctx context.Context, params EditMessageChecklistP) (Message, error) { +func (api *API) EditMessageChecklistWithContext(ctx context.Context, params EditMessageChecklist) (Message, error) { req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID) return req.DoWithContext(ctx, api) } -// EditMessageReplyMarkupP holds parameters for the editMessageReplyMarkup method. +// EditMessageReplyMarkup holds parameters for the editMessageReplyMarkup method. // See https://core.telegram.org/bots/api#editmessagereplymarkup -type EditMessageReplyMarkupP struct { +type EditMessageReplyMarkup struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` @@ -744,7 +752,7 @@ type EditMessageReplyMarkupP struct { // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagereplymarkup -func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message, bool, error) { +func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkup) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID) @@ -759,7 +767,7 @@ func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message, // EditMessageReplyMarkupWithContext is the context-aware variant of EditMessageReplyMarkup. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagereplymarkup -func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params EditMessageReplyMarkupP) (Message, bool, error) { +func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params EditMessageReplyMarkup) (Message, bool, error) { var zero Message if params.InlineMessageID != "" { req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID) @@ -771,9 +779,9 @@ func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params Ed return res, false, err } -// StopPollP holds parameters for the stopPoll method. +// StopPoll holds parameters for the stopPoll method. // See https://core.telegram.org/bots/api#stoppoll -type StopPollP struct { +type StopPoll struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` @@ -783,7 +791,7 @@ type StopPollP struct { // StopPoll stops a poll that was sent by the bot. // Returns the stopped Poll. // See https://core.telegram.org/bots/api#stoppoll -func (api *API) StopPoll(params StopPollP) (Poll, error) { +func (api *API) StopPoll(params StopPoll) (Poll, error) { req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID) return req.Do(api) } @@ -791,14 +799,14 @@ func (api *API) StopPoll(params StopPollP) (Poll, error) { // StopPollWithContext is the context-aware variant of StopPoll. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#stoppoll -func (api *API) StopPollWithContext(ctx context.Context, params StopPollP) (Poll, error) { +func (api *API) StopPollWithContext(ctx context.Context, params StopPoll) (Poll, error) { req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID) return req.DoWithContext(ctx, api) } -// ApproveSuggestedPostP holds parameters for the approveSuggestedPost method. +// ApproveSuggestedPost holds parameters for the approveSuggestedPost method. // See https://core.telegram.org/bots/api#approvesuggestedpost -type ApproveSuggestedPostP struct { +type ApproveSuggestedPost struct { ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` SendDate int `json:"send_date,omitempty"` @@ -807,7 +815,7 @@ type ApproveSuggestedPostP struct { // ApproveSuggestedPost approves a suggested channel post. // Returns True on success. // See https://core.telegram.org/bots/api#approvesuggestedpost -func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error) { +func (api *API) ApproveSuggestedPost(params ApproveSuggestedPost) (bool, error) { req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID) return req.Do(api) } @@ -815,14 +823,14 @@ func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error) // ApproveSuggestedPostWithContext is the context-aware variant of ApproveSuggestedPost. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#approvesuggestedpost -func (api *API) ApproveSuggestedPostWithContext(ctx context.Context, params ApproveSuggestedPostP) (bool, error) { +func (api *API) ApproveSuggestedPostWithContext(ctx context.Context, params ApproveSuggestedPost) (bool, error) { req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID) return req.DoWithContext(ctx, api) } -// DeclineSuggestedPostP holds parameters for the declineSuggestedPost method. +// DeclineSuggestedPost holds parameters for the declineSuggestedPost method. // See https://core.telegram.org/bots/api#declinesuggestedpost -type DeclineSuggestedPostP struct { +type DeclineSuggestedPost struct { ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` Comment string `json:"comment,omitempty"` @@ -831,7 +839,7 @@ type DeclineSuggestedPostP struct { // DeclineSuggestedPost declines a suggested channel post. // Returns True on success. // See https://core.telegram.org/bots/api#declinesuggestedpost -func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error) { +func (api *API) DeclineSuggestedPost(params DeclineSuggestedPost) (bool, error) { req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID) return req.Do(api) } @@ -839,14 +847,14 @@ func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error) // DeclineSuggestedPostWithContext is the context-aware variant of DeclineSuggestedPost. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#declinesuggestedpost -func (api *API) DeclineSuggestedPostWithContext(ctx context.Context, params DeclineSuggestedPostP) (bool, error) { +func (api *API) DeclineSuggestedPostWithContext(ctx context.Context, params DeclineSuggestedPost) (bool, error) { req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID) return req.DoWithContext(ctx, api) } -// DeleteMessageP holds parameters for the deleteMessage method. +// DeleteMessage holds parameters for the deleteMessage method. // See https://core.telegram.org/bots/api#deletemessage -type DeleteMessageP struct { +type DeleteMessage struct { ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` } @@ -854,7 +862,7 @@ type DeleteMessageP struct { // DeleteMessage deletes a message. // Returns True on success. // See https://core.telegram.org/bots/api#deletemessage -func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) { +func (api *API) DeleteMessage(params DeleteMessage) (bool, error) { req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID) return req.Do(api) } @@ -862,14 +870,14 @@ func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) { // DeleteMessageWithContext is the context-aware variant of DeleteMessage. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletemessage -func (api *API) DeleteMessageWithContext(ctx context.Context, params DeleteMessageP) (bool, error) { +func (api *API) DeleteMessageWithContext(ctx context.Context, params DeleteMessage) (bool, error) { req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID) return req.DoWithContext(ctx, api) } -// DeleteMessagesP holds parameters for the deleteMessages method. +// DeleteMessages holds parameters for the deleteMessages method. // See https://core.telegram.org/bots/api#deletemessages -type DeleteMessagesP struct { +type DeleteMessages struct { ChatID int64 `json:"chat_id"` MessageIDs []int `json:"message_ids"` } @@ -877,7 +885,7 @@ type DeleteMessagesP struct { // DeleteMessages deletes multiple messages at once. // Returns True on success. // See https://core.telegram.org/bots/api#deletemessages -func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) { +func (api *API) DeleteMessages(params DeleteMessages) (bool, error) { req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID) return req.Do(api) } @@ -885,14 +893,14 @@ func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) { // DeleteMessagesWithContext is the context-aware variant of DeleteMessages. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletemessages -func (api *API) DeleteMessagesWithContext(ctx context.Context, params DeleteMessagesP) (bool, error) { +func (api *API) DeleteMessagesWithContext(ctx context.Context, params DeleteMessages) (bool, error) { req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID) return req.DoWithContext(ctx, api) } -// AnswerCallbackQueryP holds parameters for the answerCallbackQuery method. +// AnswerCallbackQuery holds parameters for the answerCallbackQuery method. // See https://core.telegram.org/bots/api#answercallbackquery -type AnswerCallbackQueryP struct { +type AnswerCallbackQuery struct { CallbackQueryID string `json:"callback_query_id"` Text string `json:"text,omitempty"` ShowAlert bool `json:"show_alert,omitempty"` @@ -903,7 +911,7 @@ type AnswerCallbackQueryP struct { // AnswerCallbackQuery sends answers to callback queries sent from inline keyboards. // Returns True on success. // See https://core.telegram.org/bots/api#answercallbackquery -func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) { +func (api *API) AnswerCallbackQuery(params AnswerCallbackQuery) (bool, error) { req := NewRequest[bool]("answerCallbackQuery", params) return req.Do(api) } @@ -911,7 +919,7 @@ func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) { // AnswerCallbackQueryWithContext is the context-aware variant of AnswerCallbackQuery. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answercallbackquery -func (api *API) AnswerCallbackQueryWithContext(ctx context.Context, params AnswerCallbackQueryP) (bool, error) { +func (api *API) AnswerCallbackQueryWithContext(ctx context.Context, params AnswerCallbackQuery) (bool, error) { req := NewRequest[bool]("answerCallbackQuery", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 353da19..964a3f5 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -1,64 +1,222 @@ package tgapi -import "git.scuroneko.dev/scuroneko/extypes" +import ( + "encoding/json" + + "git.scuroneko.dev/scuroneko/extypes" +) // MessageID represents a message identifier wrapper returned by some API methods. type MessageID struct { MessageID int `json:"message_id"` } -// MessageReplyMarkup represents an inline keyboard markup for a message. -// It is used in the Message type. -type MessageReplyMarkup struct { - InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"` -} - // DirectMessageTopic represents a forum topic in a direct message. type DirectMessageTopic struct { TopicID int64 `json:"topic_id"` User *User `json:"user,omitempty"` } +type MessageOriginType string + +const ( + MessageOriginUserType = "user" + MessageOriginHiddenUserType = "hidden_user" + MessageOriginChatType = "chat" + MessageOriginChannel = "channel" +) + +type MessageOrigin struct { + Type MessageOriginType `json:"type"` + Date int64 `json:"date"` + + SenderUser *User `json:"sender_user,omitempty"` + + SenderUserName string `json:"sender_user_name,omitempty"` + + SenderChat *Chat `json:"sender_chat,omitempty"` + + Chat *Chat `json:"chat,omitempty"` + MessageID int `json:"message_id"` + + AuthorSignature string `json:"author_signature,omitempty"` +} + +type ExternalReplyInfo struct { + Origin MessageOrigin `json:"origin"` + Chat *Chat `json:"chat,omitempty"` + MessageID int `json:"message_id,omitempty"` + LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` + Animation *Animation `json:"animation,omitempty"` + Audio *Audio `json:"audio,omitempty"` + Document *Document `json:"document,omitempty"` + PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` + Photo []PhotoSize `json:"photo,omitempty"` + Sticker *Sticker `json:"sticker,omitempty"` + Story *Story `json:"story,omitempty"` + Video *Video `json:"video,omitempty"` + VideoNote *VideoNote `json:"video_note,omitempty"` + Voice *Voice `json:"voice,omitempty"` + HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"` + Checklist *Checklist `json:"checklist,omitempty"` + Contact *Contact `json:"contact,omitempty"` + Dice *Dice `json:"dice,omitempty"` + Game *Game `json:"game,omitempty"` + Giveaway *Giveaway `json:"giveaway,omitempty"` + GiveawayWinners *GiveawayWinners `json:"giveaway_winners,omitempty"` + Invoice *Invoice `json:"invoice,omitempty"` + Location *Location `json:"location,omitempty"` + Poll *Poll `json:"poll,omitempty"` + Venue *Venue `json:"venue,omitempty"` +} + +type TextQuote struct { + Text string `json:"text"` + Entities []MessageEntity `json:"entities"` + Position int `json:"position"` + IsManual bool `json:"is_manual,omitempty"` +} + +type MessageAutoDeleteTimerChanged struct { + MessageAutoDeleteTime int `json:"message_auto_delete_time"` +} + +type DirectMessagePriceChanged struct { + AreDirectMessagesEnabled bool `json:"are_direct_messages_enabled"` + DirectMessageStarCount int `json:"direct_message_star_count,omitempty"` +} + +type PaidMessagePriceChanged struct { + PaidMessageStarCount int `json:"paid_message_star_count"` +} + // Message represents a Telegram message. // See https://core.telegram.org/bots/api#message type Message struct { - MessageID int `json:"message_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"` - BusinessConnectionId string `json:"business_connection_id,omitempty"` - From *User `json:"from,omitempty"` + MessageID int `json:"message_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"` + From *User `json:"from,omitempty"` - SenderChat *Chat `json:"sender_chat,omitempty"` - SenderBoostCount int `json:"sender_boost_count,omitempty"` - SenderBusinessBot *User `json:"sender_business_bot,omitempty"` - SenderTag string `json:"sender_tag,omitempty"` - Chat *Chat `json:"chat,omitempty"` + SenderChat *Chat `json:"sender_chat,omitempty"` + SenderBoostCount int `json:"sender_boost_count,omitempty"` + SenderBusinessBot *User `json:"sender_business_bot,omitempty"` + SenderTag string `json:"sender_tag,omitempty"` + Date int `json:"date"` + BusinessConnectionId string `json:"business_connection_id,omitempty"` + Chat *Chat `json:"chat,omitempty"` + ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"` - IsTopicMessage bool `json:"is_topic_message,omitempty"` - IsAutomaticForward bool `json:"is_automatic_forward,omitempty"` - IsFromOffline bool `json:"is_from_offline,omitempty"` - IsPaidPost bool `json:"is_paid_post,omitempty"` - MediaGroupId string `json:"media_group_id,omitempty"` - AuthorSignature string `json:"author_signature,omitempty"` - PaidStarCount int `json:"paid_star_count,omitempty"` - ReplyToMessage *Message `json:"reply_to_message,omitempty"` + IsTopicMessage bool `json:"is_topic_message,omitempty"` + IsAutomaticForward bool `json:"is_automatic_forward,omitempty"` + ReplyToMessage *Message `json:"reply_to_message,omitempty"` + ExternalReply *ExternalReplyInfo `json:"external_reply,omitempty"` + Quote *TextQuote `json:"quote,omitempty"` - Text string `json:"text"` - - Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"` - Caption string `json:"caption,omitempty"` - CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` - - Date int `json:"date"` - EditDate int `json:"edit_date"` - - ReplyMarkup *MessageReplyMarkup `json:"reply_markup,omitempty"` + ReplyToStory *Story `json:"reply_to_story,omitempty"` + ReplyToChecklistTaskID int `json:"reply_to_checklist_task_id,omitempty"` + ReplyToPollOptionID string `json:"reply_to_poll_option_id,omitempty"` + ViaBot *User `json:"via_bot,omitempty"` + EditDate int `json:"edit_date,omitempty"` + HasProtectedContent bool `json:"has_protected_content,omitempty"` + IsFromOffline bool `json:"is_from_offline,omitempty"` + IsPaidPost bool `json:"is_paid_post,omitempty"` + MediaGroupId string `json:"media_group_id,omitempty"` + AuthorSignature string `json:"author_signature,omitempty"` + PaidStarCount int `json:"paid_star_count,omitempty"` + Text string `json:"text"` Entities []MessageEntity `json:"entities,omitempty"` LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"` + EffectID string `json:"effect_id,omitempty"` - EffectID string `json:"effect_id,omitempty"` + Animation *Animation `json:"animation,omitempty"` + Audio *Audio `json:"audio,omitempty"` + Document *Document `json:"document,omitempty"` + PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` + Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"` + Sticker *Sticker `json:"sticker,omitempty"` + Story *Story `json:"story,omitempty"` + Video *Video `json:"video,omitempty"` + VideoNote *VideoNote `json:"video_note,omitempty"` + Voice *Voice `json:"voice,omitempty"` + Caption string `json:"caption,omitempty"` + CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` + HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"` + Checklist *Checklist `json:"checklist,omitempty"` + Contact *Contact `json:"contact,omitempty"` + Dice *Dice `json:"dice,omitempty"` + Game *Game `json:"game,omitempty"` + Poll *Poll `json:"poll,omitempty"` + Venue *Venue `json:"venue,omitempty"` + Location *Location `json:"location,omitempty"` + + NewChatMembers []User `json:"new_chat_members,omitempty"` + LeftChatMember *User `json:"left_chat_member,omitempty"` + ChatOwnerLeft *ChatOwnerLeft `json:"chat_owner_left,omitempty"` + ChatOwnerChanged *ChatOwnerChanged `json:"chat_owner_changed,omitempty"` + NewChatTitle string `json:"new_chat_title,omitempty"` + NewChatPhoto []PhotoSize `json:"new_chat_photo,omitempty"` + DeleteChatPhoto bool `json:"delete_chat_photo,omitempty"` + GroupChatCreated bool `json:"group_chat_created,omitempty"` + SupergroupChatCreated bool `json:"supergroup_chat_created,omitempty"` + ChannelChatCreated bool `json:"channel_chat_created,omitempty"` + MessageAutoDeleteTimerChanged *MessageAutoDeleteTimerChanged `json:"message_auto_delete_timer_changed,omitempty"` + MigrateToChatID int64 `json:"migrate_to_chat_id,omitempty"` + MigrateFromChatID int64 `json:"migrate_from_chat_id,omitempty"` + PinnedMessage *MaybeInaccessibleMessage `json:"pinned_message,omitempty"` + + Invoice *Invoice `json:"invoice,omitempty"` + SuccessfulPayment *SuccessfulPayment `json:"successful_payment,omitempty"` + RefundedPayment *RefundedPayment `json:"refunded_payment,omitempty"` + UsersShared *UsersShared `json:"users_shared,omitempty"` + ChatShared *ChatShared `json:"chat_shared,omitempty"` + Gift *GiftInfo `json:"gift,omitempty"` + UniqueGift *UniqueGiftInfo `json:"unique_gift,omitempty"` + GiftUpgradeSent *GiftInfo `json:"gift_upgrade_sent,omitempty"` + + ConnectedWebsite string `json:"connected_website,omitempty"` + WriteAccessAllowed *WriteAccessAllowed `json:"write_access_allowed,omitempty"` + PassportData *PassportData `json:"passport_data,omitempty"` + ProximityAlertTriggered *ProximityAlertTriggered `json:"proximity_alert_triggered,omitempty"` + BoostAdded *ChatBoostAdded `json:"boost_added,omitempty"` + ChatBackgroundSet *ChatBackground `json:"chat_background_set,omitempty"` + + ChecklistTaskDone *ChecklistTaskDone `json:"checklist_task_done,omitempty"` + ChecklistTasksAdded *ChecklistTasksAdded `json:"checklist_tasks_added,omitempty"` + DirectMessagePriceChanged *DirectMessagePriceChanged `json:"direct_message_price_changed,omitempty"` + ForumTopicCreated *ForumTopicCreated `json:"forum_topic_created,omitempty"` + ForumTopicEdited *ForumTopicEdited `json:"forum_topic_edited,omitempty"` + ForumTopicClosed *ForumTopicClosed `json:"forum_topic_closed,omitempty"` + ForumTopicReopened *ForumTopicReopened `json:"forum_topic_reopened,omitempty"` + GeneralForumTopicHidden *GeneralForumTopicHidden `json:"general_forum_topic_hidden,omitempty"` + GeneralForumTopicUnhidden *GeneralForumTopicUnhidden `json:"general_forum_topic_unhidden,omitempty"` + + GiveawayCreated *GiveawayCreated `json:"giveaway_created,omitempty"` + Giveaway *Giveaway `json:"giveaway,omitempty"` + GiveawayWinners *GiveawayWinners `json:"giveaway_winners,omitempty"` + GiveawayCompleted *GiveawayCompleted `json:"giveaway_completed,omitempty"` + + ManagedBotCreated *ManagedBotCreated `json:"managed_bot_created,omitempty"` + PaidMessagePriceChanged *PaidMessagePriceChanged `json:"paid_message_price_changed,omitempty"` + PollOptionAdded *PollOptionAdded `json:"poll_option_added,omitempty"` + PollOptionDeleted *PollOptionDeleted `json:"poll_option_deleted,omitempty"` + + SuggestedPostApproved *SuggestedPostApproved `json:"suggested_post_approved,omitempty"` + SuggestedPostApprovalFailed *SuggestedPostApprovalFailed `json:"suggested_post_approval_failed,omitempty"` + SuggestedPostDeclined *SuggestedPostDeclined `json:"suggested_post_declined,omitempty"` + SuggestedPostPaid *SuggestedPostPaid `json:"suggested_post_paid,omitempty"` + SuggestedPostRefunded *SuggestedPostRefunded `json:"suggested_post_refunded,omitempty"` + + VideoChatScheduled *VideoChatScheduled `json:"video_chat_scheduled,omitempty"` + VideoChatStarted *VideoChatStarted `json:"video_chat_started,omitempty"` + VideoChatEnded *VideoChatEnded `json:"video_chat_ended,omitempty"` + VideoChatParticipantsInvited *VideoChatParticipantsInvited `json:"video_chat_participants_invited,omitempty"` + + WebAppData *WebAppData `json:"web_app_data,omitempty"` + ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } // InaccessibleMessage describes a message that was deleted or is otherwise inaccessible. @@ -71,7 +229,80 @@ type InaccessibleMessage struct { // MaybeInaccessibleMessage is a union type that can be either Message or InaccessibleMessage. // See https://core.telegram.org/bots/api#maybeinaccessiblemessage -type MaybeInaccessibleMessage interface{ Message | InaccessibleMessage } +type MaybeInaccessibleMessage struct { + msg *Message + ina *InaccessibleMessage +} + +// UnmarshalJSON decodes either an accessible Message or an InaccessibleMessage. +func (m *MaybeInaccessibleMessage) UnmarshalJSON(data []byte) error { + tmp := struct { + Date int `json:"date"` + }{} + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + var err error + if tmp.Date > 0 { + err = json.Unmarshal(data, &m.msg) + } else { + err = json.Unmarshal(data, &m.ina) + } + if err != nil { + return err + } + return nil +} + +// MarshalJSON encodes the populated accessible or inaccessible message payload. +func (m *MaybeInaccessibleMessage) MarshalJSON() ([]byte, error) { + if m.msg != nil { + return json.Marshal(m.msg) + } else if m.ina != nil { + return json.Marshal(m.ina) + } + return json.Marshal(nil) +} + +// Message returns the accessible message payload when present. +func (m *MaybeInaccessibleMessage) Message() *Message { + return m.msg +} + +// InaccessibleMessage returns the inaccessible message payload when present. +func (m *MaybeInaccessibleMessage) InaccessibleMessage() *InaccessibleMessage { + return m.ina +} + +// IsAccessible reports whether the payload is an accessible message. +func (m *MaybeInaccessibleMessage) IsAccessible() bool { + return m.msg != nil +} + +// IsInaccessible reports whether the payload is an inaccessible message. +func (m *MaybeInaccessibleMessage) IsInaccessible() bool { + return m.ina != nil +} + +// MessageID returns the message identifier from either payload form. +func (m *MaybeInaccessibleMessage) MessageID() int { + if m.IsAccessible() { + return m.msg.MessageID + } else if m.IsInaccessible() { + return m.ina.MessageID + } + return 0 +} + +// Chat returns the chat from either payload form. +func (m *MaybeInaccessibleMessage) Chat() *Chat { + if m.IsAccessible() { + return m.msg.Chat + } else if m.IsInaccessible() { + return &m.ina.Chat + } + return nil +} // MessageEntityType represents the type of a message entity. type MessageEntityType string @@ -147,6 +378,7 @@ type ReplyParameters struct { QuoteEntities []MessageEntity `json:"quote_entities,omitempty"` QuotePosition int `json:"quote_position,omitempty"` ChecklistTaskID int `json:"checklist_task_id,omitempty"` + PollOptionID string `json:"poll_option_id,omitempty"` } // LinkPreviewOptions describes the options used for link preview generation. @@ -197,15 +429,16 @@ const ( // KeyboardButton represents one button of the reply keyboard. // See https://core.telegram.org/bots/api#keyboardbutton type KeyboardButton struct { - Text string `json:"text"` - IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` - Style KeyboardButtonStyle `json:"style,omitempty"` - RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"` - RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"` - RequestContact bool `json:"request_contact,omitempty"` - RequestLocation bool `json:"request_location,omitempty"` - RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"` - WebApp *WebAppInfo `json:"web_app,omitempty"` + Text string `json:"text"` + IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` + Style KeyboardButtonStyle `json:"style,omitempty"` + RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"` + RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"` + RequestManagedBot *KeyboardButtonRequestManagedBot `json:"request_managed_bot,omitempty"` + RequestContact bool `json:"request_contact,omitempty"` + RequestLocation bool `json:"request_location,omitempty"` + RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"` + WebApp *WebAppInfo `json:"web_app,omitempty"` } // KeyboardButtonRequestUsers defines criteria used to request suitable users. @@ -236,6 +469,14 @@ type KeyboardButtonRequestChat struct { RequestPhoto bool `json:"request_photo,omitempty"` } +// KeyboardButtonRequestManagedBot defines criteria used to request a managed bot. +// See https://core.telegram.org/bots/api#keyboardbuttonrequestmanagedbot +type KeyboardButtonRequestManagedBot struct { + RequestID int32 `json:"request_id"` + SuggestedName string `json:"suggested_name,omitempty"` + SuggestedUsername string `json:"suggested_username,omitempty"` +} + // KeyboardButtonPollType represents the type of a poll that may be created from a keyboard button. // See https://core.telegram.org/bots/api#keyboardbuttonpolltype type KeyboardButtonPollType struct { @@ -275,42 +516,6 @@ type CallbackQuery struct { GameShortName string `json:"game_short_name,omitempty"` } -// InputPollOption contains information about one answer option in a poll to be sent. -// See https://core.telegram.org/bots/api#inputpolloption -type InputPollOption struct { - Text string `json:"text"` - TextParseMode ParseMode `json:"text_parse_mode,omitempty"` - TextEntities []MessageEntity `json:"text_entities,omitempty"` -} - -// PollType represents the type of a poll. -type PollType string - -const ( - // PollTypeRegular identifies a regular poll. - PollTypeRegular PollType = "regular" - // PollTypeQuiz identifies a quiz poll. - PollTypeQuiz PollType = "quiz" -) - -// InputChecklistTask describes a task in a checklist. -type InputChecklistTask struct { - ID int `json:"id"` - Text string `json:"text"` - ParseMode ParseMode `json:"parse_mode,omitempty"` - TextEntities []MessageEntity `json:"text_entities,omitempty"` -} - -// InputChecklist represents a checklist to be sent. -type InputChecklist struct { - Title string `json:"title"` - ParseMode ParseMode `json:"parse_mode,omitempty"` - TitleEntities []MessageEntity `json:"title_entities,omitempty"` - Tasks []InputChecklistTask `json:"tasks"` - OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"` - OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"` -} - // ChatActionType represents the type of chat action. type ChatActionType string @@ -392,3 +597,70 @@ type SuggestedPostParameters struct { Price SuggestedPostPrice `json:"price"` SendDate int `json:"send_date"` } + +// ManagedBotCreated describes a service message about a newly created managed bot. +// See https://core.telegram.org/bots/api#managedbotcreated +type ManagedBotCreated struct { + Bot User `json:"bot"` +} + +// ManagedBotUpdated describes an update about a managed bot and its manager. +// See https://core.telegram.org/bots/api#managedbotupdated +type ManagedBotUpdated struct { + User User `json:"user"` + Bot User `json:"bot"` +} + +type SharedUser struct { + UserID int64 `json:"user_id"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + Photo []PhotoSize `json:"photo,omitempty"` +} +type UsersShared struct { + RequestID int `json:"request_id"` + Users []SharedUser `json:"users"` +} +type ChatShared struct { + RequestID int `json:"request_id"` + ChatID int64 `json:"chat_id"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + Photo []PhotoSize `json:"photo,omitempty"` +} + +type SuggestedPostApproved struct { + SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` + Price SuggestedPostPrice `json:"price"` + SendDate int `json:"send_date"` +} +type SuggestedPostApprovalFailed struct { + SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` + Price SuggestedPostPrice `json:"price"` +} +type SuggestedPostDeclined struct { + SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` + Comment string `json:"comment,omitempty"` +} +type SuggestedPostPaid struct { + SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` + Currency string `json:"currency"` + Amount int `json:"amount"` + StarAmount *StarAmount `json:"star_amount,omitempty"` +} +type SuggestedPostRefunded struct { + SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type VideoChatScheduled struct { + StartDate int64 `json:"start_date"` +} +type VideoChatStarted struct{} +type VideoChatEnded struct { + Duration int64 `json:"duration"` +} +type VideoChatParticipantsInvited struct { + Users []User `json:"users"` +} diff --git a/tgapi/methods.go b/tgapi/methods.go index a4d5eb9..37b664b 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -33,6 +33,48 @@ func (api *API) GetMeWithContext(ctx context.Context) (User, error) { return req.DoWithContext(ctx, api) } +// GetManagedBotToken holds parameters for the getManagedBotToken method. +// See https://core.telegram.org/bots/api#getmanagedbottoken +type GetManagedBotToken struct { + UserID int64 `json:"user_id"` +} + +// GetManagedBotToken returns the current token of a managed bot. +// See https://core.telegram.org/bots/api#getmanagedbottoken +func (api *API) GetManagedBotToken(params GetManagedBotToken) (string, error) { + req := NewRequest[string]("getManagedBotToken", params) + return req.Do(api) +} + +// GetManagedBotTokenWithContext is the context-aware variant of GetManagedBotToken. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getmanagedbottoken +func (api *API) GetManagedBotTokenWithContext(ctx context.Context, params GetManagedBotToken) (string, error) { + req := NewRequest[string]("getManagedBotToken", params) + return req.DoWithContext(ctx, api) +} + +// ReplaceManagedBotToken holds parameters for the replaceManagedBotToken method. +// See https://core.telegram.org/bots/api#replacemanagedbottoken +type ReplaceManagedBotToken struct { + UserID int64 `json:"user_id"` +} + +// ReplaceManagedBotToken replaces and returns the token of a managed bot. +// See https://core.telegram.org/bots/api#replacemanagedbottoken +func (api *API) ReplaceManagedBotToken(params ReplaceManagedBotToken) (string, error) { + req := NewRequest[string]("replaceManagedBotToken", params) + return req.Do(api) +} + +// ReplaceManagedBotTokenWithContext is the context-aware variant of ReplaceManagedBotToken. +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#replacemanagedbottoken +func (api *API) ReplaceManagedBotTokenWithContext(ctx context.Context, params ReplaceManagedBotToken) (string, error) { + req := NewRequest[string]("replaceManagedBotToken", params) + return req.DoWithContext(ctx, api) +} + // LogOut logs the bot out from the cloud Bot API server. // Returns true on success. // See https://core.telegram.org/bots/api#logout @@ -80,10 +122,10 @@ func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) return req.DoWithContext(ctx, api) } -// SetWebhookP holds parameters for the setWebhook method. +// SetWebhook holds parameters for the setWebhook method. // To upload a self-signed certificate, use Uploader.SetWebhook. // See https://core.telegram.org/bots/api#setwebhook -type SetWebhookP struct { +type SetWebhook struct { URL string `json:"url"` IPAddress string `json:"ip_address,omitempty"` MaxConnections int8 `json:"max_connections,omitempty"` @@ -96,7 +138,7 @@ type SetWebhookP struct { // For certificate upload, use Uploader.SetWebhook. // Returns true on success. // See https://core.telegram.org/bots/api#setwebhook -func (api *API) SetWebhook(params SetWebhookP) (bool, error) { +func (api *API) SetWebhook(params SetWebhook) (bool, error) { req := NewRequest[bool]("setWebhook", params) return req.Do(api) } @@ -105,21 +147,21 @@ func (api *API) SetWebhook(params SetWebhookP) (bool, error) { // It executes the same request but uses ctx for cancellation and deadlines. // For certificate upload, use Uploader.SetWebhook. // See https://core.telegram.org/bots/api#setwebhook -func (api *API) SetWebhookWithContext(ctx context.Context, params SetWebhookP) (bool, error) { +func (api *API) SetWebhookWithContext(ctx context.Context, params SetWebhook) (bool, error) { req := NewRequest[bool]("setWebhook", params) return req.DoWithContext(ctx, api) } -// DeleteWebhookP holds parameters for the deleteWebhook method. +// DeleteWebhook holds parameters for the deleteWebhook method. // See https://core.telegram.org/bots/api#deletewebhook -type DeleteWebhookP struct { +type DeleteWebhook struct { DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` } // DeleteWebhook removes the current webhook integration. // Returns true on success. // See https://core.telegram.org/bots/api#deletewebhook -func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) { +func (api *API) DeleteWebhook(params DeleteWebhook) (bool, error) { req := NewRequest[bool]("deleteWebhook", params) return req.Do(api) } @@ -127,7 +169,7 @@ func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) { // DeleteWebhookWithContext is the context-aware variant of DeleteWebhook. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletewebhook -func (api *API) DeleteWebhookWithContext(ctx context.Context, params DeleteWebhookP) (bool, error) { +func (api *API) DeleteWebhookWithContext(ctx context.Context, params DeleteWebhook) (bool, error) { req := NewRequest[bool]("deleteWebhook", params) return req.DoWithContext(ctx, api) } @@ -147,15 +189,15 @@ func (api *API) GetWebhookInfoWithContext(ctx context.Context) (WebhookInfo, err return req.DoWithContext(ctx, api) } -// GetFileP holds parameters for the getFile method. +// GetFile holds parameters for the getFile method. // See https://core.telegram.org/bots/api#getfile -type GetFileP struct { - FileId string `json:"file_id"` +type GetFile struct { + FileID string `json:"file_id"` } // GetFile returns basic information about a file and prepares it for downloading. // See https://core.telegram.org/bots/api#getfile -func (api *API) GetFile(params GetFileP) (File, error) { +func (api *API) GetFile(params GetFile) (File, error) { req := NewRequest[File]("getFile", params) return req.Do(api) } @@ -163,7 +205,7 @@ func (api *API) GetFile(params GetFileP) (File, error) { // GetFileWithContext is the context-aware variant of GetFile. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getfile -func (api *API) GetFileWithContext(ctx context.Context, params GetFileP) (File, error) { +func (api *API) GetFileWithContext(ctx context.Context, params GetFile) (File, error) { req := NewRequest[File]("getFile", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/methods_test.go b/tgapi/methods_test.go index dce8c98..5769fd0 100644 --- a/tgapi/methods_test.go +++ b/tgapi/methods_test.go @@ -151,3 +151,60 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) { t.Fatalf("expected allowed_updates to be omitted, got %v", gotBody["allowed_updates"]) } } + +func TestSetChatMenuButtonSendsStructuredMenuButton(t *testing.T) { + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)), + }, nil + }), + } + + api := NewAPI( + NewAPIOpts("token"). + SetAPIUrl("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + text := "Open" + if _, err := api.SetChatMenuButton(SetChatMenuButton{ + ChatID: 42, + MenuButton: &MenuButton{ + Type: MenuButtonWebAppType, + Text: &text, + WebApp: &WebAppInfo{ + URL: "https://example.test/app", + }, + }, + }); err != nil { + t.Fatalf("SetChatMenuButton returned error: %v", err) + } + + menuButton, ok := gotBody["menu_button"].(map[string]any) + if !ok { + t.Fatalf("expected structured menu_button, got %#v", gotBody["menu_button"]) + } + if menuButton["type"] != string(MenuButtonWebAppType) { + t.Fatalf("unexpected menu button type: %#v", menuButton["type"]) + } + if menuButton["text"] != text { + t.Fatalf("unexpected menu button text: %#v", menuButton["text"]) + } +} diff --git a/tgapi/methods_types.go b/tgapi/methods_types.go index ea4294a..0c4d0d9 100644 --- a/tgapi/methods_types.go +++ b/tgapi/methods_types.go @@ -19,17 +19,3 @@ type EmptyParams struct{} // NoParams is a convenient instance of EmptyParams. var NoParams = EmptyParams{} - -// WebhookInfo describes the current webhook status. -// See https://core.telegram.org/bots/api#webhookinfo -type WebhookInfo struct { - URL string `json:"url"` - HasCustomCertificate bool `json:"has_custom_certificate"` - PendingUpdateCount int `json:"pending_update_count"` - IPAddress string `json:"ip_address,omitempty"` - LastErrorDate int `json:"last_error_date,omitempty"` - LastErrorMessage string `json:"last_error_message,omitempty"` - LastSynchronizationErrorDate int `json:"last_synchronization_error_date,omitempty"` - MaxConnections int `json:"max_connections,omitempty"` - AllowedUpdates []string `json:"allowed_updates,omitempty"` -} diff --git a/tgapi/parse_mode_test.go b/tgapi/parse_mode_test.go index c2ec740..8b1f2d3 100644 --- a/tgapi/parse_mode_test.go +++ b/tgapi/parse_mode_test.go @@ -7,7 +7,7 @@ import ( ) func TestParseNoneOmitsParseModeInJSON(t *testing.T) { - data, err := json.Marshal(SendMessageP{ + data, err := json.Marshal(SendMessage{ ChatID: 42, Text: "hello", ParseMode: ParseNone, @@ -22,7 +22,7 @@ func TestParseNoneOmitsParseModeInJSON(t *testing.T) { } func TestParseModeStillSerializesExplicitModes(t *testing.T) { - data, err := json.Marshal(SendMessageP{ + data, err := json.Marshal(SendMessage{ ChatID: 42, Text: "hello", ParseMode: ParseMDV2, diff --git a/tgapi/passport_methods.go b/tgapi/passport_methods.go index 4af8fd5..79aafe3 100644 --- a/tgapi/passport_methods.go +++ b/tgapi/passport_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// SetPassportDataErrorsP holds parameters for the setPassportDataErrors method. +// SetPassportDataErrors holds parameters for the setPassportDataErrors method. // See https://core.telegram.org/bots/api#setpassportdataerrors -type SetPassportDataErrorsP struct { +type SetPassportDataErrors struct { UserID int64 `json:"user_id"` Errors []PassportElementError `json:"errors"` } @@ -12,7 +12,7 @@ type SetPassportDataErrorsP struct { // SetPassportDataErrors informs a user about Telegram Passport data errors. // Returns true on success. // See https://core.telegram.org/bots/api#setpassportdataerrors -func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, error) { +func (api *API) SetPassportDataErrors(params SetPassportDataErrors) (bool, error) { req := NewRequest[bool]("setPassportDataErrors", params) return req.Do(api) } @@ -20,7 +20,7 @@ func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, erro // SetPassportDataErrorsWithContext is the context-aware variant of SetPassportDataErrors. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setpassportdataerrors -func (api *API) SetPassportDataErrorsWithContext(ctx context.Context, params SetPassportDataErrorsP) (bool, error) { +func (api *API) SetPassportDataErrorsWithContext(ctx context.Context, params SetPassportDataErrors) (bool, error) { req := NewRequest[bool]("setPassportDataErrors", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/passport_types.go b/tgapi/passport_types.go index b8e7ce7..e01f347 100644 --- a/tgapi/passport_types.go +++ b/tgapi/passport_types.go @@ -1,5 +1,64 @@ package tgapi +type PassportData struct { + Data []EncryptedPassportElement `json:"data"` + Credentials EncryptedCredentials `json:"credentials"` +} +type PassportFile struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + FileSize int64 `json:"file_size"` + FileDate int64 `json:"file_date"` +} +type PassportElementType string + +const ( + PassportPersonalDetailsType PassportElementType = "personal_details" + PassportPassportType PassportElementType = "passport" + PassportDriverLicenseType PassportElementType = "driver_license" + PassportIdentityCardType PassportElementType = "identity_card" + PassportInternalPassportType PassportElementType = "internal_passport" + PassportAddressType PassportElementType = "address" + PassportUtilityBillType PassportElementType = "utility_bill" + PassportBankStatementType PassportElementType = "bank_statement" + PassportRentalAgreementType PassportElementType = "rental_agreement" + PassportPassportRegistrationType PassportElementType = "passport_registration" + PassportTemporaryRegistrationType PassportElementType = "temporary_registration" + PassportPhoneNumberType PassportElementType = "phone_number" + PassportEmailType PassportElementType = "email" +) + +type EncryptedPassportElement struct { + Type PassportElementType `json:"type"` + Data string `json:"data,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + Email string `json:"email,omitempty"` + Files []PassportFile `json:"files,omitempty"` + FrontSide *PassportFile `json:"front_side,omitempty"` + ReverseSide *PassportFile `json:"reverse_side,omitempty"` + Selfie *PassportFile `json:"selfie,omitempty"` + Translation *PassportFile `json:"translation,omitempty"` + Hash string `json:"hash,omitempty"` +} +type EncryptedCredentials struct { + Data string `json:"data"` + Hash string `json:"hash"` + Secret string `json:"secret"` +} + // PassportElementError is a JSON-serializable passport element error object. // See https://core.telegram.org/bots/api#passportelementerror -type PassportElementError map[string]any +type PassportElementError struct { + Source string `json:"source"` + Type PassportElementType `json:"type"` + + FieldName string `json:"field_name,omitempty"` + DataHash string `json:"data_hash,omitempty"` + + FileHash string `json:"file_hash,omitempty"` + FileHashes []string `json:"file_hashes,omitempty"` + + ElementHash string `json:"element_hash,omitempty"` + + Message string `json:"message"` +} diff --git a/tgapi/payments_methods.go b/tgapi/payments_methods.go index 3cc9966..25c8743 100644 --- a/tgapi/payments_methods.go +++ b/tgapi/payments_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// SendInvoiceP holds parameters for the sendInvoice method. +// SendInvoice holds parameters for the sendInvoice method. // See https://core.telegram.org/bots/api#sendinvoice -type SendInvoiceP struct { +type SendInvoice struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` @@ -43,7 +43,7 @@ type SendInvoiceP struct { // SendInvoice sends an invoice. // See https://core.telegram.org/bots/api#sendinvoice -func (api *API) SendInvoice(params SendInvoiceP) (Message, error) { +func (api *API) SendInvoice(params SendInvoice) (Message, error) { req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID) return req.Do(api) } @@ -51,14 +51,14 @@ func (api *API) SendInvoice(params SendInvoiceP) (Message, error) { // SendInvoiceWithContext is the context-aware variant of SendInvoice. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendinvoice -func (api *API) SendInvoiceWithContext(ctx context.Context, params SendInvoiceP) (Message, error) { +func (api *API) SendInvoiceWithContext(ctx context.Context, params SendInvoice) (Message, error) { req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID) return req.DoWithContext(ctx, api) } -// CreateInvoiceLinkP holds parameters for the createInvoiceLink method. +// CreateInvoiceLink holds parameters for the createInvoiceLink method. // See https://core.telegram.org/bots/api#createinvoicelink -type CreateInvoiceLinkP struct { +type CreateInvoiceLink struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` Title string `json:"title"` @@ -87,7 +87,7 @@ type CreateInvoiceLinkP struct { // CreateInvoiceLink creates an invoice link. // See https://core.telegram.org/bots/api#createinvoicelink -func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) { +func (api *API) CreateInvoiceLink(params CreateInvoiceLink) (string, error) { req := NewRequest[string]("createInvoiceLink", params) return req.Do(api) } @@ -95,14 +95,14 @@ func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) { // CreateInvoiceLinkWithContext is the context-aware variant of CreateInvoiceLink. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createinvoicelink -func (api *API) CreateInvoiceLinkWithContext(ctx context.Context, params CreateInvoiceLinkP) (string, error) { +func (api *API) CreateInvoiceLinkWithContext(ctx context.Context, params CreateInvoiceLink) (string, error) { req := NewRequest[string]("createInvoiceLink", params) return req.DoWithContext(ctx, api) } -// AnswerShippingQueryP holds parameters for the answerShippingQuery method. +// AnswerShippingQuery holds parameters for the answerShippingQuery method. // See https://core.telegram.org/bots/api#answershippingquery -type AnswerShippingQueryP struct { +type AnswerShippingQuery struct { ShippingQueryID string `json:"shipping_query_id"` OK bool `json:"ok"` ShippingOptions []ShippingOption `json:"shipping_options,omitempty"` @@ -112,7 +112,7 @@ type AnswerShippingQueryP struct { // AnswerShippingQuery answers a shipping query. // Returns true on success. // See https://core.telegram.org/bots/api#answershippingquery -func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) { +func (api *API) AnswerShippingQuery(params AnswerShippingQuery) (bool, error) { req := NewRequest[bool]("answerShippingQuery", params) return req.Do(api) } @@ -120,14 +120,14 @@ func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) { // AnswerShippingQueryWithContext is the context-aware variant of AnswerShippingQuery. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answershippingquery -func (api *API) AnswerShippingQueryWithContext(ctx context.Context, params AnswerShippingQueryP) (bool, error) { +func (api *API) AnswerShippingQueryWithContext(ctx context.Context, params AnswerShippingQuery) (bool, error) { req := NewRequest[bool]("answerShippingQuery", params) return req.DoWithContext(ctx, api) } -// AnswerPreCheckoutQueryP holds parameters for the answerPreCheckoutQuery method. +// AnswerPreCheckoutQuery holds parameters for the answerPreCheckoutQuery method. // See https://core.telegram.org/bots/api#answerprecheckoutquery -type AnswerPreCheckoutQueryP struct { +type AnswerPreCheckoutQuery struct { PreCheckoutQueryID string `json:"pre_checkout_query_id"` OK bool `json:"ok"` ErrorMessage string `json:"error_message,omitempty"` @@ -136,7 +136,7 @@ type AnswerPreCheckoutQueryP struct { // AnswerPreCheckoutQuery answers a pre-checkout query. // Returns true on success. // See https://core.telegram.org/bots/api#answerprecheckoutquery -func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, error) { +func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQuery) (bool, error) { req := NewRequest[bool]("answerPreCheckoutQuery", params) return req.Do(api) } @@ -144,7 +144,7 @@ func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, er // AnswerPreCheckoutQueryWithContext is the context-aware variant of AnswerPreCheckoutQuery. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answerprecheckoutquery -func (api *API) AnswerPreCheckoutQueryWithContext(ctx context.Context, params AnswerPreCheckoutQueryP) (bool, error) { +func (api *API) AnswerPreCheckoutQueryWithContext(ctx context.Context, params AnswerPreCheckoutQuery) (bool, error) { req := NewRequest[bool]("answerPreCheckoutQuery", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/payments_types.go b/tgapi/payments_types.go index 50449cb..b775ad7 100644 --- a/tgapi/payments_types.go +++ b/tgapi/payments_types.go @@ -7,6 +7,62 @@ type LabeledPrice struct { Amount int `json:"amount"` } +type Invoice struct { + Title string `json:"title"` + Description string `json:"description"` + StartParameter string `json:"start_parameter"` + Currency string `json:"currency"` + TotalAmount int `json:"total_amount"` +} + +// ShippingQuery represents an incoming shipping query. +// See https://core.telegram.org/bots/api#shippingquery +type ShippingQuery struct { + ID string `json:"id"` + From User `json:"from"` + InvoicePayload string `json:"invoice_payload"` + ShippingAddress ShippingAddress `json:"shipping_address"` +} + +// ShippingAddress represents a shipping address. +// See https://core.telegram.org/bots/api#shippingaddress +type ShippingAddress struct { + CountryCode string `json:"country_code"` + State string `json:"state"` + City string `json:"city"` + StreetLine1 string `json:"street_line1"` + StreetLine2 string `json:"street_line2"` + PostCode string `json:"post_code"` +} + +// OrderInfo represents information about an order. +// See https://core.telegram.org/bots/api#orderinfo +type OrderInfo struct { + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` + Email string `json:"email"` + ShippingAddress ShippingAddress `json:"shipping_address"` +} + +// PreCheckoutQuery represents an incoming pre-checkout query. +// See https://core.telegram.org/bots/api#precheckoutquery +type PreCheckoutQuery struct { + ID string `json:"id"` + From User `json:"from"` + Currency string `json:"currency"` + TotalAmount int `json:"total_amount"` + InvoicePayload string `json:"invoice_payload"` + ShippingOptionID string `json:"shipping_option_id"` + OrderInfo *OrderInfo `json:"order_info,omitempty"` +} + +// PaidMediaPurchased represents a purchased paid media. +// See https://core.telegram.org/bots/api#paidmediapurchased +type PaidMediaPurchased struct { + From User `json:"from"` + PaidMediaPayload string `json:"paid_media_payload"` +} + // ShippingOption represents one shipping option. // See https://core.telegram.org/bots/api#shippingoption type ShippingOption struct { @@ -14,3 +70,27 @@ type ShippingOption struct { Title string `json:"title"` Prices []LabeledPrice `json:"prices"` } + +type SuccessfulPayment struct { + Currency string `json:"currency"` + TotalAmount int `json:"total_amount"` + InvoicePayload string `json:"invoice_payload"` + + SubscriptionExpirationDate int `json:"subscription_expiration_date,omitempty"` + IsRecurring bool `json:"is_recurring,omitempty"` + IsFirstRecurring bool `json:"is_first_recurring,omitempty"` + ShippingOptionID string `json:"shipping_option_id,omitempty"` + OrderInfo *OrderInfo `json:"order_info,omitempty"` + + TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` + ProviderPaymentChargeID string `json:"proviced_payment_charge_id"` +} + +type RefundedPayment struct { + Currency string `json:"currency"` + TotalAmount int `json:"total_amount"` + InvoicePayload string `json:"invoice_payload"` + + TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` + ProviderPaymentChargeID string `json:"proviced_payment_charge_id,omitempty"` +} diff --git a/tgapi/stars_methods.go b/tgapi/stars_methods.go index cec2447..c2e743a 100644 --- a/tgapi/stars_methods.go +++ b/tgapi/stars_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// GetStarTransactionsP holds parameters for the getStarTransactions method. +// GetStarTransactions holds parameters for the getStarTransactions method. // See https://core.telegram.org/bots/api#getstartransactions -type GetStarTransactionsP struct { +type GetStarTransactions struct { Offset int `json:"offset,omitempty"` Limit int `json:"limit,omitempty"` } @@ -26,7 +26,7 @@ func (api *API) GetMyStarBalanceWithContext(ctx context.Context) (StarAmount, er // GetStarTransactions returns Telegram Star transactions for the bot. // See https://core.telegram.org/bots/api#getstartransactions -func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) { +func (api *API) GetStarTransactions(params GetStarTransactions) (StarTransactions, error) { req := NewRequest[StarTransactions]("getStarTransactions", params) return req.Do(api) } @@ -34,14 +34,14 @@ func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactio // GetStarTransactionsWithContext is the context-aware variant of GetStarTransactions. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getstartransactions -func (api *API) GetStarTransactionsWithContext(ctx context.Context, params GetStarTransactionsP) (StarTransactions, error) { +func (api *API) GetStarTransactionsWithContext(ctx context.Context, params GetStarTransactions) (StarTransactions, error) { req := NewRequest[StarTransactions]("getStarTransactions", params) return req.DoWithContext(ctx, api) } -// RefundStarPaymentP holds parameters for the refundStarPayment method. +// RefundStarPayment holds parameters for the refundStarPayment method. // See https://core.telegram.org/bots/api#refundstarpayment -type RefundStarPaymentP struct { +type RefundStarPayment struct { UserID int64 `json:"user_id"` TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` } @@ -49,7 +49,7 @@ type RefundStarPaymentP struct { // RefundStarPayment refunds a successful Telegram Stars payment. // Returns true on success. // See https://core.telegram.org/bots/api#refundstarpayment -func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) { +func (api *API) RefundStarPayment(params RefundStarPayment) (bool, error) { req := NewRequest[bool]("refundStarPayment", params) return req.Do(api) } @@ -57,14 +57,14 @@ func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) { // RefundStarPaymentWithContext is the context-aware variant of RefundStarPayment. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#refundstarpayment -func (api *API) RefundStarPaymentWithContext(ctx context.Context, params RefundStarPaymentP) (bool, error) { +func (api *API) RefundStarPaymentWithContext(ctx context.Context, params RefundStarPayment) (bool, error) { req := NewRequest[bool]("refundStarPayment", params) return req.DoWithContext(ctx, api) } -// EditUserStarSubscriptionP holds parameters for the editUserStarSubscription method. +// EditUserStarSubscription holds parameters for the editUserStarSubscription method. // See https://core.telegram.org/bots/api#edituserstarsubscription -type EditUserStarSubscriptionP struct { +type EditUserStarSubscription struct { UserID int64 `json:"user_id"` TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` IsCanceled bool `json:"is_canceled"` @@ -73,7 +73,7 @@ type EditUserStarSubscriptionP struct { // EditUserStarSubscription cancels or re-enables a user star subscription extension. // Returns true on success. // See https://core.telegram.org/bots/api#edituserstarsubscription -func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool, error) { +func (api *API) EditUserStarSubscription(params EditUserStarSubscription) (bool, error) { req := NewRequest[bool]("editUserStarSubscription", params) return req.Do(api) } @@ -81,7 +81,7 @@ func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool // EditUserStarSubscriptionWithContext is the context-aware variant of EditUserStarSubscription. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#edituserstarsubscription -func (api *API) EditUserStarSubscriptionWithContext(ctx context.Context, params EditUserStarSubscriptionP) (bool, error) { +func (api *API) EditUserStarSubscriptionWithContext(ctx context.Context, params EditUserStarSubscription) (bool, error) { req := NewRequest[bool]("editUserStarSubscription", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/stickers_methods.go b/tgapi/stickers_methods.go index 6bffcf9..c76ace1 100644 --- a/tgapi/stickers_methods.go +++ b/tgapi/stickers_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// SendStickerP holds parameters for the sendSticker method. +// SendSticker holds parameters for the sendSticker method. // See https://core.telegram.org/bots/api#sendsticker -type SendStickerP struct { +type SendSticker struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -24,7 +24,7 @@ type SendStickerP struct { // SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker. // See https://core.telegram.org/bots/api#sendsticker -func (api *API) SendSticker(params SendStickerP) (Message, error) { +func (api *API) SendSticker(params SendSticker) (Message, error) { req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID) return req.Do(api) } @@ -32,20 +32,20 @@ func (api *API) SendSticker(params SendStickerP) (Message, error) { // SendStickerWithContext is the context-aware variant of SendSticker. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendsticker -func (api *API) SendStickerWithContext(ctx context.Context, params SendStickerP) (Message, error) { +func (api *API) SendStickerWithContext(ctx context.Context, params SendSticker) (Message, error) { req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID) return req.DoWithContext(ctx, api) } -// GetStickerSetP holds parameters for the getStickerSet method. +// GetStickerSet holds parameters for the getStickerSet method. // See https://core.telegram.org/bots/api#getstickerset -type GetStickerSetP struct { +type GetStickerSet struct { Name string `json:"name"` } // GetStickerSet returns a sticker set by its name. // See https://core.telegram.org/bots/api#getstickerset -func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) { +func (api *API) GetStickerSet(params GetStickerSet) (StickerSet, error) { req := NewRequest[StickerSet]("getStickerSet", params) return req.Do(api) } @@ -53,20 +53,20 @@ func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) { // GetStickerSetWithContext is the context-aware variant of GetStickerSet. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getstickerset -func (api *API) GetStickerSetWithContext(ctx context.Context, params GetStickerSetP) (StickerSet, error) { +func (api *API) GetStickerSetWithContext(ctx context.Context, params GetStickerSet) (StickerSet, error) { req := NewRequest[StickerSet]("getStickerSet", params) return req.DoWithContext(ctx, api) } -// GetCustomEmojiStickersP holds parameters for the getCustomEmojiStickers method. +// GetCustomEmojiStickers holds parameters for the getCustomEmojiStickers method. // See https://core.telegram.org/bots/api#getcustomemojistickers -type GetCustomEmojiStickersP struct { +type GetCustomEmojiStickers struct { CustomEmojiIDs []string `json:"custom_emoji_ids"` } // GetCustomEmojiStickers returns information about custom emoji stickers by their IDs. // See https://core.telegram.org/bots/api#getcustomemojistickers -func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticker, error) { +func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickers) ([]Sticker, error) { req := NewRequest[[]Sticker]("getCustomEmojiStickers", params) return req.Do(api) } @@ -74,14 +74,14 @@ func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticke // GetCustomEmojiStickersWithContext is the context-aware variant of GetCustomEmojiStickers. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getcustomemojistickers -func (api *API) GetCustomEmojiStickersWithContext(ctx context.Context, params GetCustomEmojiStickersP) ([]Sticker, error) { +func (api *API) GetCustomEmojiStickersWithContext(ctx context.Context, params GetCustomEmojiStickers) ([]Sticker, error) { req := NewRequest[[]Sticker]("getCustomEmojiStickers", params) return req.DoWithContext(ctx, api) } -// UploadStickerFileP holds parameters for the uploadStickerFile method. +// UploadStickerFile holds parameters for the uploadStickerFile method. // See https://core.telegram.org/bots/api#uploadstickerfile -type UploadStickerFileP struct { +type UploadStickerFile struct { UserID int64 `json:"user_id"` StickerFormat InputStickerFormat `json:"sticker_format"` } @@ -89,7 +89,7 @@ type UploadStickerFileP struct { // UploadStickerFile uploads a sticker file for later use in sticker set methods. // sticker is the file to upload. // See https://core.telegram.org/bots/api#uploadstickerfile -func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFile) (File, error) { +func (api *API) UploadStickerFile(params UploadStickerFile, sticker UploaderFile) (File, error) { uploader := NewUploader(api) defer func() { _ = uploader.Close() @@ -101,7 +101,7 @@ func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFil // UploadStickerFileWithContext is the context-aware variant of UploadStickerFile. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#uploadstickerfile -func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadStickerFileP, sticker UploaderFile) (File, error) { +func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadStickerFile, sticker UploaderFile) (File, error) { uploader := NewUploader(api) defer func() { _ = uploader.Close() @@ -110,9 +110,9 @@ func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadS return req.DoWithContext(ctx, uploader) } -// CreateNewStickerSetP holds parameters for the createNewStickerSet method. +// CreateNewStickerSet holds parameters for the createNewStickerSet method. // See https://core.telegram.org/bots/api#createnewstickerset -type CreateNewStickerSetP struct { +type CreateNewStickerSet struct { UserID int64 `json:"user_id"` Name string `json:"name"` Title string `json:"title"` @@ -125,7 +125,7 @@ type CreateNewStickerSetP struct { // CreateNewStickerSet creates a new sticker set owned by a user. // Returns True on success. // See https://core.telegram.org/bots/api#createnewstickerset -func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) { +func (api *API) CreateNewStickerSet(params CreateNewStickerSet) (bool, error) { req := NewRequest[bool]("createNewStickerSet", params) return req.Do(api) } @@ -133,14 +133,14 @@ func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) { // CreateNewStickerSetWithContext is the context-aware variant of CreateNewStickerSet. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createnewstickerset -func (api *API) CreateNewStickerSetWithContext(ctx context.Context, params CreateNewStickerSetP) (bool, error) { +func (api *API) CreateNewStickerSetWithContext(ctx context.Context, params CreateNewStickerSet) (bool, error) { req := NewRequest[bool]("createNewStickerSet", params) return req.DoWithContext(ctx, api) } -// AddStickerToSetP holds parameters for the addStickerToSet method. +// AddStickerToSet holds parameters for the addStickerToSet method. // See https://core.telegram.org/bots/api#addstickertoset -type AddStickerToSetP struct { +type AddStickerToSet struct { UserID int64 `json:"user_id"` Name string `json:"name"` Sticker InputSticker `json:"sticker"` @@ -149,7 +149,7 @@ type AddStickerToSetP struct { // AddStickerToSet adds a new sticker to a set created by the bot. // Returns True on success. // See https://core.telegram.org/bots/api#addstickertoset -func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) { +func (api *API) AddStickerToSet(params AddStickerToSet) (bool, error) { req := NewRequest[bool]("addStickerToSet", params) return req.Do(api) } @@ -157,14 +157,14 @@ func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) { // AddStickerToSetWithContext is the context-aware variant of AddStickerToSet. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#addstickertoset -func (api *API) AddStickerToSetWithContext(ctx context.Context, params AddStickerToSetP) (bool, error) { +func (api *API) AddStickerToSetWithContext(ctx context.Context, params AddStickerToSet) (bool, error) { req := NewRequest[bool]("addStickerToSet", params) return req.DoWithContext(ctx, api) } -// SetStickerPositionInSetP holds parameters for the setStickerPositionInSet method. +// SetStickerPositionInSet holds parameters for the setStickerPositionInSet method. // See https://core.telegram.org/bots/api#setstickerpositioninset -type SetStickerPositionInSetP struct { +type SetStickerPositionInSet struct { Sticker string `json:"sticker"` Position int `json:"position"` } @@ -172,7 +172,7 @@ type SetStickerPositionInSetP struct { // SetStickerPositionInSet moves a sticker in a set to a specific position. // Returns True on success. // See https://core.telegram.org/bots/api#setstickerpositioninset -func (api *API) SetStickerPositionInSet(params SetStickerPositionInSetP) (bool, error) { +func (api *API) SetStickerPositionInSet(params SetStickerPositionInSet) (bool, error) { req := NewRequest[bool]("setStickerPositionInSet", params) return req.Do(api) } @@ -180,21 +180,21 @@ func (api *API) SetStickerPositionInSet(params SetStickerPositionInSetP) (bool, // SetStickerPositionInSetWithContext is the context-aware variant of SetStickerPositionInSet. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickerpositioninset -func (api *API) SetStickerPositionInSetWithContext(ctx context.Context, params SetStickerPositionInSetP) (bool, error) { +func (api *API) SetStickerPositionInSetWithContext(ctx context.Context, params SetStickerPositionInSet) (bool, error) { req := NewRequest[bool]("setStickerPositionInSet", params) return req.DoWithContext(ctx, api) } -// DeleteStickerFromSetP holds parameters for the deleteStickerFromSet method. +// DeleteStickerFromSet holds parameters for the deleteStickerFromSet method. // See https://core.telegram.org/bots/api#deletestickerfromset -type DeleteStickerFromSetP struct { +type DeleteStickerFromSet struct { Sticker string `json:"sticker"` } // DeleteStickerFromSet deletes a sticker from a set created by the bot. // Returns True on success. // See https://core.telegram.org/bots/api#deletestickerfromset -func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error) { +func (api *API) DeleteStickerFromSet(params DeleteStickerFromSet) (bool, error) { req := NewRequest[bool]("deleteStickerFromSet", params) return req.Do(api) } @@ -202,14 +202,14 @@ func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error) // DeleteStickerFromSetWithContext is the context-aware variant of DeleteStickerFromSet. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletestickerfromset -func (api *API) DeleteStickerFromSetWithContext(ctx context.Context, params DeleteStickerFromSetP) (bool, error) { +func (api *API) DeleteStickerFromSetWithContext(ctx context.Context, params DeleteStickerFromSet) (bool, error) { req := NewRequest[bool]("deleteStickerFromSet", params) return req.DoWithContext(ctx, api) } -// ReplaceStickerInSetP holds parameters for the replaceStickerInSet method. +// ReplaceStickerInSet holds parameters for the replaceStickerInSet method. // See https://core.telegram.org/bots/api#replacestickerinset -type ReplaceStickerInSetP struct { +type ReplaceStickerInSet struct { UserID int64 `json:"user_id"` Name string `json:"name"` OldSticker string `json:"old_sticker"` @@ -219,7 +219,7 @@ type ReplaceStickerInSetP struct { // ReplaceStickerInSet replaces an existing sticker in a set with a new one. // Returns True on success. // See https://core.telegram.org/bots/api#replacestickerinset -func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) { +func (api *API) ReplaceStickerInSet(params ReplaceStickerInSet) (bool, error) { req := NewRequest[bool]("replaceStickerInSet", params) return req.Do(api) } @@ -227,14 +227,14 @@ func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) { // ReplaceStickerInSetWithContext is the context-aware variant of ReplaceStickerInSet. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#replacestickerinset -func (api *API) ReplaceStickerInSetWithContext(ctx context.Context, params ReplaceStickerInSetP) (bool, error) { +func (api *API) ReplaceStickerInSetWithContext(ctx context.Context, params ReplaceStickerInSet) (bool, error) { req := NewRequest[bool]("replaceStickerInSet", params) return req.DoWithContext(ctx, api) } -// SetStickerEmojiListP holds parameters for the setStickerEmojiList method. +// SetStickerEmojiList holds parameters for the setStickerEmojiList method. // See https://core.telegram.org/bots/api#setstickeremojilist -type SetStickerEmojiListP struct { +type SetStickerEmojiList struct { Sticker string `json:"sticker"` EmojiList []string `json:"emoji_list"` } @@ -242,7 +242,7 @@ type SetStickerEmojiListP struct { // SetStickerEmojiList changes the list of emoji associated with a sticker. // Returns True on success. // See https://core.telegram.org/bots/api#setstickeremojilist -func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) { +func (api *API) SetStickerEmojiList(params SetStickerEmojiList) (bool, error) { req := NewRequest[bool]("setStickerEmojiList", params) return req.Do(api) } @@ -250,14 +250,14 @@ func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) { // SetStickerEmojiListWithContext is the context-aware variant of SetStickerEmojiList. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickeremojilist -func (api *API) SetStickerEmojiListWithContext(ctx context.Context, params SetStickerEmojiListP) (bool, error) { +func (api *API) SetStickerEmojiListWithContext(ctx context.Context, params SetStickerEmojiList) (bool, error) { req := NewRequest[bool]("setStickerEmojiList", params) return req.DoWithContext(ctx, api) } -// SetStickerKeywordsP holds parameters for the setStickerKeywords method. +// SetStickerKeywords holds parameters for the setStickerKeywords method. // See https://core.telegram.org/bots/api#setstickerkeywords -type SetStickerKeywordsP struct { +type SetStickerKeywords struct { Sticker string `json:"sticker"` Keywords []string `json:"keywords"` } @@ -265,7 +265,7 @@ type SetStickerKeywordsP struct { // SetStickerKeywords changes the keywords of a sticker. // Returns True on success. // See https://core.telegram.org/bots/api#setstickerkeywords -func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) { +func (api *API) SetStickerKeywords(params SetStickerKeywords) (bool, error) { req := NewRequest[bool]("setStickerKeywords", params) return req.Do(api) } @@ -273,14 +273,14 @@ func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) { // SetStickerKeywordsWithContext is the context-aware variant of SetStickerKeywords. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickerkeywords -func (api *API) SetStickerKeywordsWithContext(ctx context.Context, params SetStickerKeywordsP) (bool, error) { +func (api *API) SetStickerKeywordsWithContext(ctx context.Context, params SetStickerKeywords) (bool, error) { req := NewRequest[bool]("setStickerKeywords", params) return req.DoWithContext(ctx, api) } -// SetStickerMaskPositionP holds parameters for the setStickerMaskPosition method. +// SetStickerMaskPosition holds parameters for the setStickerMaskPosition method. // See https://core.telegram.org/bots/api#setstickermaskposition -type SetStickerMaskPositionP struct { +type SetStickerMaskPosition struct { Sticker string `json:"sticker"` MaskPosition *MaskPosition `json:"mask_position,omitempty"` } @@ -288,7 +288,7 @@ type SetStickerMaskPositionP struct { // SetStickerMaskPosition changes the mask position of a mask sticker. // Returns True on success. // See https://core.telegram.org/bots/api#setstickermaskposition -func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, error) { +func (api *API) SetStickerMaskPosition(params SetStickerMaskPosition) (bool, error) { req := NewRequest[bool]("setStickerMaskPosition", params) return req.Do(api) } @@ -296,14 +296,14 @@ func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, er // SetStickerMaskPositionWithContext is the context-aware variant of SetStickerMaskPosition. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickermaskposition -func (api *API) SetStickerMaskPositionWithContext(ctx context.Context, params SetStickerMaskPositionP) (bool, error) { +func (api *API) SetStickerMaskPositionWithContext(ctx context.Context, params SetStickerMaskPosition) (bool, error) { req := NewRequest[bool]("setStickerMaskPosition", params) return req.DoWithContext(ctx, api) } -// SetStickerSetTitleP holds parameters for the setStickerSetTitle method. +// SetStickerSetTitle holds parameters for the setStickerSetTitle method. // See https://core.telegram.org/bots/api#setstickersettitle -type SetStickerSetTitleP struct { +type SetStickerSetTitle struct { Name string `json:"name"` Title string `json:"title"` } @@ -311,7 +311,7 @@ type SetStickerSetTitleP struct { // SetStickerSetTitle sets the title of a sticker set created by the bot. // Returns True on success. // See https://core.telegram.org/bots/api#setstickersettitle -func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) { +func (api *API) SetStickerSetTitle(params SetStickerSetTitle) (bool, error) { req := NewRequest[bool]("setStickerSetTitle", params) return req.Do(api) } @@ -319,14 +319,14 @@ func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) { // SetStickerSetTitleWithContext is the context-aware variant of SetStickerSetTitle. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickersettitle -func (api *API) SetStickerSetTitleWithContext(ctx context.Context, params SetStickerSetTitleP) (bool, error) { +func (api *API) SetStickerSetTitleWithContext(ctx context.Context, params SetStickerSetTitle) (bool, error) { req := NewRequest[bool]("setStickerSetTitle", params) return req.DoWithContext(ctx, api) } -// SetStickerSetThumbnailP holds parameters for the setStickerSetThumbnail method. +// SetStickerSetThumbnail holds parameters for the setStickerSetThumbnail method. // See https://core.telegram.org/bots/api#setstickersetthumbnail -type SetStickerSetThumbnailP struct { +type SetStickerSetThumbnail struct { Name string `json:"name"` UserID int64 `json:"user_id"` Thumbnail string `json:"thumbnail"` @@ -336,7 +336,7 @@ type SetStickerSetThumbnailP struct { // SetStickerSetThumbnail sets the thumbnail of a sticker set. // Returns True on success. // See https://core.telegram.org/bots/api#setstickersetthumbnail -func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) { +func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnail) (bool, error) { req := NewRequest[bool]("setStickerSetThumbnail", params) return req.Do(api) } @@ -344,14 +344,14 @@ func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, er // SetStickerSetThumbnailWithContext is the context-aware variant of SetStickerSetThumbnail. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickersetthumbnail -func (api *API) SetStickerSetThumbnailWithContext(ctx context.Context, params SetStickerSetThumbnailP) (bool, error) { +func (api *API) SetStickerSetThumbnailWithContext(ctx context.Context, params SetStickerSetThumbnail) (bool, error) { req := NewRequest[bool]("setStickerSetThumbnail", params) return req.DoWithContext(ctx, api) } -// SetCustomEmojiStickerSetThumbnailP holds parameters for the setCustomEmojiStickerSetThumbnail method. +// SetCustomEmojiStickerSetThumbnail holds parameters for the setCustomEmojiStickerSetThumbnail method. // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail -type SetCustomEmojiStickerSetThumbnailP struct { +type SetCustomEmojiStickerSetThumbnail struct { Name string `json:"name"` CustomEmojiID string `json:"custom_emoji_id,omitempty"` } @@ -359,7 +359,7 @@ type SetCustomEmojiStickerSetThumbnailP struct { // SetCustomEmojiStickerSetThumbnail sets the thumbnail of a custom emoji sticker set. // Returns True on success. // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail -func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSetThumbnailP) (bool, error) { +func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSetThumbnail) (bool, error) { req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params) return req.Do(api) } @@ -367,21 +367,21 @@ func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSe // SetCustomEmojiStickerSetThumbnailWithContext is the context-aware variant of SetCustomEmojiStickerSetThumbnail. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail -func (api *API) SetCustomEmojiStickerSetThumbnailWithContext(ctx context.Context, params SetCustomEmojiStickerSetThumbnailP) (bool, error) { +func (api *API) SetCustomEmojiStickerSetThumbnailWithContext(ctx context.Context, params SetCustomEmojiStickerSetThumbnail) (bool, error) { req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params) return req.DoWithContext(ctx, api) } -// DeleteStickerSetP holds parameters for the deleteStickerSet method. +// DeleteStickerSet holds parameters for the deleteStickerSet method. // See https://core.telegram.org/bots/api#deletestickerset -type DeleteStickerSetP struct { +type DeleteStickerSet struct { Name string `json:"name"` } // DeleteStickerSet deletes a sticker set created by the bot. // Returns True on success. // See https://core.telegram.org/bots/api#deletestickerset -func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) { +func (api *API) DeleteStickerSet(params DeleteStickerSet) (bool, error) { req := NewRequest[bool]("deleteStickerSet", params) return req.Do(api) } @@ -389,7 +389,7 @@ func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) { // DeleteStickerSetWithContext is the context-aware variant of DeleteStickerSet. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletestickerset -func (api *API) DeleteStickerSetWithContext(ctx context.Context, params DeleteStickerSetP) (bool, error) { +func (api *API) DeleteStickerSetWithContext(ctx context.Context, params DeleteStickerSet) (bool, error) { req := NewRequest[bool]("deleteStickerSet", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/stickers_types.go b/tgapi/stickers_types.go index 883deb2..a1ded01 100644 --- a/tgapi/stickers_types.go +++ b/tgapi/stickers_types.go @@ -38,8 +38,8 @@ const ( // Sticker represents a sticker. // See https://core.telegram.org/bots/api#sticker type Sticker struct { - FileId string `json:"file_id"` - FileUniqueId string `json:"file_unique_id"` + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` Type StickerType `json:"type"` Width int `json:"width"` Height int `json:"height"` diff --git a/tgapi/types.go b/tgapi/types.go index ba3bd22..3575998 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -57,6 +57,8 @@ const ( UpdateTypeChatBoost UpdateType = "chat_boost" // UpdateTypeRemovedChatBoost is a removed chat boost update. UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost" + + UpdateTypeManagedBot UpdateType = "managed_bot" ) // Update represents an incoming update from Telegram. @@ -91,6 +93,8 @@ type Update struct { ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"` ChatBoost *ChatBoostUpdated `json:"chat_boost,omitempty"` RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` + + ManagedBot *ManagedBotUpdated `json:"managed_bot,omitempty"` } // UnmarshalJSON decodes an update and derives its Type from the populated payload field. @@ -154,6 +158,8 @@ func (u *Update) UnmarshalJSON(data []byte) error { u.Type = UpdateTypeChatBoost case u.RemovedChatBoost != nil: u.Type = UpdateTypeRemovedChatBoost + case u.ManagedBot != nil: + u.Type = UpdateTypeManagedBot default: u.Type = UpdateTypeUnknown } @@ -161,6 +167,26 @@ func (u *Update) UnmarshalJSON(data []byte) error { return nil } +// WebhookInfo describes the current webhook status. +// See https://core.telegram.org/bots/api#webhookinfo +type WebhookInfo struct { + URL string `json:"url"` + HasCustomCertificate bool `json:"has_custom_certificate"` + PendingUpdateCount int `json:"pending_update_count"` + IPAddress string `json:"ip_address,omitempty"` + LastErrorDate int `json:"last_error_date,omitempty"` + LastErrorMessage string `json:"last_error_message,omitempty"` + LastSynchronizationErrorDate int `json:"last_synchronization_error_date,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + AllowedUpdates []string `json:"allowed_updates,omitempty"` +} + +type ProximityAlertTriggered struct { + Traveler User `json:"traveler"` + Watcher User `json:"watcher"` + Distance int `json:"distance"` +} + // InlineQuery represents an incoming inline query. // See https://core.telegram.org/bots/api#inlinequery type InlineQuery struct { @@ -182,115 +208,15 @@ type ChosenInlineResult struct { Query string `json:"query"` } -// ShippingQuery represents an incoming shipping query. -// See https://core.telegram.org/bots/api#shippingquery -type ShippingQuery struct { - ID string `json:"id"` - From User `json:"from"` - InvoicePayload string `json:"invoice_payload"` - ShippingAddress ShippingAddress `json:"shipping_address"` -} - -// ShippingAddress represents a shipping address. -// See https://core.telegram.org/bots/api#shippingaddress -type ShippingAddress struct { - CountryCode string `json:"country_code"` - State string `json:"state"` - City string `json:"city"` - StreetLine1 string `json:"street_line1"` - StreetLine2 string `json:"street_line2"` - PostCode string `json:"post_code"` -} - -// OrderInfo represents information about an order. -// See https://core.telegram.org/bots/api#orderinfo -type OrderInfo struct { - Name string `json:"name"` - PhoneNumber string `json:"phone_number"` - Email string `json:"email"` - ShippingAddress ShippingAddress `json:"shipping_address"` -} - -// PreCheckoutQuery represents an incoming pre-checkout query. -// See https://core.telegram.org/bots/api#precheckoutquery -type PreCheckoutQuery struct { - ID string `json:"id"` - From User `json:"from"` - Currency string `json:"currency"` - TotalAmount int `json:"total_amount"` - InvoicePayload string `json:"invoice_payload"` - ShippingOptionID string `json:"shipping_option_id"` - OrderInfo *OrderInfo `json:"order_info,omitempty"` -} - -// PaidMediaPurchased represents a purchased paid media. -// See https://core.telegram.org/bots/api#paidmediapurchased -type PaidMediaPurchased struct { - From User `json:"from"` - PaidMediaPayload string `json:"paid_media_payload"` -} - // File represents a file ready to be downloaded. // See https://core.telegram.org/bots/api#file type File struct { - FileId string `json:"file_id"` + FileID string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` FileSize int64 `json:"file_size,omitempty"` FilePath string `json:"file_path,omitempty"` } -// Audio represents an audio file to be treated as music by the Telegram clients. -// See https://core.telegram.org/bots/api#audio -type Audio struct { - FileID string `json:"file_id"` - FileUniqueID string `json:"file_unique_id"` - Duration int `json:"duration"` - - Performer string `json:"performer,omitempty"` - Title string `json:"title,omitempty"` - FileName string `json:"file_name,omitempty"` - MimeType string `json:"mime_type,omitempty"` - FileSize int64 `json:"file_size,omitempty"` - Thumbnail *PhotoSize `json:"thumbnail,omitempty"` -} - -// PollOption contains information about one answer option in a poll. -// See https://core.telegram.org/bots/api#polloption -type PollOption struct { - Text string `json:"text"` - TextEntities []MessageEntity `json:"text_entities"` - VoterCount int `json:"voter_count"` -} - -// Poll contains information about a poll. -// See https://core.telegram.org/bots/api#poll -type Poll struct { - ID string `json:"id"` - Question string `json:"question"` - QuestionEntities []MessageEntity `json:"question_entities"` - Options []PollOption `json:"options"` - TotalVoterCount int `json:"total_voter_count"` - IsClosed bool `json:"is_closed"` - IsAnonymous bool `json:"is_anonymous"` - Type PollType `json:"type"` - - AllowsMultipleAnswers bool `json:"allows_multiple_answers"` - CorrectOptionID *int `json:"correct_option_id,omitempty"` - Explanation *string `json:"explanation,omitempty"` - ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"` - OpenPeriod int `json:"open_period,omitempty"` - CloseDate int `json:"close_date,omitempty"` -} - -// PollAnswer represents an answer of a user in a poll. -// See https://core.telegram.org/bots/api#pollanswer -type PollAnswer struct { - PollID string `json:"poll_id"` - VoterChat Chat `json:"voter_chat"` - User User `json:"user"` - OptionIDS []int `json:"option_ids"` -} - // ChatMemberUpdated represents changes in the status of a chat member. // See https://core.telegram.org/bots/api#chatmemberupdated type ChatMemberUpdated struct { @@ -352,18 +278,17 @@ type WebAppInfo struct { URL string `json:"url"` } +type WebAppData struct { + Data string `json:"data"` + ButtonText string `json:"button_text"` +} + // StarAmount represents an amount of Telegram Stars. type StarAmount struct { Amount int `json:"amount"` NanostarAmount int `json:"nanostar_amount"` } -// Story represents a story. -type Story struct { - Chat Chat `json:"chat"` - ID int `json:"id"` -} - // AcceptedGiftTypes represents the types of gifts accepted by a user or chat. type AcceptedGiftTypes struct { UnlimitedGifts bool `json:"unlimited_gifts"` @@ -373,16 +298,6 @@ type AcceptedGiftTypes struct { GiftsFromChannels bool `json:"gifts_from_channels"` } -// UniqueGiftColors represents color information for a unique gift. -type UniqueGiftColors struct { - ModelCustomEmojiID string `json:"model_custom_emoji_id"` - SymbolCustomEmojiID string `json:"symbol_custom_emoji_id"` - LightThemeMainColor int `json:"light_theme_main_color"` - LightThemeOtherColors []int `json:"light_theme_other_colors"` - DarkThemeMainColor int `json:"dark_theme_main_color"` - DarkThemeOtherColors []int `json:"dark_theme_other_colors"` -} - // GiftBackground represents the background of a gift. type GiftBackground struct { CenterColor int `json:"center_color"` @@ -412,6 +327,78 @@ type Gifts struct { Gifts []Gift `json:"gifts"` } +type UniqueGiftModel struct { + Name string `json:"name"` + Sticker Sticker `json:"sticker"` + RarityPerMille int `json:"rarity_per_mille"` + Rarity string `json:"rarity,omitempty"` +} +type UniqueGiftSymbol struct { + Name string `json:"name"` + Sticker Sticker `json:"sticker"` + RarityPerMille int `json:"rarity_per_mille"` +} +type UniqueGiftBackdropColors struct { + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + SymbolColor int `json:"symbol_color"` + TextColor int `json:"text_color"` +} +type UniqueGiftBackdrop struct { + Name string `json:"name"` + Colors UniqueGiftBackdropColors `json:"colors"` + RarityPerMille int `json:"rarity_per_mille"` +} + +// UniqueGiftColors represents color information for a unique gift. +type UniqueGiftColors struct { + ModelCustomEmojiID string `json:"model_custom_emoji_id"` + SymbolCustomEmojiID string `json:"symbol_custom_emoji_id"` + LightThemeMainColor int `json:"light_theme_main_color"` + LightThemeOtherColors []int `json:"light_theme_other_colors"` + DarkThemeMainColor int `json:"dark_theme_main_color"` + DarkThemeOtherColors []int `json:"dark_theme_other_colors"` +} + +type UniqueGift struct { + GiftID string `json:"gift_id"` + BaseName string `json:"base_name"` + Name string `json:"name"` + Number int `json:"number"` + Model UniqueGiftModel `json:"model"` + Symbol UniqueGiftSymbol `json:"symbol"` + Backdrop UniqueGiftBackdrop `json:"backdrop"` + + IsPremium bool `json:"is_premium,omitempty"` + IsBurned bool `json:"is_burned,omitempty"` + IsFromBlockchain bool `json:"is_from_blockchain,omitempty"` + Colors *UniqueGiftColors `json:"colors,omitempty"` + PublisherChat *Chat `json:"publisher_chat,omitempty"` +} + +type GiftInfo struct { + Gift Gift `json:"gift"` + + OwnedGiftID string `json:"owned_gift_id,omitempty"` + ConvertStarCount int `json:"convert_star_count,omitempty"` + PrepaidUpgradeStarCount int `json:"prepaid_upgrade_star_count,omitempty"` + IsUpgradeSeparate bool `json:"is_upgrade_separate,omitempty"` + CanBeUpgraded bool `json:"can_be_upgraded,omitempty"` + Text string `json:"text,omitempty"` + Entities []MessageEntity `json:"entities,omitempty"` + IsPrivate bool `json:"is_private,omitempty"` + UniqueGiftNumber int `json:"unique_gift_number,omitempty"` +} +type UniqueGiftInfo struct { + Gift UniqueGift `json:"gift"` + Origin string `json:"origin"` + LastResaleCurrency string `json:"last_resale_currency,omitempty"` + LastResaleAmount int `json:"last_resale_amount,omitempty"` + OwnedGiftID string `json:"owned_gift_id,omitempty"` + TransferStarCount int `json:"transfer_star_count,omitempty"` + NextTransferDate int `json:"next_transfer_date,omitempty"` +} + // OwnedGiftType represents the type of an owned gift. type OwnedGiftType string @@ -425,27 +412,27 @@ const ( // OwnedGift represents a gift owned by a user or chat. type OwnedGift struct { Type OwnedGiftType `json:"type"` - OwnerGiftID *string `json:"owner_gift_id,omitempty"` - SendDate *int `json:"send_date,omitempty"` - IsSaved *bool `json:"is_saved,omitempty"` + OwnedGiftID string `json:"ownen_gift_id,omitempty"` + SendDate int `json:"send_date,omitempty"` + IsSaved bool `json:"is_saved,omitempty"` // Fields specific to "regular" type Gift Gift `json:"gift"` SenderUser *User `json:"sender_user,omitempty"` Text string `json:"text,omitempty"` Entities []MessageEntity `json:"entities,omitempty"` - IsPrivate *bool `json:"is_private,omitempty"` - CanBeUpgraded *bool `json:"can_be_upgraded,omitempty"` - WasRefunded *bool `json:"was_refunded,omitempty"` - ConvertStarCount *int `json:"convert_star_count,omitempty"` - PrepaidUpgradeStarCount *int `json:"prepaid_upgrade_star_count,omitempty"` - IsUpgradeSeparate *bool `json:"is_upgrade_separate,omitempty"` - UniqueGiftNumber *int `json:"unique_gift_number,omitempty"` + IsPrivate bool `json:"is_private,omitempty"` + CanBeUpgraded bool `json:"can_be_upgraded,omitempty"` + WasRefunded bool `json:"was_refunded,omitempty"` + ConvertStarCount int `json:"convert_star_count,omitempty"` + PrepaidUpgradeStarCount int `json:"prepaid_upgrade_star_count,omitempty"` + IsUpgradeSeparate bool `json:"is_upgrade_separate,omitempty"` + UniqueGiftNumber int `json:"unique_gift_number,omitempty"` // Fields specific to "unique" type - CanBeTransferred *bool `json:"can_be_transferred,omitempty"` - TransferStarCount *int `json:"transfer_star_count,omitempty"` - NextTransferDate *int `json:"next_transfer_date,omitempty"` + CanBeTransferred bool `json:"can_be_transferred,omitempty"` + TransferStarCount int `json:"transfer_star_count,omitempty"` + NextTransferDate int `json:"next_transfer_date,omitempty"` } // OwnedGifts represents a list of owned gifts with pagination. @@ -454,3 +441,94 @@ type OwnedGifts struct { Gifts []OwnedGift `json:"gifts"` NextOffset string `json:"next_offset"` } + +type GiveawayCreated struct { + PrizeStarCount int `json:"prize_star_count,omitempty"` +} + +type Giveaway struct { + Chats []Chat `json:"chats"` + WinnersSelectionDate int `json:"winners_selection_date"` + WinnerCount int `json:"winner_count"` + + OnlyNewMembers bool `json:"only_new_members,omitempty"` + HasPublicWinners bool `json:"has_public_winners,omitempty"` + PrizeDescription string `json:"prize_description,omitempty"` + CountryCodes []string `json:"country_codes,omitempty"` + PrizeStarCount int `json:"prize_star_count,omitempty"` + PremiumSubscriptionMonthCount int `json:"premium_subscription_month_count,omitempty"` +} + +type GiveawayWinners struct { + Chat Chat `json:"chat"` + GiveawayMessageID int `json:"giveaway_message_id"` + WinnersSelectionDate int `json:"winners_selection_date"` + WinnerCount int `json:"winner_count"` + Winners []User `json:"winners"` + + AdditionalChatCount int `json:"additional_chat_count,omitempty"` + PrizeStarCount int `json:"prize_star_count,omitempty"` + PremiumSubscriptionMonthCount int `json:"premium_subscription_month_count,omitempty"` + UnclaimedPrizeCount int `json:"unclaimed_prize_count,omitempty"` + OnlyNewMembers bool `json:"only_new_members,omitempty"` + WasRefunded bool `json:"was_refunded,omitempty"` + PrizeDescription string `json:"prize_description,omitempty"` +} + +type GiveawayCompleted struct { + WinnerCount int `json:"winner_count"` + UnclaimedPrizeCount int `json:"unclaimed_prize_count,omitempty"` + GiveawayMessage *Message `json:"giveaway_message,omitempty"` + IsStarGiveaway bool `json:"is_star_giveaway,omitempty"` +} + +type WriteAccessAllowed struct { + FromRequest bool `json:"from_request,omitempty"` + WebAppName string `json:"web_app_name,omitempty"` + FromAttachmentMenu bool `json:"from_attachment_menu,omitempty"` +} + +type BackgroundFillType string + +const ( + BackgroundFillSolidType BackgroundFillType = "solid" + BackgroundFillGradientType BackgroundFillType = "gradient" + BackgroundFillFreeformGradientType BackgroundFillType = "freeform_gradient" +) + +type BackgroundFill struct { + Type BackgroundFillType `json:"type"` + + Color int `json:"color,omitempty"` + + TopColor int `json:"top_color,omitempty"` + BottomColor int `json:"bottom_color,omitempty"` + RotationAngle int `json:"rotation_angle,omitempty"` + + Colors []int `json:"colors,omitempty"` +} + +type BackgroundTypeType string + +const ( + BackgroundTypeFillType BackgroundTypeType = "fill" + BackgroundTypeWallpaperType BackgroundTypeType = "wallpaper" + BackgroundTypePatternType BackgroundTypeType = "pattern" + BackgroundTypeChatThemeType BackgroundTypeType = "chat_theme" +) + +type BackgroundType struct { + Type BackgroundTypeType `json:"type"` + + Fill *BackgroundFill `json:"fill,omitempty"` + DarkThemeDimming int `json:"dark_theme_dimming,omitempty"` + + Document *Document `json:"document,omitempty"` + IsBlurred bool `json:"is_blurred,omitempty"` + IsMoving bool `json:"is_moving,omitempty"` + + Intensity int `json:"intensity,omitempty"` + IsInverted bool `json:"is_inverted,omitempty"` + + ThemeName string `json:"theme_name,omitempty"` +} diff --git a/tgapi/types_test.go b/tgapi/types_test.go index 6a72d98..8bc4156 100644 --- a/tgapi/types_test.go +++ b/tgapi/types_test.go @@ -61,6 +61,17 @@ func TestUpdateUnmarshalSetsType(t *testing.T) { body: `{"update_id":4}`, want: UpdateTypeUnknown, }, + { + name: "managed bot", + body: `{ + "update_id": 5, + "managed_bot": { + "user": {"id": 11, "is_bot": false, "first_name": "Manager"}, + "bot": {"id": 12, "is_bot": true, "first_name": "Worker"} + } + }`, + want: UpdateTypeManagedBot, + }, } for _, tt := range tests { @@ -75,10 +86,41 @@ func TestUpdateUnmarshalSetsType(t *testing.T) { if tt.want == UpdateTypeChatBoost && update.ChatBoost.Boost.BoostID != "boost-1" { t.Fatalf("unexpected boost id: got %q want %q", update.ChatBoost.Boost.BoostID, "boost-1") } + if tt.want == UpdateTypeManagedBot && update.ManagedBot.Bot.ID != 12 { + t.Fatalf("unexpected managed bot id: got %d want %d", update.ManagedBot.Bot.ID, 12) + } }) } } +func TestPollUnmarshalSupportsBotAPI96Fields(t *testing.T) { + var poll Poll + + body := `{ + "id": "poll-1", + "question": "Pick winners", + "question_entities": [], + "options": [], + "total_voter_count": 2, + "is_closed": false, + "is_anonymous": false, + "type": "quiz", + "allows_multiple_answers": true, + "allows_revoting": true, + "correct_option_ids": [1, 3] + }` + + if err := json.Unmarshal([]byte(body), &poll); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if !poll.AllowsRevoting { + t.Fatal("expected allows_revoting to be decoded") + } + if len(poll.CorrectOptionIDs) != 2 || poll.CorrectOptionIDs[0] != 1 || poll.CorrectOptionIDs[1] != 3 { + t.Fatalf("unexpected correct option ids: %#v", poll.CorrectOptionIDs) + } +} + func TestUpdateMarshalOmitsSyntheticTypeField(t *testing.T) { update := Update{ UpdateID: 1, @@ -114,3 +156,62 @@ func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) { t.Fatalf("expected UpdateTypeUnknown, got %q", update.Type) } } + +func TestMaybeInaccessibleMessageUnmarshalAccessibleMessage(t *testing.T) { + var wrapper MaybeInaccessibleMessage + + body := `{ + "message_id": 10, + "date": 1700000000, + "chat": {"id": 42, "type": "private"}, + "text": "hello" + }` + + if err := json.Unmarshal([]byte(body), &wrapper); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if !wrapper.IsAccessible() { + t.Fatal("expected accessible message payload") + } + if wrapper.IsInaccessible() { + t.Fatal("expected inaccessible payload to be empty") + } + if wrapper.Message() == nil || wrapper.Message().Text != "hello" { + t.Fatalf("unexpected accessible payload: %#v", wrapper.Message()) + } + if wrapper.MessageID() != 10 { + t.Fatalf("unexpected message id: got %d want %d", wrapper.MessageID(), 10) + } + if wrapper.Chat() == nil || wrapper.Chat().ID != 42 { + t.Fatalf("unexpected chat payload: %#v", wrapper.Chat()) + } +} + +func TestMaybeInaccessibleMessageUnmarshalInaccessibleMessage(t *testing.T) { + var wrapper MaybeInaccessibleMessage + + body := `{ + "message_id": 7, + "date": 0, + "chat": {"id": -1001, "type": "supergroup"} + }` + + if err := json.Unmarshal([]byte(body), &wrapper); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if wrapper.IsAccessible() { + t.Fatal("expected accessible payload to be empty") + } + if !wrapper.IsInaccessible() { + t.Fatal("expected inaccessible message payload") + } + if wrapper.InaccessibleMessage() == nil || wrapper.InaccessibleMessage().MessageID != 7 { + t.Fatalf("unexpected inaccessible payload: %#v", wrapper.InaccessibleMessage()) + } + if wrapper.MessageID() != 7 { + t.Fatalf("unexpected message id: got %d want %d", wrapper.MessageID(), 7) + } + if wrapper.Chat() == nil || wrapper.Chat().ID != -1001 { + t.Fatalf("unexpected chat payload: %#v", wrapper.Chat()) + } +} diff --git a/tgapi/uploader_api_test.go b/tgapi/uploader_api_test.go index 54f0d3c..88f328f 100644 --- a/tgapi/uploader_api_test.go +++ b/tgapi/uploader_api_test.go @@ -57,7 +57,7 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test }() msg, err := uploader.SendPhoto( - UploadPhotoP{ + UploadPhoto{ ChatID: 42, CaptionEntities: []MessageEntity{{ Type: MessageEntityBold, diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index d88a534..f9b8213 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// UploadPhotoP holds parameters for uploading a photo using the Uploader. +// UploadPhoto holds parameters for uploading a photo using the Uploader. // See https://core.telegram.org/bots/api#sendphoto -type UploadPhotoP struct { +type UploadPhoto struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -29,7 +29,7 @@ type UploadPhotoP struct { // SendPhoto uploads a photo via multipart and sends it as a message. // file is the photo file to upload. // See https://core.telegram.org/bots/api#sendphoto -func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, error) { +func (u *Uploader) SendPhoto(params UploadPhoto, file UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file) return req.Do(u) } @@ -39,14 +39,14 @@ func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, e // SendPhotoWithContext is the context-aware variant of SendPhoto. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendphoto -func (u *Uploader) SendPhotoWithContext(ctx context.Context, params UploadPhotoP, file UploaderFile) (Message, error) { +func (u *Uploader) SendPhotoWithContext(ctx context.Context, params UploadPhoto, file UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file) return req.DoWithContext(ctx, u) } -// UploadAudioP holds parameters for uploading an audio file using the Uploader. +// UploadAudio holds parameters for uploading an audio file using the Uploader. // See https://core.telegram.org/bots/api#sendaudio -type UploadAudioP struct { +type UploadAudio struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -73,7 +73,7 @@ type UploadAudioP struct { // SendAudio uploads an audio file via multipart and sends it as a message. // files are the audio file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendaudio -func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendAudio(params UploadAudio, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...) return req.Do(u) } @@ -83,14 +83,14 @@ func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Messag // SendAudioWithContext is the context-aware variant of SendAudio. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendaudio -func (u *Uploader) SendAudioWithContext(ctx context.Context, params UploadAudioP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendAudioWithContext(ctx context.Context, params UploadAudio, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...) return req.DoWithContext(ctx, u) } -// UploadDocumentP holds parameters for uploading a document using the Uploader. +// UploadDocument holds parameters for uploading a document using the Uploader. // See https://core.telegram.org/bots/api#senddocument -type UploadDocumentP struct { +type UploadDocument struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -114,7 +114,7 @@ type UploadDocumentP struct { // SendDocument uploads a document via multipart and sends it as a message. // files are the document file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#senddocument -func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendDocument(params UploadDocument, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...) return req.Do(u) } @@ -124,14 +124,14 @@ func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) ( // SendDocumentWithContext is the context-aware variant of SendDocument. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#senddocument -func (u *Uploader) SendDocumentWithContext(ctx context.Context, params UploadDocumentP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendDocumentWithContext(ctx context.Context, params UploadDocument, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...) return req.DoWithContext(ctx, u) } -// UploadVideoP holds parameters for uploading a video using the Uploader. +// UploadVideo holds parameters for uploading a video using the Uploader. // See https://core.telegram.org/bots/api#sendvideo -type UploadVideoP struct { +type UploadVideo struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -162,7 +162,7 @@ type UploadVideoP struct { // SendVideo uploads a video via multipart and sends it as a message. // files are the video file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvideo -func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendVideo(params UploadVideo, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...) return req.Do(u) } @@ -172,14 +172,14 @@ func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Messag // SendVideoWithContext is the context-aware variant of SendVideo. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvideo -func (u *Uploader) SendVideoWithContext(ctx context.Context, params UploadVideoP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendVideoWithContext(ctx context.Context, params UploadVideo, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...) return req.DoWithContext(ctx, u) } -// UploadAnimationP holds parameters for uploading an animation using the Uploader. +// UploadAnimation holds parameters for uploading an animation using the Uploader. // See https://core.telegram.org/bots/api#sendanimation -type UploadAnimationP struct { +type UploadAnimation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -208,7 +208,7 @@ type UploadAnimationP struct { // SendAnimation uploads an animation via multipart and sends it as a message. // files are the animation file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendanimation -func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendAnimation(params UploadAnimation, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...) return req.Do(u) } @@ -218,14 +218,14 @@ func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile) // SendAnimationWithContext is the context-aware variant of SendAnimation. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendanimation -func (u *Uploader) SendAnimationWithContext(ctx context.Context, params UploadAnimationP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendAnimationWithContext(ctx context.Context, params UploadAnimation, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...) return req.DoWithContext(ctx, u) } -// UploadVoiceP holds parameters for uploading a voice note using the Uploader. +// UploadVoice holds parameters for uploading a voice note using the Uploader. // See https://core.telegram.org/bots/api#sendvoice -type UploadVoiceP struct { +type UploadVoice struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -249,7 +249,7 @@ type UploadVoiceP struct { // SendVoice uploads a voice note via multipart and sends it as a message. // files are the voice file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvoice -func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendVoice(params UploadVoice, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...) return req.Do(u) } @@ -259,14 +259,14 @@ func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Messag // SendVoiceWithContext is the context-aware variant of SendVoice. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvoice -func (u *Uploader) SendVoiceWithContext(ctx context.Context, params UploadVoiceP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendVoiceWithContext(ctx context.Context, params UploadVoice, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...) return req.DoWithContext(ctx, u) } -// UploadVideoNoteP holds parameters for uploading a video note (rounded video) using the Uploader. +// UploadVideoNote holds parameters for uploading a video note (rounded video) using the Uploader. // See https://core.telegram.org/bots/api#sendvideonote -type UploadVideoNoteP struct { +type UploadVideoNote struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -288,7 +288,7 @@ type UploadVideoNoteP struct { // SendVideoNote uploads a video note via multipart and sends it as a message. // files are the video note file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvideonote -func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendVideoNote(params UploadVideoNote, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...) return req.Do(u) } @@ -298,21 +298,21 @@ func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile) // SendVideoNoteWithContext is the context-aware variant of SendVideoNote. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvideonote -func (u *Uploader) SendVideoNoteWithContext(ctx context.Context, params UploadVideoNoteP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendVideoNoteWithContext(ctx context.Context, params UploadVideoNote, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...) return req.DoWithContext(ctx, u) } -// UploadChatPhotoP holds parameters for uploading a chat photo using the Uploader. +// UploadChatPhoto holds parameters for uploading a chat photo using the Uploader. // See https://core.telegram.org/bots/api#setchatphoto -type UploadChatPhotoP struct { +type UploadChatPhoto struct { ChatID int64 `json:"chat_id"` } // SetChatPhoto uploads a new chat photo. // photo is the photo file to upload. // See https://core.telegram.org/bots/api#setchatphoto -func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bool, error) { +func (u *Uploader) SetChatPhoto(params UploadChatPhoto, photo UploaderFile) (bool, error) { req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo) return req.Do(u) } @@ -322,15 +322,15 @@ func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bo // SetChatPhotoWithContext is the context-aware variant of SetChatPhoto. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatphoto -func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadChatPhotoP, photo UploaderFile) (bool, error) { +func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadChatPhoto, photo UploaderFile) (bool, error) { req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo) return req.DoWithContext(ctx, u) } -// UploadSetWebhookP holds multipart parameters for the setWebhook method. +// UploadSetWebhook holds multipart parameters for the setWebhook method. // Use this type when uploading a self-signed certificate file. // See https://core.telegram.org/bots/api#setwebhook -type UploadSetWebhookP struct { +type UploadSetWebhook struct { URL string `json:"url"` IPAddress string `json:"ip_address,omitempty"` MaxConnections int8 `json:"max_connections,omitempty"` @@ -342,7 +342,7 @@ type UploadSetWebhookP struct { // SetWebhook uploads a certificate and sets a webhook URL. // certificate maps to the multipart field \"certificate\". // See https://core.telegram.org/bots/api#setwebhook -func (u *Uploader) SetWebhook(params UploadSetWebhookP, certificate UploaderFile) (bool, error) { +func (u *Uploader) SetWebhook(params UploadSetWebhook, certificate UploaderFile) (bool, error) { req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType)) return req.Do(u) } @@ -350,7 +350,7 @@ func (u *Uploader) SetWebhook(params UploadSetWebhookP, certificate UploaderFile // SetWebhookWithContext is the context-aware variant of SetWebhook. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setwebhook -func (u *Uploader) SetWebhookWithContext(ctx context.Context, params UploadSetWebhookP, certificate UploaderFile) (bool, error) { +func (u *Uploader) SetWebhookWithContext(ctx context.Context, params UploadSetWebhook, certificate UploaderFile) (bool, error) { req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType)) return req.DoWithContext(ctx, u) } diff --git a/tgapi/users_methods.go b/tgapi/users_methods.go index 08c883b..a8be7b3 100644 --- a/tgapi/users_methods.go +++ b/tgapi/users_methods.go @@ -2,9 +2,9 @@ package tgapi import "context" -// GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method. +// GetUserProfilePhotos holds parameters for the GetUserProfilePhotos method. // See https://core.telegram.org/bots/api#getuserprofilephotos -type GetUserProfilePhotosP struct { +type GetUserProfilePhotos struct { UserID int64 `json:"user_id"` Offset int `json:"offset,omitempty"` Limit int `json:"limit,omitempty"` @@ -12,7 +12,7 @@ type GetUserProfilePhotosP struct { // GetUserProfilePhotos returns a list of profile pictures for a user. // See https://core.telegram.org/bots/api#getuserprofilephotos -func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfilePhotos, error) { +func (api *API) GetUserProfilePhotos(params GetUserProfilePhotos) (UserProfilePhotos, error) { req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params) return req.Do(api) } @@ -20,14 +20,14 @@ func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfileP // GetUserProfilePhotosWithContext is the context-aware variant of GetUserProfilePhotos. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getuserprofilephotos -func (api *API) GetUserProfilePhotosWithContext(ctx context.Context, params GetUserProfilePhotosP) (UserProfilePhotos, error) { +func (api *API) GetUserProfilePhotosWithContext(ctx context.Context, params GetUserProfilePhotos) (UserProfilePhotos, error) { req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params) return req.DoWithContext(ctx, api) } -// GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method. +// GetUserProfileAudios holds parameters for the GetUserProfileAudios method. // See https://core.telegram.org/bots/api#getuserprofileaudios -type GetUserProfileAudiosP struct { +type GetUserProfileAudios struct { UserID int64 `json:"user_id"` Offset int `json:"offset,omitempty"` Limit int `json:"limit,omitempty"` @@ -35,7 +35,7 @@ type GetUserProfileAudiosP struct { // GetUserProfileAudios returns a list of profile audios for a user. // See https://core.telegram.org/bots/api#getuserprofileaudios -func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileAudios, error) { +func (api *API) GetUserProfileAudios(params GetUserProfileAudios) (UserProfileAudios, error) { req := NewRequest[UserProfileAudios]("getUserProfileAudios", params) return req.Do(api) } @@ -43,14 +43,14 @@ func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileA // GetUserProfileAudiosWithContext is the context-aware variant of GetUserProfileAudios. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getuserprofileaudios -func (api *API) GetUserProfileAudiosWithContext(ctx context.Context, params GetUserProfileAudiosP) (UserProfileAudios, error) { +func (api *API) GetUserProfileAudiosWithContext(ctx context.Context, params GetUserProfileAudios) (UserProfileAudios, error) { req := NewRequest[UserProfileAudios]("getUserProfileAudios", params) return req.DoWithContext(ctx, api) } -// SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method. +// SetUserEmojiStatus holds parameters for the SetUserEmojiStatus method. // See https://core.telegram.org/bots/api#setuseremojistatus -type SetUserEmojiStatusP struct { +type SetUserEmojiStatus struct { UserID int64 `json:"user_id"` EmojiID string `json:"emoji_status_custom_emoji_id,omitempty"` ExpirationDate int `json:"emoji_status_expiration_date,omitempty"` @@ -59,7 +59,7 @@ type SetUserEmojiStatusP struct { // SetUserEmojiStatus sets a custom emoji status for a user. // Returns true on success. // See https://core.telegram.org/bots/api#setuseremojistatus -func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) { +func (api *API) SetUserEmojiStatus(params SetUserEmojiStatus) (bool, error) { req := NewRequest[bool]("setUserEmojiStatus", params) return req.Do(api) } @@ -67,14 +67,14 @@ func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) { // SetUserEmojiStatusWithContext is the context-aware variant of SetUserEmojiStatus. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setuseremojistatus -func (api *API) SetUserEmojiStatusWithContext(ctx context.Context, params SetUserEmojiStatusP) (bool, error) { +func (api *API) SetUserEmojiStatusWithContext(ctx context.Context, params SetUserEmojiStatus) (bool, error) { req := NewRequest[bool]("setUserEmojiStatus", params) return req.DoWithContext(ctx, api) } -// GetUserGiftsP holds parameters for the GetUserGifts method. +// GetUserGifts holds parameters for the GetUserGifts method. // See https://core.telegram.org/bots/api#getusergifts -type GetUserGiftsP struct { +type GetUserGifts struct { UserID int64 `json:"user_id"` ExcludeUnlimited bool `json:"exclude_unlimited,omitempty"` ExcludeLimitedUpgradable bool `json:"exclude_limited_upgradable,omitempty"` @@ -88,7 +88,7 @@ type GetUserGiftsP struct { // GetUserGifts returns gifts owned by a user. // See https://core.telegram.org/bots/api#getusergifts -func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) { +func (api *API) GetUserGifts(params GetUserGifts) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getUserGifts", params) return req.Do(api) } @@ -96,7 +96,7 @@ func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) { // GetUserGiftsWithContext is the context-aware variant of GetUserGifts. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getusergifts -func (api *API) GetUserGiftsWithContext(ctx context.Context, params GetUserGiftsP) (OwnedGifts, error) { +func (api *API) GetUserGiftsWithContext(ctx context.Context, params GetUserGifts) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getUserGifts", params) return req.DoWithContext(ctx, api) } diff --git a/tgapi/users_types.go b/tgapi/users_types.go index 94eeeb8..2079797 100644 --- a/tgapi/users_types.go +++ b/tgapi/users_types.go @@ -13,6 +13,7 @@ type User struct { AddedToAttachmentMenu *bool `json:"added_to_attachment_menu,omitempty"` CanJoinGroups *bool `json:"can_join_groups,omitempty"` CanReadAllGroupMessages *bool `json:"can_read_all_group_messages,omitempty"` + CanManageBots *bool `json:"can_manage_bots,omitempty"` SupportsInlineQueries *bool `json:"supports_inline_queries,omitempty"` CanConnectToBusiness *bool `json:"can_connect_to_business,omitempty"` HasMainWebApp *bool `json:"has_main_web_app,omitempty"` diff --git a/utils/multipart_test.go b/utils/multipart_test.go index a1dc522..8bc9aab 100644 --- a/utils/multipart_test.go +++ b/utils/multipart_test.go @@ -19,10 +19,9 @@ type multipartEncodeParams struct { } func TestEncodeMultipartJSONFields(t *testing.T) { - threadID := 7 params := multipartEncodeParams{ ChatID: 42, - MessageThreadID: &threadID, + MessageThreadID: new(7), ReplyMarkup: &tgapi.ReplyMarkup{ InlineKeyboard: [][]tgapi.InlineKeyboardButton{{ {Text: "A", CallbackData: "b"}, -- 2.54.0 From 2b64e8543f2a61772b4fe4dddfe30c7760e2ee3e Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 13 Apr 2026 10:11:13 +0300 Subject: [PATCH 73/99] tests cleanup --- bot_webhook.go | 4 ++-- plugins_test.go | 6 +++--- policy_test.go | 18 +++++++++--------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bot_webhook.go b/bot_webhook.go index 23f55f3..9612ff0 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -362,9 +362,9 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc { func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *http.ServeMux { r := http.NewServeMux() if opts.UseStatusPath { - r.HandleFunc("/status", statusHandler[T](bot, opts)) + r.HandleFunc("/status", statusHandler(bot, opts)) } - r.HandleFunc(opts.Path, updateHandler[T](ctx, bot, opts.SecretToken)) + r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken)) return r } func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error { diff --git a/plugins_test.go b/plugins_test.go index b293aec..1518cf3 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -6,7 +6,7 @@ import ( ) func TestValidateArgsRequiresFullMatch(t *testing.T) { - intCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) + intCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) if err := intCmd.validateArgs([]string{"123"}); err != nil { t.Fatalf("expected valid integer argument, got %v", err) } @@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) } - boolCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) + boolCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) if err := boolCmd.validateArgs([]string{"false"}); err != nil { t.Fatalf("expected valid bool argument, got %v", err) } @@ -24,7 +24,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { } func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { - cmd := NewCommand[NoData]( + cmd := NewCommand( func(ctx *MsgContext, db NoData) error { return nil }, "mixed", NewCommandArg("optional"), diff --git a/policy_test.go b/policy_test.go index a7d6fd8..1952c44 100644 --- a/policy_test.go +++ b/policy_test.go @@ -53,7 +53,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { errorTemplate: "Error: %s", } - mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error { + mw := RequirePolicy("deny", func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("blocked")) }) @@ -159,7 +159,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { func TestAllPoliciesReturnsFirstError(t *testing.T) { want := AsUserError(errors.New("blocked")) - policy := AllPolicies[NoData]( + policy := AllPolicies( func(ctx *MsgContext, data NoData) error { return nil }, func(ctx *MsgContext, data NoData) error { return want }, func(ctx *MsgContext, data NoData) error { @@ -175,7 +175,7 @@ func TestAllPoliciesReturnsFirstError(t *testing.T) { } func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { - policy := AnyPolicy[NoData]( + policy := AnyPolicy( func(ctx *MsgContext, data NoData) error { return AsInternalError(errors.New("temporary")) }, func(ctx *MsgContext, data NoData) error { return nil }, ) @@ -187,7 +187,7 @@ func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { internal := AsInternalError(errors.New("temporary")) - policy := AnyPolicy[NoData]( + policy := AnyPolicy( func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) }, func(ctx *MsgContext, data NoData) error { return internal }, ) @@ -200,7 +200,7 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) { first := AsUserError(errors.New("first deny")) - policy := AnyPolicy[NoData]( + policy := AnyPolicy( func(ctx *MsgContext, data NoData) error { return first }, func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) }, ) @@ -212,7 +212,7 @@ func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) { } func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { - inverted := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error { + inverted := NotPolicy(func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) }) if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil { @@ -220,7 +220,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { } internal := AsInternalError(errors.New("temporary")) - preserve := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error { + preserve := NotPolicy(func(ctx *MsgContext, data NoData) error { return internal }) err := preserve(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) @@ -240,7 +240,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { ChatID: 20, } - mw := RequirePolicy[NoData]("allow", func(ctx *MsgContext, data NoData) error { + mw := RequirePolicy("allow", func(ctx *MsgContext, data NoData) error { return nil }) @@ -264,7 +264,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { errorTemplate: "%s", } - mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error { + mw := RequirePolicy("deny", func(ctx *MsgContext, data NoData) error { return AsInternalError(errors.New("blocked")) }) -- 2.54.0 From aa18da73d50a53e4e58e7eacbe148b2bc6bd60c0 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 13 Apr 2026 16:27:53 +0300 Subject: [PATCH 74/99] Add plugin message fallback Route unmatched messages through plugin fallback handlers Add observer coverage and bump version to rc.15 --- CHANGELOG.md | 8 +++ bot_utils.go | 19 +++--- handler_test.go | 168 ++++++++++++++++++++++++++++++++++++++++++++++- msg_handler.go | 112 ++++++++++++++++++++++++++----- observer.go | 2 + plugins.go | 22 +++++-- utils/version.go | 4 +- 7 files changed, 299 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fea96ea..7c260b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.0.0-rc.15 + +### Changed +- Added plugin-level message fallback handlers for text messages and channel posts that do not match commands. + +### Tests +- Added regression coverage for plugin message fallback routing, observer lifecycle events, command precedence, and middleware blocking. + ## v1.0.0-rc.14 ### Bot API 9.6 diff --git a/bot_utils.go b/bot_utils.go index c6fa9b4..097aa19 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -122,15 +122,16 @@ func shouldWarnOnValueAppData[T any]() bool { func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] { cloned := Plugin[T]{ - name: p.name, - commands: make(map[string]*Command[T], len(p.commands)), - payloads: make(map[string]*Command[T], len(p.payloads)), - scenes: make(map[string]*Scene[T], len(p.scenes)), - middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), - skipAutoCmd: p.skipAutoCmd, - logger: p.logger, - handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), - onClose: p.onClose, + name: p.name, + commands: make(map[string]*Command[T], len(p.commands)), + payloads: make(map[string]*Command[T], len(p.payloads)), + scenes: make(map[string]*Scene[T], len(p.scenes)), + middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), + skipAutoCmd: p.skipAutoCmd, + logger: p.logger, + messageFallback: p.messageFallback, + handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), + onClose: p.onClose, } for name, command := range p.commands { diff --git a/handler_test.go b/handler_test.go index a42b6ef..2f1fdcf 100644 --- a/handler_test.go +++ b/handler_test.go @@ -13,13 +13,16 @@ type recordingObserver struct { started []HandlerStartedEvent finished []HandlerFinishedEvent errors []ErrorEvent + handled []UpdateHandledEvent policies []PolicyCheckedEvent runners []RunnerFinishedEvent retries []PollingRetryEvent } func (*recordingObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {} -func (*recordingObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {} +func (o *recordingObserver) OnHandledUpdate(_ context.Context, ev UpdateHandledEvent) { + o.handled = append(o.handled, ev) +} func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) { o.started = append(o.started, ev) } @@ -584,6 +587,169 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { } } +func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { + observer := &recordingObserver{} + called := false + plugin := NewPlugin[NoData]("test") + plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + called = true + if ctx.Text != "/missing hello world" { + t.Fatalf("unexpected fallback text: got %q", ctx.Text) + } + if ctx.Prefix != "/" { + t.Fatalf("unexpected fallback prefix: got %q", ctx.Prefix) + } + wantArgs := []string{"/missing", "hello", "world"} + if len(ctx.Args) != len(wantArgs) || ctx.Args[0] != wantArgs[0] || ctx.Args[1] != wantArgs[1] || ctx.Args[2] != wantArgs[2] { + t.Fatalf("unexpected fallback args: got %v want %v", ctx.Args, wantArgs) + } + return nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + observer: observer, + } + bot.AddPlugins(plugin) + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 5, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "/missing hello world", + From: &tgapi.User{ID: 41}, + Chat: &tgapi.Chat{ID: 99}, + }, + }) + + if !called { + t.Fatal("expected message fallback to be called") + } + if len(observer.started) != 1 { + t.Fatalf("expected one started event, got %d", len(observer.started)) + } + if got := observer.started[0]; got.HandlerKind != HandlerMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "test" { + t.Fatalf("unexpected started event: %#v", got) + } + if len(observer.finished) != 1 { + t.Fatalf("expected one finished event, got %d", len(observer.finished)) + } + if got := observer.finished[0]; got.HandlerKind != HandlerMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "test" || got.Err != nil { + t.Fatalf("unexpected finished event: %#v", got) + } + if len(observer.handled) != 1 || !observer.handled[0].Handled { + t.Fatalf("expected handled update event, got %#v", observer.handled) + } +} + +func TestHandleMessageFallbackRunsForPlainText(t *testing.T) { + called := false + plugin := NewPlugin[NoData]("test").SetMessageFallback(func(ctx *MsgContext, db NoData) error { + called = true + if ctx.Text != "hello fallback" { + t.Fatalf("unexpected fallback text: got %q", ctx.Text) + } + if ctx.Prefix != "" { + t.Fatalf("unexpected fallback prefix: got %q", ctx.Prefix) + } + return nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + } + bot.AddPlugins(plugin) + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 6, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "hello fallback", + From: &tgapi.User{ID: 41}, + Chat: &tgapi.Chat{ID: 99}, + }, + }) + + if !called { + t.Fatal("expected message fallback to be called") + } +} + +func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) { + called := false + plugin := NewPlugin[NoData]("test") + plugin.AddMiddleware(NewMiddleware("block", func(ctx *MsgContext, db NoData) bool { + return false + })) + plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + called = true + return nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + } + bot.AddPlugins(plugin) + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 7, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "blocked", + From: &tgapi.User{ID: 41}, + Chat: &tgapi.Chat{ID: 99}, + }, + }) + + if called { + t.Fatal("message fallback must not run when plugin middleware blocks") + } +} + +func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { + commandCalled := false + fallbackCalled := false + plugin := NewPlugin[NoData]("test") + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + commandCalled = true + return nil + }, "start") + plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + fallbackCalled = true + return nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + } + bot.AddPlugins(plugin) + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 8, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "/start", + From: &tgapi.User{ID: 41}, + Chat: &tgapi.Chat{ID: 99}, + }, + }) + + if !commandCalled { + t.Fatal("expected command handler to be called") + } + if fallbackCalled { + t.Fatal("message fallback must not run when command matches") + } +} + func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") diff --git a/msg_handler.go b/msg_handler.go index 8d355d2..5053ddd 100644 --- a/msg_handler.go +++ b/msg_handler.go @@ -8,27 +8,14 @@ import ( ) func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { - var msg *tgapi.Message - if update.Message != nil { - msg = update.Message - } else if update.ChannelPost != nil { - msg = update.ChannelPost - } else { - return false - } - - var text string - if len(msg.Text) > 0 { - text = msg.Text - } else if len(msg.Caption) > 0 { - text = msg.Caption - } else { + text, ok := messageText(update) + if !ok { return false } prefix, cmd, args := bot.parseCommand(text) if cmd == "" { - return false + return bot.handleFallback(update, ctx) } ctx.Prefix = prefix @@ -99,7 +86,98 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { return true } } - return false + + return bot.handleFallback(update, ctx) +} + +func (bot *Bot[T]) handleFallback(update *tgapi.Update, ctx *MsgContext) bool { + text, ok := messageText(update) + if !ok { + return false + } + + prefix, _, _ := bot.parseCommand(text) + handled := false + for _, plugin := range bot.plugins { + if plugin.messageFallback == nil { + continue + } + + pluginCtx := cloneMsgContext(ctx) + pluginCtx.Prefix = prefix + pluginCtx.Text = text + pluginCtx.Args = strings.Fields(text) + if plugin.logger != nil { + pluginCtx.Logger = plugin.logger + } + if !plugin.executeMiddlewares(pluginCtx, bot.appData) { + continue + } + + startTime := time.Now() + bot.safeEmitEvent(pluginCtx.Context(), HandlerStartedEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerMessageKind, + HandlerName: "message_fallback", + FromID: pluginCtx.FromID, + ChatID: pluginCtx.ChatID, + }) + err := plugin.messageFallback(pluginCtx, bot.appData) + endEvent := HandlerFinishedEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerMessageKind, + HandlerName: "message_fallback", + FromID: pluginCtx.FromID, + ChatID: pluginCtx.ChatID, + Duration: time.Since(startTime), + } + if err != nil { + endEvent.Err = err + endEvent.UserFacing = IsUserError(err) + } + bot.safeEmitEvent(pluginCtx.Context(), endEvent) + if err != nil { + pluginCtx.error(err) + bot.safeEmitEvent(pluginCtx.Context(), ErrorEvent{ + UpdateID: update.UpdateID, + UpdateType: update.Type, + Plugin: plugin.name, + HandlerKind: HandlerMessageKind, + HandlerName: "message_fallback", + FromID: pluginCtx.FromID, + ChatID: pluginCtx.ChatID, + Err: err, + UserFacing: IsUserError(err), + }) + } + handled = true + } + return handled +} + +func messageText(update *tgapi.Update) (string, bool) { + var msg *tgapi.Message + if update.Message != nil { + msg = update.Message + } else if update.ChannelPost != nil { + msg = update.ChannelPost + } else { + return "", false + } + + var text string + if len(msg.Text) > 0 { + text = msg.Text + } else if len(msg.Caption) > 0 { + text = msg.Caption + } else { + return "", false + } + return text, true } func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool { diff --git a/observer.go b/observer.go index d5df215..d454acc 100644 --- a/observer.go +++ b/observer.go @@ -14,6 +14,8 @@ type HandlerEventKind string const ( // HandlerCommandKind identifies a command handler. HandlerCommandKind HandlerEventKind = "command" + // HandlerMessageKind identifies a message fallback handler. + HandlerMessageKind HandlerEventKind = "message" // HandlerPayloadKind identifies a callback payload handler. HandlerPayloadKind HandlerEventKind = "payload" // HandlerUpdateKind identifies a generic update handler. diff --git a/plugins.go b/plugins.go index e61a223..014322b 100644 --- a/plugins.go +++ b/plugins.go @@ -171,7 +171,8 @@ type Plugin[T AppData] struct { skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help logger *slog.Logger - handlers map[tgapi.UpdateType]CommandExecutor[T] + messageFallback CommandExecutor[T] + handlers map[tgapi.UpdateType]CommandExecutor[T] onClose func() error } @@ -243,12 +244,6 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { return p } -// UsePolicy registers a Policy as plugin middleware for all plugin handlers. -func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] { - mw := RequirePolicy(name, policy) - return p.AddMiddleware(mw) -} - // NewScene creates, registers, and returns a new scene owned by the plugin. func (p *Plugin[T]) NewScene(name string) *Scene[T] { scene := NewScene[T](name) @@ -257,6 +252,12 @@ func (p *Plugin[T]) NewScene(name string) *Scene[T] { return scene } +// UsePolicy registers a Policy as plugin middleware for all plugin handlers. +func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] { + mw := RequirePolicy(name, policy) + return p.AddMiddleware(mw) +} + // AddUpdateHandler registers a handler for a non-command update type. // Message, channel post, and callback query updates stay on the command/payload flow. func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] { @@ -316,6 +317,13 @@ func (p *Plugin[T]) SetOnClose(f func() error) *Plugin[T] { return p } +// SetMessageFallback registers a fallback handler for messages that do not +// match a command. +func (p *Plugin[T]) SetMessageFallback(handler CommandExecutor[T]) *Plugin[T] { + p.messageFallback = handler + return p +} + // Close releases plugin-owned resources such as its logger and optional // OnClose callback. func (p *Plugin[T]) Close() error { diff --git a/utils/version.go b/utils/version.go index 22bdb51..5209b17 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,7 +2,7 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0-rc.14" + VersionString = "1.0.0-rc.15" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. @@ -10,5 +10,5 @@ const ( // VersionPatch is the module patch version. VersionPatch = 0 // VersionBeta is the prerelease counter for the current version. - VersionBeta = 14 + VersionBeta = 15 ) -- 2.54.0 From c9ec18cceac2f45dda2b6cca6d0362a9b1c646cc Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 23 Apr 2026 12:12:57 +0300 Subject: [PATCH 75/99] (new): bot opts file loader --- CHANGELOG.md | 4 ++ README.md | 29 ++++++++ README_RU.md | 29 ++++++++ bot.go | 4 -- bot_opts_loader.go | 153 ++++++++++++++++++++++++++++++++++++++++ bot_opts_loader_test.go | 116 ++++++++++++++++++++++++++++++ tgapi/api.go | 1 - tgapi/methods.go | 4 +- 8 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 bot_opts_loader.go create mode 100644 bot_opts_loader_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c260b3..aa97221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,13 @@ ## v1.0.0-rc.15 ### Changed +- Added file-based `BotOpts` loading and saving through `LoadBotOptsFile(...)`, `SaveBotOptsFile(...)`, and the `BotOptsFileCodec` API, with built-in JSON support. - Added plugin-level message fallback handlers for text messages and channel posts that do not match commands. +- Added godoc for the exported `BotOpts` file codec and load/save helpers. +- README, README_RU, and bot-configuration wiki pages now document file-based `BotOpts` loading, built-in JSON support, env placeholder expansion, and custom codec usage including the TOML example. ### Tests +- Added regression coverage for JSON `BotOpts` file codecs, file load/save helpers, decode failures, and env placeholder expansion. - Added regression coverage for plugin message fallback routing, observer lifecycle events, command precedence, and middleware blocking. ## v1.0.0-rc.14 diff --git a/README.md b/README.md index 69b3a1c..199eadd 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,35 @@ func main() { 9. `RunWebHookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling. 10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebHookWithContext()` returns, create a new bot instance for the next session. +## File-Based Config + +`BotOpts` can also be loaded from or saved to config files through the file codec API. + +Built in: +- `BotOptsFileJsonCodec` for JSON files. + +Example: + +```go +codec := laniakea.BotOptsFileJsonCodec{} +opts, err := laniakea.LoadBotOptsFile(codec, "config.json") +if err != nil { + log.Fatal(err) +} + +bot, err := laniakea.NewBot[laniakea.NoData](opts) +if err != nil { + log.Fatal(err) +} +``` + +Placeholders like `{{ TG_TOKEN }}` inside the file are expanded from environment variables before decoding. + +You can also implement your own codec for other formats by satisfying `BotOptsFileCodec`. +Only JSON is supported out of the box right now. If you want another format such as TOML, use `BotOptsFileJsonCodec` as the reference implementation for your own codec. + +See the full guide in the wiki: [Bot Options and Configuration](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration) + ## Webhook Runtime Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`. diff --git a/README_RU.md b/README_RU.md index 16852df..263340e 100644 --- a/README_RU.md +++ b/README_RU.md @@ -121,6 +121,35 @@ func main() { 9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling. 10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот. +## Конфиг из файла + +`BotOpts` можно не только собирать вручную или из environment, но и загружать и сохранять через file codec API. + +Из коробки доступно: +- `BotOptsFileJsonCodec` для JSON-файлов. + +Пример: + +```go +codec := laniakea.BotOptsFileJsonCodec{} +opts, err := laniakea.LoadBotOptsFile(codec, "config.json") +if err != nil { + log.Fatal(err) +} + +bot, err := laniakea.NewBot[laniakea.NoData](opts) +if err != nil { + log.Fatal(err) +} +``` + +Плейсхолдеры вида `{{ TG_TOKEN }}` внутри файла перед декодированием разворачиваются из переменных окружения. + +Для других форматов можно реализовать собственный codec через интерфейс `BotOptsFileCodec`. +Из коробки сейчас поддерживается только JSON. Если нужен другой формат, например TOML, используй `BotOptsFileJsonCodec` как эталонную реализацию собственного codec. + +Подробности есть в wiki: [Bot Options and Configuration RU](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration-RU) + ## Webhook Runtime Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`. diff --git a/bot.go b/bot.go index 86d3a00..184c959 100644 --- a/bot.go +++ b/bot.go @@ -154,10 +154,6 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { updateQueue := make(chan *tgapi.Update, 512) - //var limiter *utils.RateLimiter - //if opts.RateLimit > 0 { - // limiter = utils.NewRateLimiter() - //} limiter := utils.NewRateLimiter() limiter.SetGlobalRate(opts.RateLimit) diff --git a/bot_opts_loader.go b/bot_opts_loader.go new file mode 100644 index 0000000..f06128e --- /dev/null +++ b/bot_opts_loader.go @@ -0,0 +1,153 @@ +package laniakea + +import ( + "encoding/json" + "io" + "os" + "regexp" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +// BotOptsFileJson is the JSON file representation of BotOpts. +type BotOptsFileJson struct { + Token string `json:"token"` + UpdateTypes []tgapi.UpdateType `json:"update_types"` + Debug bool `json:"debug"` + ErrorTemplate string `json:"error_template"` + Prefixes []string `json:"prefixes"` + Logger struct { + LoggerBasePath string `json:"base_path"` + UseRequestLogger bool `json:"use_request_logger"` + WriteToFile bool `json:"write_to_file"` + } `json:"logger"` + API struct { + UseTestServer bool `json:"use_test_server"` + APIUrl string `json:"url"` + RateLimit int `json:"rate_limit"` + DropRLOverflow bool `json:"drop_overflow"` + } `json:"api"` + StrictPayloadType bool `json:"strict_payload_type"` + MaxWorkers int `json:"max_workers"` +} + +// BotOptsFileJsonCodec encodes and decodes BotOpts using BotOptsFileJson. +type BotOptsFileJsonCodec struct{} + +// FromBytes decodes BotOpts from JSON file bytes. +func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { + fileOpts := new(BotOptsFileJson) + err := json.Unmarshal(data, fileOpts) + if err != nil { + return nil, err + } + opts := &BotOpts{ + Token: fileOpts.Token, + UpdateTypes: fileOpts.UpdateTypes, + Debug: fileOpts.Debug, + ErrorTemplate: fileOpts.ErrorTemplate, + Prefixes: fileOpts.Prefixes, + + LoggerBasePath: fileOpts.Logger.LoggerBasePath, + UseRequestLogger: fileOpts.Logger.UseRequestLogger, + WriteToFile: fileOpts.Logger.WriteToFile, + + UseTestServer: fileOpts.API.UseTestServer, + APIUrl: fileOpts.API.APIUrl, + RateLimit: fileOpts.API.RateLimit, + DropRLOverflow: fileOpts.API.DropRLOverflow, + + StrictPayloadType: fileOpts.StrictPayloadType, + MaxWorkers: fileOpts.MaxWorkers, + } + return opts, nil +} + +// ToBytes encodes BotOpts into JSON file bytes. +func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { + fileOpts := &BotOptsFileJson{ + Token: opts.Token, + UpdateTypes: opts.UpdateTypes, + Debug: opts.Debug, + ErrorTemplate: opts.ErrorTemplate, + Prefixes: opts.Prefixes, + + Logger: struct { + LoggerBasePath string `json:"base_path"` + UseRequestLogger bool `json:"use_request_logger"` + WriteToFile bool `json:"write_to_file"` + }{ + LoggerBasePath: opts.LoggerBasePath, + UseRequestLogger: opts.UseRequestLogger, + WriteToFile: opts.WriteToFile, + }, + + API: struct { + UseTestServer bool `json:"use_test_server"` + APIUrl string `json:"url"` + RateLimit int `json:"rate_limit"` + DropRLOverflow bool `json:"drop_overflow"` + }{ + UseTestServer: opts.UseTestServer, + APIUrl: opts.APIUrl, + RateLimit: opts.RateLimit, + DropRLOverflow: opts.DropRLOverflow, + }, + + StrictPayloadType: opts.StrictPayloadType, + MaxWorkers: opts.MaxWorkers, + } + data, err := json.Marshal(fileOpts) + if err != nil { + return nil, err + } + return data, nil +} + +var envParameterRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) + +// BotOptsFileCodec decodes and encodes BotOpts file formats. +type BotOptsFileCodec interface { + FromBytes([]byte) (*BotOpts, error) + ToBytes(*BotOpts) ([]byte, error) +} + +// LoadBotOptsFile reads a config file, expands env placeholders, and decodes BotOpts. +func LoadBotOptsFile(codec BotOptsFileCodec, filename string) (*BotOpts, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + data, err := io.ReadAll(f) + if err != nil { + return nil, err + } + data = expandEnvPlaceholdersInFile(data) + return codec.FromBytes(data) +} + +// SaveBotOptsFile encodes BotOpts with codec and writes the result to filename. +func SaveBotOptsFile(codec BotOptsFileCodec, filename string, opts *BotOpts) error { + data, err := codec.ToBytes(opts) + if err != nil { + return err + } + err = os.WriteFile(filename, data, 0644) + if err != nil { + return err + } + return nil +} + +func expandEnvPlaceholdersInFile(data []byte) []byte { + return envParameterRegex.ReplaceAllFunc(data, func(match []byte) []byte { + group := envParameterRegex.FindSubmatch(match) + if len(group) != 2 { + return match + } + key := group[1] + value := os.Getenv(string(key)) + return []byte(value) + }) +} diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go new file mode 100644 index 0000000..fc76b7b --- /dev/null +++ b/bot_opts_loader_test.go @@ -0,0 +1,116 @@ +package laniakea + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) { + codec := BotOptsFileJsonCodec{} + want := &BotOpts{ + Token: "TOKEN", + UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, + Debug: true, + ErrorTemplate: "Error: %s", + Prefixes: []string{"/", "!"}, + LoggerBasePath: "/tmp/logs", + UseRequestLogger: true, + WriteToFile: true, + UseTestServer: true, + APIUrl: "https://api.example.invalid", + RateLimit: 42, + DropRLOverflow: true, + StrictPayloadType: true, + MaxWorkers: 64, + } + + data, err := codec.ToBytes(want) + if err != nil { + t.Fatalf("ToBytes returned error: %v", err) + } + + got, err := codec.FromBytes(data) + if err != nil { + t.Fatalf("FromBytes returned error: %v", err) + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("round-trip mismatch:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) { + t.Setenv("TG_TOKEN", "TOKEN_FROM_ENV") + t.Setenv("BOT_API_URL", "https://api.example.invalid") + + dir := t.TempDir() + filename := filepath.Join(dir, "config.json") + data := []byte(`{ + "token": "{{ TG_TOKEN }}", + "api": { + "url": "{{BOT_API_URL}}" + }, + "error_template": "Error: %s" + }`) + if err := os.WriteFile(filename, data, 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + if err != nil { + t.Fatalf("LoadBotOptsFile returned error: %v", err) + } + + if got.Token != "TOKEN_FROM_ENV" { + t.Fatalf("unexpected token: got %q want %q", got.Token, "TOKEN_FROM_ENV") + } + if got.APIUrl != "https://api.example.invalid" { + t.Fatalf("unexpected api url: got %q want %q", got.APIUrl, "https://api.example.invalid") + } + if got.ErrorTemplate != "Error: %s" { + t.Fatalf("unexpected error template: got %q", got.ErrorTemplate) + } +} + +func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) { + dir := t.TempDir() + filename := filepath.Join(dir, "config.json") + if err := os.WriteFile(filename, []byte(`{"token":`), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + if _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename); err == nil { + t.Fatal("expected decode error, got nil") + } +} + +func TestSaveBotOptsFileWritesEncodedData(t *testing.T) { + dir := t.TempDir() + filename := filepath.Join(dir, "config.json") + want := &BotOpts{ + Token: "TOKEN", + UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, + ErrorTemplate: "Error: %s", + Prefixes: []string{"/"}, + APIUrl: "https://api.example.invalid", + RateLimit: 30, + MaxWorkers: 32, + } + + if err := SaveBotOptsFile(BotOptsFileJsonCodec{}, filename, want); err != nil { + t.Fatalf("SaveBotOptsFile returned error: %v", err) + } + + got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + if err != nil { + t.Fatalf("LoadBotOptsFile returned error: %v", err) + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("saved file mismatch:\n got: %#v\nwant: %#v", got, want) + } +} diff --git a/tgapi/api.go b/tgapi/api.go index 79ceddd..34c10b8 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -192,7 +192,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro methodPrefix = "/test" } url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method) - req, err := http.NewRequestWithContext(ctx, "POST", url, nil) if err != nil { return zero, fmt.Errorf("failed to create request: %w", err) diff --git a/tgapi/methods.go b/tgapi/methods.go index 37b664b..1d06720 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -21,7 +21,7 @@ type UpdateParams struct { // GetMe returns basic information about the bot. // See https://core.telegram.org/bots/api#getme func (api *API) GetMe() (User, error) { - req := NewRequest[User, EmptyParams]("getMe", NoParams) + req := NewRequest[User]("getMe", NoParams) return req.Do(api) } @@ -29,7 +29,7 @@ func (api *API) GetMe() (User, error) { // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getme func (api *API) GetMeWithContext(ctx context.Context) (User, error) { - req := NewRequest[User, EmptyParams]("getMe", NoParams) + req := NewRequest[User]("getMe", NoParams) return req.DoWithContext(ctx, api) } -- 2.54.0 From d6da95394ca1037a3d7186295d5516768a82c58a Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 23 Apr 2026 12:23:58 +0300 Subject: [PATCH 76/99] (dev): actions --- .gitea/workflows/test.yaml | 19 +++++++++++++++++++ .github/.gitkeep | 0 2 files changed, 19 insertions(+) create mode 100644 .gitea/workflows/test.yaml create mode 100644 .github/.gitkeep diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml new file mode 100644 index 0000000..620fe2f --- /dev/null +++ b/.gitea/workflows/test.yaml @@ -0,0 +1,19 @@ +name: Gitea Actions Demo +run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀 +on: [push] + +jobs: + Explore-Gitea-Actions: + runs-on: arch-latest + steps: + - run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event." + - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!" + - run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}." + - name: Check out repository code + uses: actions/checkout@v4 + - run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner." + - run: echo "🖥️ The workflow is now ready to test your code on the runner." + - name: List files in the repository + run: | + ls ${{ gitea.workspace }} + - run: echo "🍏 This job's status is ${{ job.status }}." diff --git a/.github/.gitkeep b/.github/.gitkeep new file mode 100644 index 0000000..e69de29 -- 2.54.0 From 768dc859d73881351af601be3f8ba27e57b8616f Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 23 Apr 2026 21:50:00 +0300 Subject: [PATCH 77/99] (new): logger replacer for token (fix): scene command routing --- .gitea/workflows/go-lint.yaml | 13 +++++++ .gitea/workflows/test.yaml | 19 ---------- .github/.gitkeep | 0 CHANGELOG.md | 2 ++ bot_opts_loader.go | 9 +++++ bot_utils.go | 4 +-- go.mod | 8 ++--- go.sum | 17 +++++---- scene_handler.go | 4 +++ scene_test.go | 68 +++++++++++++++++++++++++++++++++++ 10 files changed, 110 insertions(+), 34 deletions(-) create mode 100644 .gitea/workflows/go-lint.yaml delete mode 100644 .gitea/workflows/test.yaml delete mode 100644 .github/.gitkeep diff --git a/.gitea/workflows/go-lint.yaml b/.gitea/workflows/go-lint.yaml new file mode 100644 index 0000000..694ced1 --- /dev/null +++ b/.gitea/workflows/go-lint.yaml @@ -0,0 +1,13 @@ +name: Golang lint +run-name: Linting code in ${{ gitea.repository }} by ${{ gitea.actor }} +on: [push] + +jobs: + lint: + runs-on: go-latest + steps: + - name: Check out repository code + uses: actions/checkout@v6 + + - name: Run golangci-lint + run: golangci-lint run \ No newline at end of file diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml deleted file mode 100644 index 620fe2f..0000000 --- a/.gitea/workflows/test.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: Gitea Actions Demo -run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀 -on: [push] - -jobs: - Explore-Gitea-Actions: - runs-on: arch-latest - steps: - - run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event." - - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!" - - run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}." - - name: Check out repository code - uses: actions/checkout@v4 - - run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner." - - run: echo "🖥️ The workflow is now ready to test your code on the runner." - - name: List files in the repository - run: | - ls ${{ gitea.workspace }} - - run: echo "🍏 This job's status is ${{ job.status }}." diff --git a/.github/.gitkeep b/.github/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/CHANGELOG.md b/CHANGELOG.md index aa97221..079d16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,12 @@ - Added plugin-level message fallback handlers for text messages and channel posts that do not match commands. - Added godoc for the exported `BotOpts` file codec and load/save helpers. - README, README_RU, and bot-configuration wiki pages now document file-based `BotOpts` loading, built-in JSON support, env placeholder expansion, and custom codec usage including the TOML example. +- Active scenes now let unmatched slash-commands continue into normal bot command routing instead of also executing the current scene step or scene message fallback. ### Tests - Added regression coverage for JSON `BotOpts` file codecs, file load/save helpers, decode failures, and env placeholder expansion. - Added regression coverage for plugin message fallback routing, observer lifecycle events, command precedence, and middleware blocking. +- Added regression coverage proving unmatched slash-commands do not trigger active scene step handlers before normal bot command routing. ## v1.0.0-rc.14 diff --git a/bot_opts_loader.go b/bot_opts_loader.go index f06128e..7cee524 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -104,12 +104,21 @@ func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { return data, nil } +func (codec BotOptsFileJsonCodec) Load(filename string) (*BotOpts, error) { + return LoadBotOptsFile(codec, filename) +} +func (codec BotOptsFileJsonCodec) Save(filename string, opts *BotOpts) error { + return SaveBotOptsFile(codec, filename, opts) +} + var envParameterRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) // BotOptsFileCodec decodes and encodes BotOpts file formats. type BotOptsFileCodec interface { FromBytes([]byte) (*BotOpts, error) ToBytes(*BotOpts) ([]byte, error) + Load(filename string) (*BotOpts, error) + Save(filename string, opts *BotOpts) error } // LoadBotOptsFile reads a config file, expands env placeholders, and decodes BotOpts. diff --git a/bot_utils.go b/bot_utils.go index 097aa19..6bdb4f9 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -41,7 +41,7 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { level = slog.DEBUG } - bot.logger = utils.CreateLogger("BOT", level) + bot.logger = utils.CreateLogger("BOT", level).AddReplacer(bot.token, "") if opts.WriteToFile { path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) logger, err := utils.CreateFileLogger("BOT", level, path) @@ -53,7 +53,7 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { } if opts.UseRequestLogger { - bot.RequestLogger = utils.CreateLogger("REQUESTS", level) + bot.RequestLogger = utils.CreateLogger("REQUESTS", level).AddReplacer(bot.token, "") if opts.WriteToFile { path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) logger, err := utils.CreateFileLogger("REQUESTS", level, path) diff --git a/go.mod b/go.mod index 0399919..7d1b6e3 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,14 @@ retract v1.0.0-rc.5 require ( git.scuroneko.dev/scuroneko/extypes v1.2.3 - git.scuroneko.dev/scuroneko/slog v1.1.3 + git.scuroneko.dev/scuroneko/slog v1.2.0 github.com/alitto/pond/v2 v2.7.0 golang.org/x/time v0.15.0 ) require ( - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + golang.org/x/sys v0.43.0 // indirect ) diff --git a/go.sum b/go.sum index 6297810..7b7f275 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,16 @@ git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s= git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors= -git.scuroneko.dev/scuroneko/slog v1.1.3 h1:vI4GZykn8gDb6OJ2xq+KLcEk38M7O4e/z1kzpeRHEHw= -git.scuroneko.dev/scuroneko/slog v1.1.3/go.mod h1:gnDap54sfZv3EuSyZd7fjOH46aLbDFpvtN2wgFcWkgE= +git.scuroneko.dev/scuroneko/slog v1.2.0 h1:xbwzrMcmN0NG/zTgEn508mn2JVnfZN5z/Zsi3PREfDM= +git.scuroneko.dev/scuroneko/slog v1.2.0/go.mod h1:r+oz9NzvvdtWd9/PjeS+n5vQoNHL38BdcdLoBtJPvFU= github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/scene_handler.go b/scene_handler.go index 12c6f00..a70b5e5 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -81,6 +81,10 @@ func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error } return ok, err } + + // Unmatched slash-commands should continue through normal bot command routing + // instead of also triggering the active scene step or fallback handler. + return false, nil } ctx.Text = text ctx.Args = nil diff --git a/scene_test.go b/scene_test.go index ea9903b..2fd57e5 100644 --- a/scene_test.go +++ b/scene_test.go @@ -509,6 +509,74 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { } } +func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { + commandCalled := false + stepCalled := false + + plugin := NewPlugin[NoData]("wizard") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { + stepCalled = true + return ctx.Stay(), nil + }) + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + commandCalled = true + return nil + }, "ping") + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + prefixes: []string{"/"}, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, + FromID: 42, + }) + if !ok { + t.Fatal("expected scene key to be built") + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 5, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 10, + Text: "/ping", + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, + From: &tgapi.User{ID: 42}, + }, + }) + + if !commandCalled { + t.Fatal("expected normal command routing to handle /ping") + } + if stepCalled { + t.Fatal("scene step must not run for an unmatched slash-command") + } + + after, err := bot.sessionStore.Get(key) + if err != nil { + t.Fatalf("Get after handle returned error: %v", err) + } + if after.Scene != "signup" || after.Step != "start" { + t.Fatalf("unexpected session after command fallback: %#v", after) + } +} + func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { fallbackCalled := false -- 2.54.0 From e92a0d37f3c41e5753dbce5fc143cb4091006f2c Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 23 Apr 2026 22:24:11 +0300 Subject: [PATCH 78/99] (ci/cd): some style changes in workflow --- .gitea/workflows/go-lint.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/go-lint.yaml b/.gitea/workflows/go-lint.yaml index 694ced1..861257d 100644 --- a/.gitea/workflows/go-lint.yaml +++ b/.gitea/workflows/go-lint.yaml @@ -1,13 +1,12 @@ name: Golang lint -run-name: Linting code in ${{ gitea.repository }} by ${{ gitea.actor }} +run-name: Linting code on: [push] jobs: lint: runs-on: go-latest steps: - - name: Check out repository code + - name: Checkout repository code uses: actions/checkout@v6 - - name: Run golangci-lint run: golangci-lint run \ No newline at end of file -- 2.54.0 From 7d4b150b0b9f40ce8831058a7f8c5b7d9c034ddb Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 23 Apr 2026 22:57:16 +0300 Subject: [PATCH 79/99] (new): bump to rc 16 (fix): token replacer fix --- CHANGELOG.md | 8 ++++ bot.go | 1 + bot_register.go | 5 +- bot_test.go | 119 +++++++++++++++++++++++++++++++++++++++++++++++ bot_utils.go | 41 +++++++++++++++- bot_webhook.go | 1 + utils/version.go | 4 +- 7 files changed, 174 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 079d16c..35f3cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.0.0-rc.16 + +### Changed +- Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, and auto-managed plugin loggers, so bot tokens stay masked in both stdout and file-backed logs. + +### Tests +- Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking as well. + ## v1.0.0-rc.15 ### Changed diff --git a/bot.go b/bot.go index 184c959..f92f8d9 100644 --- a/bot.go +++ b/bot.go @@ -220,6 +220,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { bot.logger.Warn("Can't get bot username. Named command handlers won't work!") } bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown"))) + bot.logger.Debugln("Bot initialized with configuration:", fmt.Sprintf("%+v", opts)) return bot, nil } diff --git a/bot_register.go b/bot_register.go index eb97890..8d046ab 100644 --- a/bot_register.go +++ b/bot_register.go @@ -30,6 +30,7 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { if cloned.logger == nil { cloned.logger = utils.CreateLogger(cloned.name, level) } + bot.addTokenReplacer(cloned.logger) bot.plugins = append(bot.plugins, cloned) if bot.logger != nil { bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name)) @@ -145,7 +146,7 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { if bot.RequestLogger != nil { bot.RequestLogger.AddWriter(w) } - for _, l := range bot.extraLoggers { + for _, l := range bot.managedExtraLoggers() { l.AddWriter(w) } for _, p := range bot.plugins { @@ -153,5 +154,7 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { p.logger.AddWriter(w) } } + bot.addTokenReplacer(bot.logger, bot.RequestLogger) + bot.addTokenReplacer(bot.managedExtraLoggers()...) return bot } diff --git a/bot_test.go b/bot_test.go index e37848c..711bf9f 100644 --- a/bot_test.go +++ b/bot_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/http" + "os" "path/filepath" "reflect" "strings" @@ -136,6 +137,124 @@ func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { } } +func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) { + tempDir := t.TempDir() + api := tgapi.NewAPI(tgapi.NewAPIOpts("secret-token")) + uploader := tgapi.NewUploader(api) + bot := &Bot[NoData]{token: "secret-token", api: api, uploader: uploader} + t.Cleanup(func() { + if err := uploader.Close(); err != nil { + t.Fatalf("failed to close uploader: %v", err) + } + }) + t.Cleanup(func() { + if err := api.Close(); err != nil { + t.Fatalf("failed to close api: %v", err) + } + }) + + bot.initLoggers(&BotOpts{ + Debug: true, + WriteToFile: true, + UseRequestLogger: true, + LoggerBasePath: tempDir, + }) + + apiPath := filepath.Join(tempDir, "api.log") + apiFile, err := os.OpenFile(apiPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + t.Fatalf("failed to open api log: %v", err) + } + defer func() { _ = apiFile.Close() }() + bot.api.GetLogger().AddWriter(bot.api.GetLogger().CreateTextWriter(apiFile)) + + uploaderPath := filepath.Join(tempDir, "uploader.log") + uploaderFile, err := os.OpenFile(uploaderPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + t.Fatalf("failed to open uploader log: %v", err) + } + defer func() { _ = uploaderFile.Close() }() + bot.uploader.GetLogger().AddWriter(bot.uploader.GetLogger().CreateTextWriter(uploaderFile)) + + bot.logger.Infoln("main secret-token") + bot.RequestLogger.Infoln("request secret-token") + bot.api.GetLogger().Infoln("api secret-token") + bot.uploader.GetLogger().Infoln("uploader secret-token") + + if err := bot.RequestLogger.Close(); err != nil { + t.Fatalf("failed to close request logger: %v", err) + } + if err := bot.logger.Close(); err != nil { + t.Fatalf("failed to close main logger: %v", err) + } + + mainLog, err := os.ReadFile(filepath.Join(tempDir, "main.log")) + if err != nil { + t.Fatalf("failed to read main log: %v", err) + } + requestLog, err := os.ReadFile(filepath.Join(tempDir, "requests.log")) + if err != nil { + t.Fatalf("failed to read request log: %v", err) + } + apiLog, err := os.ReadFile(apiPath) + if err != nil { + t.Fatalf("failed to read api log: %v", err) + } + uploaderLog, err := os.ReadFile(uploaderPath) + if err != nil { + t.Fatalf("failed to read uploader log: %v", err) + } + + for _, tt := range []struct { + name string + data string + }{ + {name: "main", data: string(mainLog)}, + {name: "request", data: string(requestLog)}, + {name: "api", data: string(apiLog)}, + {name: "uploader", data: string(uploaderLog)}, + } { + if strings.Contains(tt.data, "secret-token") { + t.Fatalf("%s log leaked raw token: %q", tt.name, tt.data) + } + if !strings.Contains(tt.data, "") { + t.Fatalf("%s log did not contain masked token: %q", tt.name, tt.data) + } + } +} + +func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) { + bot := &Bot[NoData]{ + token: "secret-token", + logger: slog.CreateLogger(), + } + defer func() { _ = bot.logger.Close() }() + + plugin := NewPlugin[NoData]("demo") + bot.AddPlugins(plugin) + + logPath := filepath.Join(t.TempDir(), "plugin.log") + file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + t.Fatalf("failed to open plugin log: %v", err) + } + defer func() { _ = file.Close() }() + + bot.plugins[0].logger.AddWriter(bot.plugins[0].logger.CreateTextWriter(file)) + bot.plugins[0].logger.Infoln("plugin secret-token") + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("failed to read plugin log: %v", err) + } + if strings.Contains(string(data), "secret-token") { + t.Fatalf("plugin log leaked raw token: %q", string(data)) + } + if !strings.Contains(string(data), "") { + t.Fatalf("plugin log did not contain masked token: %q", string(data)) + } +} + func TestNextPollRetryDelay(t *testing.T) { tests := []struct { name string diff --git a/bot_utils.go b/bot_utils.go index 6bdb4f9..843b6cf 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "reflect" + "slices" "strings" "time" @@ -15,6 +16,39 @@ import ( "github.com/alitto/pond/v2" ) +func (bot *Bot[T]) addTokenReplacer(loggers ...*slog.Logger) { + if bot.token == "" { + return + } + for _, logger := range loggers { + if logger == nil { + continue + } + logger.AddReplacer(bot.token, "") + } +} + +func appendUniqueLogger(loggers []*slog.Logger, logger *slog.Logger) []*slog.Logger { + if logger == nil { + return loggers + } + if slices.Contains(loggers, logger) { + return loggers + } + return append(loggers, logger) +} + +func (bot *Bot[T]) managedExtraLoggers() []*slog.Logger { + loggers := append([]*slog.Logger(nil), bot.extraLoggers...) + if bot.api != nil { + loggers = appendUniqueLogger(loggers, bot.api.GetLogger()) + } + if bot.uploader != nil { + loggers = appendUniqueLogger(loggers, bot.uploader.GetLogger()) + } + return loggers +} + func (bot *Bot[T]) enqueueUpdate(ctx context.Context, update tgapi.Update) error { select { case <-ctx.Done(): @@ -41,7 +75,7 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { level = slog.DEBUG } - bot.logger = utils.CreateLogger("BOT", level).AddReplacer(bot.token, "") + bot.logger = utils.CreateLogger("BOT", level) if opts.WriteToFile { path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) logger, err := utils.CreateFileLogger("BOT", level, path) @@ -53,7 +87,7 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { } if opts.UseRequestLogger { - bot.RequestLogger = utils.CreateLogger("REQUESTS", level).AddReplacer(bot.token, "") + bot.RequestLogger = utils.CreateLogger("REQUESTS", level) if opts.WriteToFile { path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) logger, err := utils.CreateFileLogger("REQUESTS", level, path) @@ -64,6 +98,9 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { } } } + + bot.addTokenReplacer(bot.logger, bot.RequestLogger) + bot.addTokenReplacer(bot.managedExtraLoggers()...) } func (bot *Bot[T]) beginRun() error { diff --git a/bot_webhook.go b/bot_webhook.go index 9612ff0..3198dae 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -153,6 +153,7 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp } bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel()) + bot.addTokenReplacer(bot.webHookLogger) if opts.SecretToken == "" { bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") } diff --git a/utils/version.go b/utils/version.go index 5209b17..abd6b55 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,7 +2,7 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0-rc.15" + VersionString = "1.0.0-rc.16" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. @@ -10,5 +10,5 @@ const ( // VersionPatch is the module patch version. VersionPatch = 0 // VersionBeta is the prerelease counter for the current version. - VersionBeta = 15 + VersionBeta = 16 ) -- 2.54.0 From b0882a46d5527474bae4b2fea2127dae326f253d Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 24 Apr 2026 11:34:53 +0300 Subject: [PATCH 80/99] (new): add scene payload routing (fix): stabilize config file versions (tests): cover routing and versions (doc): improve exported godoc --- AGENTS.md | 9 +- CHANGELOG.md | 3 + bot.go | 10 ++ bot_opts.go | 9 +- bot_opts_loader.go | 15 +++ bot_opts_loader_test.go | 37 ++++++-- bot_utils.go | 10 +- observer.go | 2 + scene.go | 16 ++++ scene_handler.go | 35 +++++++ scene_test.go | 198 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 327 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c3eb99..ae5a585 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,9 +123,12 @@ Prefer the repository’s documented commands. If multiple choices exist, use th ## Commit message format - When the user asks for a commit message, the agent must produce it in this format: - 1. a short summary line; - 2. up to three additional lines with only the most important changes; - 3. each additional line must start on its own new line. + 1. one to four short lines; + 2. each line must use the format `(): `; + 3. `` must be a short change type such as `fix`, `new`, `tests`, `doc`, `refactor`, or `ci/cd`; + 4. `` must be a concise 1-5 word description of the change or function; + 5. each line must start on its own new line; + 6. when multiple lines are present, kinds must be ordered from top to bottom by this priority: `new`, `fix`, `refactor`, `ci/cd`, `tests`, `doc`. - The agent must output the commit message as a plain multiline block that the user can copy directly. - Do not collapse the lines into a paragraph, bullet list, or wrapped prose explanation. - Keep commit text concise and high-signal. diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f3cd5..5a735ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,12 @@ ### Changed - Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, and auto-managed plugin loggers, so bot tokens stay masked in both stdout and file-backed logs. +- JSON `BotOpts` files now write `version`, reject newer unsupported config versions, keep older unversioned files loadable, and preserve the loaded file version in `BotOpts.FileConfigVersion`. +- Active scenes now support scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution. ### Tests - Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking as well. +- Added regression coverage for JSON config version handling and scene-local payload routing, including observer lifecycle events and callback fallthrough behavior. ## v1.0.0-rc.15 diff --git a/bot.go b/bot.go index f92f8d9..34b67cc 100644 --- a/bot.go +++ b/bot.go @@ -209,6 +209,16 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { } bot.initLoggers(opts) + if opts.FileConfigVersion > 0 && opts.FileConfigVersion < ConfigVersion { + bot.logger.Warnln( + fmt.Sprintf( + "Config file version %d is older than library version %d; please update your config file to access new features and avoid compatibility issues", + opts.FileConfigVersion, + ConfigVersion, + ), + ) + } + // Fetch bot info to validate token and get username u, err := api.GetMe() if err != nil { diff --git a/bot_opts.go b/bot_opts.go index 83e7413..36bfaab 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -62,6 +62,12 @@ type BotOpts struct { // MaxWorkers is the maximum number of update handlers that may run concurrently. MaxWorkers int + + // FileConfigVersion stores the version declared by the config file used to + // load these options. + // + // It is zero when the options were not loaded from a versioned file. + FileConfigVersion int } // LoadOptsFromEnv loads BotOpts from environment variables. @@ -125,7 +131,8 @@ func LoadOptsFromEnv() *BotOpts { DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", - MaxWorkers: maxWorkers, + MaxWorkers: maxWorkers, + FileConfigVersion: ConfigVersion, } } diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 7cee524..65e9856 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -2,6 +2,7 @@ package laniakea import ( "encoding/json" + "fmt" "io" "os" "regexp" @@ -9,8 +10,16 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) +// ConfigVersion is the current version of the built-in JSON BotOpts file format. +const ConfigVersion = 1 + +// ErrConfigVersionMismatch reports that a config file declares a newer version +// than this library knows how to decode. +var ErrConfigVersionMismatch = fmt.Errorf("config version mismatch: expected %d", ConfigVersion) + // BotOptsFileJson is the JSON file representation of BotOpts. type BotOptsFileJson struct { + Version int `json:"version"` Token string `json:"token"` UpdateTypes []tgapi.UpdateType `json:"update_types"` Debug bool `json:"debug"` @@ -41,6 +50,9 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { if err != nil { return nil, err } + if fileOpts.Version > ConfigVersion { + return nil, ErrConfigVersionMismatch + } opts := &BotOpts{ Token: fileOpts.Token, UpdateTypes: fileOpts.UpdateTypes, @@ -59,6 +71,8 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { StrictPayloadType: fileOpts.StrictPayloadType, MaxWorkers: fileOpts.MaxWorkers, + + FileConfigVersion: fileOpts.Version, } return opts, nil } @@ -66,6 +80,7 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { // ToBytes encodes BotOpts into JSON file bytes. func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { fileOpts := &BotOptsFileJson{ + Version: ConfigVersion, Token: opts.Token, UpdateTypes: opts.UpdateTypes, Debug: opts.Debug, diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go index fc76b7b..09922ce 100644 --- a/bot_opts_loader_test.go +++ b/bot_opts_loader_test.go @@ -1,6 +1,7 @@ package laniakea import ( + "errors" "os" "path/filepath" "reflect" @@ -26,6 +27,7 @@ func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) { DropRLOverflow: true, StrictPayloadType: true, MaxWorkers: 64, + FileConfigVersion: ConfigVersion, } data, err := codec.ToBytes(want) @@ -74,6 +76,9 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) { if got.ErrorTemplate != "Error: %s" { t.Fatalf("unexpected error template: got %q", got.ErrorTemplate) } + if got.FileConfigVersion != 0 { + t.Fatalf("unexpected file config version: got %d want 0", got.FileConfigVersion) + } } func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) { @@ -92,13 +97,14 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) { dir := t.TempDir() filename := filepath.Join(dir, "config.json") want := &BotOpts{ - Token: "TOKEN", - UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, - ErrorTemplate: "Error: %s", - Prefixes: []string{"/"}, - APIUrl: "https://api.example.invalid", - RateLimit: 30, - MaxWorkers: 32, + Token: "TOKEN", + UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, + ErrorTemplate: "Error: %s", + Prefixes: []string{"/"}, + APIUrl: "https://api.example.invalid", + RateLimit: 30, + MaxWorkers: 32, + FileConfigVersion: ConfigVersion, } if err := SaveBotOptsFile(BotOptsFileJsonCodec{}, filename, want); err != nil { @@ -114,3 +120,20 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) { t.Fatalf("saved file mismatch:\n got: %#v\nwant: %#v", got, want) } } + +func TestLoadBotOptsFileRejectsFutureConfigVersion(t *testing.T) { + dir := t.TempDir() + filename := filepath.Join(dir, "config.json") + data := []byte(`{ + "version": 2, + "token": "TOKEN" + }`) + if err := os.WriteFile(filename, data, 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + if !errors.Is(err, ErrConfigVersionMismatch) { + t.Fatalf("expected ErrConfigVersionMismatch, got %v", err) + } +} diff --git a/bot_utils.go b/bot_utils.go index 843b6cf..29fc3b2 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -204,13 +204,11 @@ func cloneScene[T AppData](scene *Scene[T]) *Scene[T] { cloned := *scene cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) + cloned.payloads = make(map[string]SceneHandler[T], len(scene.payloads)) - for name, handler := range scene.steps { - cloned.steps[name] = handler - } - for name, handler := range scene.commands { - cloned.commands[name] = handler - } + maps.Copy(cloned.steps, scene.steps) + maps.Copy(cloned.commands, scene.commands) + maps.Copy(cloned.payloads, scene.payloads) return &cloned } diff --git a/observer.go b/observer.go index d454acc..205ccfb 100644 --- a/observer.go +++ b/observer.go @@ -30,6 +30,8 @@ const ( HandlerSceneStepKind HandlerEventKind = "scene_step" // HandlerSceneCommandKind identifies a scene-local command handler. HandlerSceneCommandKind HandlerEventKind = "scene_command" + // HandlerScenePayloadKind identifies a scene-local callback payload handler. + HandlerScenePayloadKind HandlerEventKind = "scene_payload" // HandlerSceneMessageKind identifies a scene message fallback handler. HandlerSceneMessageKind HandlerEventKind = "scene_message" ) diff --git a/scene.go b/scene.go index 8e4132d..c3ec067 100644 --- a/scene.go +++ b/scene.go @@ -21,6 +21,7 @@ type Scene[T any] struct { steps map[string]SceneHandler[T] commands map[string]SceneHandler[T] + payloads map[string]SceneHandler[T] message SceneHandler[T] } @@ -32,6 +33,7 @@ func NewScene[T any](name string) *Scene[T] { Entry: "", steps: make(map[string]SceneHandler[T]), commands: make(map[string]SceneHandler[T]), + payloads: make(map[string]SceneHandler[T]), message: nil, } } @@ -65,6 +67,12 @@ func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] { return s } +// OnPayload registers a callback payload handler active while the scene is running. +func (s *Scene[T]) OnPayload(cmd string, handler SceneHandler[T]) *Scene[T] { + s.payloads[cmd] = handler + return s +} + // OnMessage registers a fallback handler used when no scene command or step matches. func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] { s.message = handler @@ -79,6 +87,14 @@ func (s *Scene[T]) executeCommand(cmd string, ctx *SceneContext, db T) (SceneRes result, err := handler(ctx, db) return result, true, err } +func (s *Scene[T]) executePayload(cmd string, ctx *SceneContext, db T) (SceneResult, bool, error) { + handler, ok := s.payloads[cmd] + if !ok { + return SceneResult{}, false, nil + } + result, err := handler(ctx, db) + return result, true, err +} func (s *Scene[T]) executeStep(step string, ctx *SceneContext, db T) (SceneResult, bool, error) { handler, ok := s.steps[step] if !ok { diff --git a/scene_handler.go b/scene_handler.go index a70b5e5..161398d 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -86,6 +86,41 @@ func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error // instead of also triggering the active scene step or fallback handler. return false, nil } + + query := ctx.Update.CallbackQuery + if query != nil { + data, err := bot.decodePayload(query.Data) + if err != nil { + return false, err + } + ctx.Args = data.Args + cmd := data.Command + if _, ok := scene.payloads[cmd]; ok { + startTime := time.Now() + bot.emitSceneStarted(ctx, scene, HandlerScenePayloadKind, cmd) + res, _, err := scene.executePayload(cmd, ctx, bot.appData) + if err != nil { + bot.emitSceneFinished(ctx, scene, HandlerScenePayloadKind, cmd, startTime, err) + bot.emitSceneError(ctx, scene, HandlerScenePayloadKind, cmd, err) + return false, err + } + from := ctx.sess.Step + ok, err := bot.applySceneResult(scene, ctx, res) + bot.emitSceneFinished(ctx, scene, HandlerScenePayloadKind, cmd, startTime, err) + if err != nil { + bot.emitSceneError(ctx, scene, HandlerScenePayloadKind, cmd, err) + } + if ok { + bot.emitSceneTransition(ctx, scene, from, res) + } + return ok, err + } + + // Unmatched payloads should not trigger the active scene step or fallback handler. + // This allows using payloads for other bot features like pagination without interfering with active scenes. + return false, nil + } + ctx.Text = text ctx.Args = nil ctx.Prefix = "" diff --git a/scene_test.go b/scene_test.go index 2fd57e5..93d5f04 100644 --- a/scene_test.go +++ b/scene_test.go @@ -430,6 +430,204 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { } } +func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { + payloadCalled := false + stepCalled := false + + plugin := NewPlugin[NoData]("wizard") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { + stepCalled = true + return ctx.Stay(), nil + }). + OnPayload("confirm", func(ctx *SceneContext, db NoData) (SceneResult, error) { + payloadCalled = true + if got, want := ctx.Args, []string{"7", "ok"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("unexpected payload args: got %v want %v", got, want) + } + if ctx.Text != "" { + t.Fatalf("callback flow must not populate Text, got %q", ctx.Text) + } + return ctx.Exit(), nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + data, err := encodeJsonPayload(CallbackData{Command: "confirm", Args: []string{"7", "ok"}}) + if err != nil { + t.Fatalf("encodeJsonPayload returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 25, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-scene", + Data: data, + From: tgapi.User{ID: 42}, + Message: &tgapi.Message{ + MessageID: 12, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, + }, + }, + }) + + if !payloadCalled { + t.Fatal("expected scene payload handler to be called") + } + if stepCalled { + t.Fatal("expected scene payload to short-circuit the active step") + } +} + +func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { + observer := &recordingObserver{} + plugin := NewPlugin[NoData]("wizard") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { + return ctx.Stay(), nil + }). + OnPayload("confirm", func(ctx *SceneContext, db NoData) (SceneResult, error) { + return ctx.Exit(), nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + observer: observer, + } + bot.AddPlugins(plugin) + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + data, err := encodeJsonPayload(CallbackData{Command: "confirm"}) + if err != nil { + t.Fatalf("encodeJsonPayload returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 26, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-scene", + Data: data, + From: tgapi.User{ID: 42}, + Message: &tgapi.Message{ + MessageID: 13, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, + }, + }, + }) + + if len(observer.started) != 1 { + t.Fatalf("expected one scene started event, got %d", len(observer.started)) + } + if got := observer.started[0]; got.HandlerKind != HandlerScenePayloadKind || got.HandlerName != "confirm" || got.Plugin != "wizard" { + t.Fatalf("unexpected scene payload started event: %#v", got) + } + if len(observer.finished) != 1 { + t.Fatalf("expected one scene finished event, got %d", len(observer.finished)) + } + if got := observer.finished[0]; got.HandlerKind != HandlerScenePayloadKind || got.HandlerName != "confirm" || got.Plugin != "wizard" || got.Err != nil { + t.Fatalf("unexpected scene payload finished event: %#v", got) + } +} + +func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { + stepCalled := false + + plugin := NewPlugin[NoData]("wizard") + plugin.NewPayload(func(ctx *MsgContext, db NoData) error { return nil }, "ping") + plugin.NewScene("signup"). + SetEntry("start"). + OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { + stepCalled = true + return ctx.Stay(), nil + }) + + bot := &Bot[NoData]{ + logger: slog.CreateLogger(), + payloadType: BotPayloadJson, + sessionStore: NewMemorySessionStore(), + sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, + } + bot.AddPlugins(plugin) + + enterCtx := &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, + FromID: 42, + sceneRuntime: bot, + } + if err := enterCtx.EnterScene("signup"); err != nil { + t.Fatalf("EnterScene returned error: %v", err) + } + + key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, + FromID: 42, + }) + if !ok { + t.Fatal("expected scene key to be built") + } + + data, err := encodeJsonPayload(CallbackData{Command: "ping"}) + if err != nil { + t.Fatalf("encodeJsonPayload returned error: %v", err) + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 27, + Type: tgapi.UpdateTypeCallbackQuery, + CallbackQuery: &tgapi.CallbackQuery{ + ID: "cb-global", + Data: data, + From: tgapi.User{ID: 42}, + Message: &tgapi.Message{ + MessageID: 14, + Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}, + }, + }, + }) + + if stepCalled { + t.Fatal("scene step must not run for an unmatched payload") + } + + after, err := bot.sessionStore.Get(key) + if err != nil { + t.Fatalf("Get after handle returned error: %v", err) + } + if after.Scene != "signup" || after.Step != "start" { + t.Fatalf("unexpected session after payload fallback: %#v", after) + } +} + func TestScenePassDoesNotPersistSessionData(t *testing.T) { commandCalled := false -- 2.54.0 From 3aee29986906eda97e21111607a75e14753f4696 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 24 Apr 2026 11:37:16 +0300 Subject: [PATCH 81/99] (fix): newline in go-lint workflow --- .gitea/workflows/go-lint.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/go-lint.yaml b/.gitea/workflows/go-lint.yaml index 861257d..5dc1a35 100644 --- a/.gitea/workflows/go-lint.yaml +++ b/.gitea/workflows/go-lint.yaml @@ -9,4 +9,4 @@ jobs: - name: Checkout repository code uses: actions/checkout@v6 - name: Run golangci-lint - run: golangci-lint run \ No newline at end of file + run: golangci-lint run -- 2.54.0 From a34734366d8ba0fd0819af10fc82834cc778516a Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 27 Apr 2026 10:23:54 +0300 Subject: [PATCH 82/99] (new): sneklog v2 --- CHANGELOG.md | 3 ++ bot.go | 81 +++++++++++++++++++++++++++++++++---------- bot_config.go | 10 +++--- bot_register.go | 8 ++--- bot_test.go | 70 +++++++++++++++++++++++++------------ bot_utils.go | 41 ++++++++++++---------- bot_webhook_test.go | 12 +++---- cmd_generator_test.go | 4 +-- drafts_test.go | 4 +-- go.mod | 11 ++---- go.sum | 16 +++------ handler_test.go | 38 ++++++++++---------- methods.go | 4 +-- msg_context.go | 4 +-- msg_context_test.go | 18 +++++----- plugins.go | 6 ++-- policy_test.go | 26 +++++++------- runners_test.go | 8 ++--- scene_test.go | 34 +++++++++--------- tgapi/api.go | 6 ++-- tgapi/uploader_api.go | 6 ++-- utils/utils.go | 14 ++++---- utils/utils_test.go | 6 ++-- 23 files changed, 245 insertions(+), 185 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a735ad..afae59b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,14 @@ ## v1.0.0-rc.16 ### Changed +- Updated `slog` to `v2`. - Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, and auto-managed plugin loggers, so bot tokens stay masked in both stdout and file-backed logs. - JSON `BotOpts` files now write `version`, reject newer unsupported config versions, keep older unversioned files loadable, and preserve the loaded file version in `BotOpts.FileConfigVersion`. - Active scenes now support scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution. ### Tests +- Added regression coverage proving polling startup preserves an enabled request logger. +- Updated file logger regression coverage for the current `sneklog` text prefix format. - Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking as well. - Added regression coverage for JSON config version handling and scene-local payload routing, including observer lifecycle events and callback fallthrough behavior. diff --git a/bot.go b/bot.go index 34b67cc..7d2083b 100644 --- a/bot.go +++ b/bot.go @@ -10,7 +10,7 @@ import ( "git.scuroneko.dev/scuroneko/extypes" "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/utils" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) // AppData is the generic shared application data type injected into bots, @@ -38,11 +38,11 @@ type AppData any // Use Bot[NoData] to indicate no shared dependency injection is required. type NoData struct{ AppData } -// AppDataLogger builds a slog.LoggerWriter from injected application data. +// AppDataLogger builds a sneklog.LoggerWriter from injected application data. // // Use it when shared application data exposes a log sink or adapter that should // receive framework logs. -type AppDataLogger[T AppData] func(data T) slog.LoggerWriter +type AppDataLogger[T AppData] func(data T) sneklog.LoggerWriter // BotPayloadType defines the serialization format for callback data payloads. type BotPayloadType string @@ -90,10 +90,11 @@ type Bot[T AppData] struct { strictPayloadType bool maxWorkers int - logger *slog.Logger // Main bot logger (JSON stdout + optional file) - RequestLogger *slog.Logger // Optional request-level API logging - webHookLogger *slog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext. - extraLoggers extypes.Slice[*slog.Logger] // API, Uploader, and custom loggers + logger *sneklog.Logger // Main bot logger (JSON stdout + optional file) + requestLogger *sneklog.Logger // Optional request-level API logging + useReqLogger bool + webHookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext. + extraLoggers extypes.Slice[*sneklog.Logger] // API, Uploader, and custom loggers plugins []Plugin[T] // Command/event handlers middlewares []Middleware[T] // Pre-processing filters (sorted by order) @@ -187,12 +188,14 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { debug: opts.Debug, prefixes: prefixes, token: opts.Token, - plugins: make([]Plugin[T], 0), - updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...), - runners: make([]Runner[T], 0), - extraLoggers: make([]*slog.Logger, 0), - l10n: &L10n{}, - draftProvider: NewRandomDraftProvider(api), + useReqLogger: opts.UseRequestLogger, + + plugins: make([]Plugin[T], 0), + updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...), + runners: make([]Runner[T], 0), + extraLoggers: make([]*sneklog.Logger, 0), + l10n: &L10n{}, + draftProvider: NewRandomDraftProvider(api), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -235,6 +238,24 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { return bot, nil } +// SetLogger replaces the main bot logger. +func (b *Bot[T]) SetLogger(l *sneklog.Logger) *Bot[T] { + b.logger = l + return b +} + +// SetRequestLogger replaces the request-level logger. +func (b *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] { + b.requestLogger = l + return b +} + +// SetWebHookLogger replaces the webhook logger. +func (b *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { + b.webHookLogger = l + return b +} + // Close gracefully shuts down bot-owned resources. // // Close shuts down, in order: @@ -283,8 +304,8 @@ func (bot *Bot[T]) Close() error { logCloseErr(err) } } - if bot.RequestLogger != nil { - if err := bot.RequestLogger.Close(); err != nil { + if bot.requestLogger != nil { + if err := bot.requestLogger.Close(); err != nil { logCloseErr(err) } } @@ -322,14 +343,20 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) { } // GetLogger returns the main bot logger. -func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } +func (bot *Bot[T]) GetLogger() *sneklog.Logger { return bot.logger } + +// GetRequestLogger returns the request-level logger, if configured. +func (bot *Bot[T]) GetRequestLogger() *sneklog.Logger { return bot.requestLogger } + +// GetWebHookLogger returns the webhook logger, if configured. +func (bot *Bot[T]) GetWebHookLogger() *sneklog.Logger { return bot.webHookLogger } // GetLoggerLevel returns the effective log level derived from the bot's debug // flag. -func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel { - level := slog.FATAL +func (bot *Bot[T]) GetLoggerLevel() sneklog.LogLevel { + level := sneklog.FATAL if bot.debug { - level = slog.DEBUG + level = sneklog.DEBUG } return level } @@ -373,6 +400,22 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { return err } defer bot.finishRun() + if !bot.useReqLogger && bot.requestLogger != nil { + bot.logger.Warnln("Opts#UseRequestLogger is false, but Bot#requestLogger present. Remove Bot#SetRequestLogger or set Opts#UseRequestLogger to true!") + err := bot.requestLogger.Close() + if err != nil { + bot.logger.Errorln(err) + } + bot.requestLogger = nil + } + if bot.webHookLogger != nil { + bot.logger.Warnln("Bot#webHookLogger present. You shouldn't set this, if ran in Long Polling mode!") + err := bot.webHookLogger.Close() + if err != nil { + bot.logger.Errorln(err) + } + bot.webHookLogger = nil + } bot.ExecRunners(ctx) diff --git a/bot_config.go b/bot_config.go index 851915c..ad179b5 100644 --- a/bot_config.go +++ b/bot_config.go @@ -5,7 +5,7 @@ import ( "slices" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) // AddPrefixes adds one or more command prefixes (e.g., "/", "!"). @@ -187,14 +187,14 @@ func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] { // SetDebug enables or disables debug logging. func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { bot.debug = debug - level := slog.FATAL + level := sneklog.FATAL if debug { - level = slog.DEBUG + level = sneklog.DEBUG } bot.logger.Level(level) - if bot.RequestLogger != nil { - bot.RequestLogger.Level(level) + if bot.requestLogger != nil { + bot.requestLogger.Level(level) } for _, p := range bot.plugins { if p.logger == nil { diff --git a/bot_register.go b/bot_register.go index 8d046ab..c71fbb1 100644 --- a/bot_register.go +++ b/bot_register.go @@ -129,7 +129,7 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { // // Example: // -// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter { +// bot.AddAppDataLoggerWriter(func(data *MyAppData) sneklog.LoggerWriter { // return data.QueryLogger() // }) func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { @@ -143,8 +143,8 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { } w := writer(bot.appData) bot.logger.AddWriter(w) - if bot.RequestLogger != nil { - bot.RequestLogger.AddWriter(w) + if bot.requestLogger != nil { + bot.requestLogger.AddWriter(w) } for _, l := range bot.managedExtraLoggers() { l.AddWriter(w) @@ -154,7 +154,7 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { p.logger.AddWriter(w) } } - bot.addTokenReplacer(bot.logger, bot.RequestLogger) + bot.addTokenReplacer(bot.logger, bot.requestLogger) bot.addTokenReplacer(bot.managedExtraLoggers()...) return bot } diff --git a/bot_test.go b/bot_test.go index 711bf9f..1551a8e 100644 --- a/bot_test.go +++ b/bot_test.go @@ -13,7 +13,7 @@ import ( "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) type pollingRoundTripFunc func(*http.Request) (*http.Response, error) @@ -59,7 +59,7 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) { } func TestAddPluginsSnapshotsConfiguration(t *testing.T) { - bot := &Bot[NoData]{logger: slog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.CreateLogger()} plugin := NewPlugin[NoData]("demo") cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start") @@ -100,7 +100,7 @@ func TestBotPayloadTypeConfiguration(t *testing.T) { } func TestAddPluginsSkipsNilPlugin(t *testing.T) { - bot := &Bot[NoData]{logger: slog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.CreateLogger()} plugin := NewPlugin[NoData]("demo") bot.AddPlugins(nil, plugin) @@ -126,10 +126,10 @@ func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) { if bot.logger == nil { t.Fatal("expected main logger fallback") } - if bot.RequestLogger == nil { + if bot.requestLogger == nil { t.Fatal("expected request logger fallback") } - if err := bot.RequestLogger.Close(); err != nil { + if err := bot.requestLogger.Close(); err != nil { t.Fatalf("failed to close request logger: %v", err) } if err := bot.logger.Close(); err != nil { @@ -177,11 +177,11 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) { bot.uploader.GetLogger().AddWriter(bot.uploader.GetLogger().CreateTextWriter(uploaderFile)) bot.logger.Infoln("main secret-token") - bot.RequestLogger.Infoln("request secret-token") + bot.requestLogger.Infoln("request secret-token") bot.api.GetLogger().Infoln("api secret-token") bot.uploader.GetLogger().Infoln("uploader secret-token") - if err := bot.RequestLogger.Close(); err != nil { + if err := bot.requestLogger.Close(); err != nil { t.Fatalf("failed to close request logger: %v", err) } if err := bot.logger.Close(); err != nil { @@ -226,7 +226,7 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) { func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) { bot := &Bot[NoData]{ token: "secret-token", - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), } defer func() { _ = bot.logger.Close() }() @@ -276,10 +276,10 @@ func TestNextPollRetryDelay(t *testing.T) { } func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) { - bot := &Bot[NoData]{logger: slog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.CreateLogger()} called := false - bot.AddAppDataLoggerWriter(func(db NoData) slog.LoggerWriter { + bot.AddAppDataLoggerWriter(func(db NoData) sneklog.LoggerWriter { called = true return nil }) @@ -292,12 +292,12 @@ func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) { func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) { type testDB struct{} - bot := &Bot[*testDB]{logger: slog.CreateLogger()} + bot := &Bot[*testDB]{logger: sneklog.CreateLogger()} var db *testDB bot.SetAppData(db) called := false - bot.AddAppDataLoggerWriter(func(db *testDB) slog.LoggerWriter { + bot.AddAppDataLoggerWriter(func(db *testDB) sneklog.LoggerWriter { called = true return nil }) @@ -336,13 +336,13 @@ func TestShouldWarnOnValueAppData(t *testing.T) { func TestSetAppDataMarksValueWarningOnce(t *testing.T) { type testDB struct{} - bot := &Bot[testDB]{logger: slog.CreateLogger()} + bot := &Bot[testDB]{logger: sneklog.CreateLogger()} bot.SetAppData(testDB{}) if !bot.warnedValueData { t.Fatal("expected value-typed app data to mark warning state") } - ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()} + ptrBot := &Bot[*testDB]{logger: sneklog.CreateLogger()} ptrBot.SetAppData(&testDB{}) if ptrBot.warnedValueData { t.Fatal("did not expect pointer-typed app data to mark warning state") @@ -350,7 +350,7 @@ func TestSetAppDataMarksValueWarningOnce(t *testing.T) { } func TestSetObserverAndGetObserver(t *testing.T) { - bot := &Bot[NoData]{logger: slog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.CreateLogger()} observer := testObserver{} if got := bot.GetObserver(); got != nil { @@ -364,7 +364,7 @@ func TestSetObserverAndGetObserver(t *testing.T) { } func TestSetObserverNilClearsObserver(t *testing.T) { - bot := &Bot[NoData]{logger: slog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.CreateLogger()} bot.SetObserver(testObserver{}) if bot.GetObserver() == nil { @@ -382,7 +382,7 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) { cancel() bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, updateQueue: make(chan *tgapi.Update, 1), @@ -397,6 +397,32 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) { } } +func TestRunWithContextKeepsEnabledRequestLogger(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + requestLogger := sneklog.CreateLogger() + bot := &Bot[NoData]{ + logger: sneklog.CreateLogger(), + requestLogger: requestLogger, + useReqLogger: true, + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + } + t.Cleanup(func() { + _ = bot.Close() + }) + + if err := bot.RunWithContext(ctx); err != nil { + t.Fatalf("RunWithContext returned error: %v", err) + } + if got := bot.GetRequestLogger(); got != requestLogger { + t.Fatalf("expected enabled request logger to be preserved, got %#v", got) + } +} + func TestCloseDoesNotDeleteWebhook(t *testing.T) { requests := 0 client := &http.Client{ @@ -418,8 +444,8 @@ func TestCloseDoesNotDeleteWebhook(t *testing.T) { uploader := tgapi.NewUploader(api) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), - webHookLogger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), + webHookLogger: sneklog.CreateLogger(), api: api, uploader: uploader, } @@ -457,7 +483,7 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { }() bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), api: api, prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, @@ -489,7 +515,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { makeBot := func() *Bot[*testDB] { return &Bot[*testDB]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, payloadType: BotPayloadBase64, @@ -681,7 +707,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })}, runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })}, diff --git a/bot_utils.go b/bot_utils.go index 29fc3b2..340db54 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -12,11 +12,12 @@ import ( "git.scuroneko.dev/scuroneko/extypes" "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/utils" - "git.scuroneko.dev/scuroneko/slog" + + "git.scuroneko.dev/scuroneko/sneklog/v2" "github.com/alitto/pond/v2" ) -func (bot *Bot[T]) addTokenReplacer(loggers ...*slog.Logger) { +func (bot *Bot[T]) addTokenReplacer(loggers ...*sneklog.Logger) { if bot.token == "" { return } @@ -28,7 +29,7 @@ func (bot *Bot[T]) addTokenReplacer(loggers ...*slog.Logger) { } } -func appendUniqueLogger(loggers []*slog.Logger, logger *slog.Logger) []*slog.Logger { +func appendUniqueLogger(loggers []*sneklog.Logger, logger *sneklog.Logger) []*sneklog.Logger { if logger == nil { return loggers } @@ -38,8 +39,8 @@ func appendUniqueLogger(loggers []*slog.Logger, logger *slog.Logger) []*slog.Log return append(loggers, logger) } -func (bot *Bot[T]) managedExtraLoggers() []*slog.Logger { - loggers := append([]*slog.Logger(nil), bot.extraLoggers...) +func (bot *Bot[T]) managedExtraLoggers() []*sneklog.Logger { + loggers := append([]*sneklog.Logger(nil), bot.extraLoggers...) if bot.api != nil { loggers = appendUniqueLogger(loggers, bot.api.GetLogger()) } @@ -70,36 +71,38 @@ func (bot *Bot[T]) startUpdateWorkers(ctx context.Context) { } func (bot *Bot[T]) initLoggers(opts *BotOpts) { - level := slog.FATAL + level := sneklog.FATAL if opts.Debug { - level = slog.DEBUG + level = sneklog.DEBUG } - bot.logger = utils.CreateLogger("BOT", level) - if opts.WriteToFile { - path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("BOT", level, path) - if err != nil { - bot.logger.Errorln(err) - } else { - bot.logger = logger + if bot.logger == nil { + bot.logger = utils.CreateLogger("BOT", level) + if opts.WriteToFile { + path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) + logger, err := utils.CreateFileLogger("BOT", level, path) + if err != nil { + bot.logger.Errorln(err) + } else { + bot.logger = logger + } } } - if opts.UseRequestLogger { - bot.RequestLogger = utils.CreateLogger("REQUESTS", level) + if opts.UseRequestLogger && bot.requestLogger == nil { + bot.requestLogger = utils.CreateLogger("REQUESTS", level) if opts.WriteToFile { path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) logger, err := utils.CreateFileLogger("REQUESTS", level, path) if err != nil { bot.logger.Errorln(err) } else { - bot.RequestLogger = logger + bot.requestLogger = logger } } } - bot.addTokenReplacer(bot.logger, bot.RequestLogger) + bot.addTokenReplacer(bot.logger, bot.requestLogger) bot.addTokenReplacer(bot.managedExtraLoggers()...) } diff --git a/bot_webhook_test.go b/bot_webhook_test.go index e58d06c..328df04 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -11,7 +11,7 @@ import ( "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) func TestEnqueueUpdateCopiesValue(t *testing.T) { @@ -35,7 +35,7 @@ func TestEnqueueUpdateCopiesValue(t *testing.T) { func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: slog.CreateLogger(), + webHookLogger: sneklog.CreateLogger(), } t.Cleanup(func() { _ = bot.webHookLogger.Close() @@ -66,7 +66,7 @@ func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) { bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), updateQueue: make(chan *tgapi.Update, 1), maxWorkers: 1, } @@ -86,7 +86,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { var calls atomic.Int32 bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), updateQueue: make(chan *tgapi.Update, 1), maxWorkers: 1, runners: []Runner[NoData]{ @@ -185,7 +185,7 @@ func TestValidateWebhookTLSFiles(t *testing.T) { func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: slog.CreateLogger(), + webHookLogger: sneklog.CreateLogger(), } t.Cleanup(func() { _ = bot.webHookLogger.Close() @@ -222,7 +222,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { bot := &Bot[NoData]{ api: api, - webHookLogger: slog.CreateLogger(), + webHookLogger: sneklog.CreateLogger(), } t.Cleanup(func() { _ = bot.webHookLogger.Close() diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 064b2b0..56157df 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -11,7 +11,7 @@ import ( "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) type roundTripFunc func(*http.Request) (*http.Response, error) @@ -51,7 +51,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { bot := &Bot[NoData]{ api: api, - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), plugins: []Plugin[NoData]{*plugin}, } diff --git a/drafts_test.go b/drafts_test.go index 757aa16..230414b 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -6,7 +6,7 @@ import ( "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) func TestDraftFlushRequiresChatID(t *testing.T) { @@ -24,7 +24,7 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), draftProvider: NewRandomDraftProvider(&tgapi.API{}), } diff --git a/go.mod b/go.mod index 7d1b6e3..48585f1 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,7 @@ retract v1.0.0-rc.5 require ( git.scuroneko.dev/scuroneko/extypes v1.2.3 - git.scuroneko.dev/scuroneko/slog v1.2.0 - github.com/alitto/pond/v2 v2.7.0 + git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1 + github.com/alitto/pond/v2 v2.7.1 golang.org/x/time v0.15.0 ) - -require ( - github.com/fatih/color v1.19.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.21 // indirect - golang.org/x/sys v0.43.0 // indirect -) diff --git a/go.sum b/go.sum index 7b7f275..8e3936a 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,8 @@ git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s= git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors= -git.scuroneko.dev/scuroneko/slog v1.2.0 h1:xbwzrMcmN0NG/zTgEn508mn2JVnfZN5z/Zsi3PREfDM= -git.scuroneko.dev/scuroneko/slog v1.2.0/go.mod h1:r+oz9NzvvdtWd9/PjeS+n5vQoNHL38BdcdLoBtJPvFU= -github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= -github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1 h1:nDGLsvxbYoSxSk9wrGCmb+tn5jPM+KYlBX+3ciyC2Yo= +git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1/go.mod h1:q8XnLXzLdGjW0Jtcbh9/+G9WmfD68rsPQvLXEPxvum4= +github.com/alitto/pond/v2 v2.7.1 h1:QxMbcfjcVTa0pyxX5Ib1226mM8u8D7gKUVkCUU4DYIw= +github.com/alitto/pond/v2 v2.7.1/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/handler_test.go b/handler_test.go index 2f1fdcf..47406dc 100644 --- a/handler_test.go +++ b/handler_test.go @@ -6,7 +6,7 @@ import ( "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) type recordingObserver struct { @@ -55,7 +55,7 @@ func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { } func TestBotMiddlewareReceivesLogger(t *testing.T) { - logger := slog.CreateLogger() + logger := sneklog.CreateLogger() called := false bot := &Bot[NoData]{ @@ -468,7 +468,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -514,7 +514,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), plugins: []Plugin[NoData]{ clonePlugin(first), clonePlugin(second), @@ -543,7 +543,7 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), plugins: []Plugin[NoData]{clonePlugin(plugin)}, observer: observer, } @@ -607,7 +607,7 @@ func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, observer: observer, } @@ -658,7 +658,7 @@ func TestHandleMessageFallbackRunsForPlainText(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, } bot.AddPlugins(plugin) @@ -691,7 +691,7 @@ func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, } bot.AddPlugins(plugin) @@ -726,7 +726,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, } bot.AddPlugins(plugin) @@ -771,7 +771,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { }, "ping") bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -808,7 +808,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { ) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -845,7 +845,7 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { ) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), payloadType: BotPayloadJson, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -898,7 +898,7 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -940,7 +940,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -993,7 +993,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { }, "approve") bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), payloadType: BotPayloadJson, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -1052,7 +1052,7 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { }, "inline.approve") bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), payloadType: BotPayloadJson, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -1086,7 +1086,7 @@ func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) { }, "approve") bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), payloadType: BotPayloadJson, plugins: []Plugin[NoData]{clonePlugin(plugin)}, observer: observer, @@ -1137,7 +1137,7 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { }, "approve") bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), payloadType: BotPayloadJson, plugins: []Plugin[NoData]{clonePlugin(plugin)}, observer: observer, @@ -1182,7 +1182,7 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { observer := &recordingObserver{} bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), payloadType: BotPayloadJson, observer: observer, } diff --git a/methods.go b/methods.go index 4d4ac04..b72802b 100644 --- a/methods.go +++ b/methods.go @@ -53,13 +53,13 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { return nil, err } - if bot.RequestLogger != nil { + if bot.requestLogger != nil { for _, u := range updates { j, err := json.Marshal(u) if err != nil { bot.GetLogger().Error(err) } - bot.RequestLogger.Debugf("UPDATE %s\n", j) + bot.requestLogger.Debugf("UPDATE %s\n", j) } } if len(updates) > 0 { diff --git a/msg_context.go b/msg_context.go index 8f8f8e7..bf8ce89 100644 --- a/msg_context.go +++ b/msg_context.go @@ -11,7 +11,7 @@ import ( "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) // MsgContext holds the normalized per-update context passed to command, payload, @@ -46,7 +46,7 @@ type MsgContext struct { // 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 + Logger *sneklog.Logger // InlineMsgId is the inline message identifier for callback queries that target // an inline message instead of a chat message. diff --git a/msg_context_test.go b/msg_context_test.go index 78c67e0..a6dee1e 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -10,7 +10,7 @@ import ( "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { @@ -50,7 +50,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, }, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), } answer := ctx.AnswerPhoto("photo-id", "caption") @@ -202,7 +202,7 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { ctx := &MsgContext{ Api: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), errorTemplate: "Error: %s", } @@ -238,7 +238,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { ctx := &MsgContext{ Api: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), errorTemplate: "Error: %s", } @@ -266,7 +266,7 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) { ctx := &MsgContext{ Api: api, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), errorTemplate: "%s", CallbackQueryId: "cb-1", } @@ -309,7 +309,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { ctx := &MsgContext{ Api: api, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), errorTemplate: "Oops: %s", CallbackQueryId: "cb-1", } @@ -327,7 +327,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { func TestAnswerRejectsEmptyMessage(t *testing.T) { ctx := &MsgContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), } if answer := ctx.Answer(""); answer != nil { @@ -357,7 +357,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { ctx := &MsgContext{ Api: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), } if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil { @@ -441,7 +441,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { ctx := &MsgContext{ Api: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), } kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd") text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32) diff --git a/plugins.go b/plugins.go index 014322b..6190bf4 100644 --- a/plugins.go +++ b/plugins.go @@ -7,7 +7,7 @@ import ( "git.scuroneko.dev/scuroneko/extypes" "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/utils" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) // CommandValueType defines the expected type of command argument. @@ -169,7 +169,7 @@ type Plugin[T AppData] struct { scenes map[string]*Scene[T] // Optional scenes for multi-step interactions middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help - logger *slog.Logger + logger *sneklog.Logger messageFallback CommandExecutor[T] handlers map[tgapi.UpdateType]CommandExecutor[T] @@ -293,7 +293,7 @@ func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] { // // Call this before Bot.AddPlugins. If the plugin is already registered, changing // the original *Plugin does not update the Bot's internal copy. -func (p *Plugin[T]) SetLogger(l *slog.Logger) *Plugin[T] { +func (p *Plugin[T]) SetLogger(l *sneklog.Logger) *Plugin[T] { p.logger = l return p } diff --git a/policy_test.go b/policy_test.go index 1952c44..d0925d4 100644 --- a/policy_test.go +++ b/policy_test.go @@ -10,7 +10,7 @@ import ( "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { @@ -49,7 +49,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { ctx := &MsgContext{ Api: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), errorTemplate: "Error: %s", } @@ -73,7 +73,7 @@ func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) { Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), } if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil { @@ -86,7 +86,7 @@ func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) { Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup}, }, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), } err := RequirePrivateChat[NoData]()(ctx, NoData{}) @@ -140,7 +140,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { Api: api, ChatID: -2001, FromID: 55, - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), } if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil { @@ -168,7 +168,7 @@ func TestAllPoliciesReturnsFirstError(t *testing.T) { }, ) - err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) if !errors.Is(err, want) { t.Fatalf("expected first policy error, got %v", err) } @@ -180,7 +180,7 @@ func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { func(ctx *MsgContext, data NoData) error { return nil }, ) - if err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil { + if err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}); err != nil { t.Fatalf("expected later success to allow access, got %v", err) } } @@ -192,7 +192,7 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { func(ctx *MsgContext, data NoData) error { return internal }, ) - err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) if !errors.Is(err, internal) { t.Fatalf("expected internal error, got %v", err) } @@ -205,7 +205,7 @@ func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) { func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) }, ) - err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) if !errors.Is(err, first) { t.Fatalf("expected first deny error, got %v", err) } @@ -215,7 +215,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { inverted := NotPolicy(func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) }) - if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil { + if err := inverted(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}); err != nil { t.Fatalf("expected inverted deny to succeed, got %v", err) } @@ -223,7 +223,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { preserve := NotPolicy(func(ctx *MsgContext, data NoData) error { return internal }) - err := preserve(&MsgContext{Logger: slog.CreateLogger()}, NoData{}) + err := preserve(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) if !errors.Is(err, internal) { t.Fatalf("expected internal error to be preserved, got %v", err) } @@ -233,7 +233,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { t.Run("allow", func(t *testing.T) { observer := &recordingObserver{} ctx := &MsgContext{ - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), ctx: context.Background(), observer: observer, FromID: 10, @@ -258,7 +258,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { t.Run("deny", func(t *testing.T) { observer := &recordingObserver{} ctx := &MsgContext{ - Logger: slog.CreateLogger(), + Logger: sneklog.CreateLogger(), ctx: context.Background(), observer: observer, errorTemplate: "%s", diff --git a/runners_test.go b/runners_test.go index cbeefd4..351150a 100644 --- a/runners_test.go +++ b/runners_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) type runnerObserver struct { @@ -17,7 +17,7 @@ type runnerObserver struct { func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { var calls atomic.Int32 bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { calls.Add(1) @@ -39,7 +39,7 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), runners: []Runner[NoData]{ NewRunner("background", func(*Bot[NoData]) error { if calls.Add(1) == 1 { @@ -71,7 +71,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) { wantErr := errors.New("runner failed") bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), observer: observer, runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { diff --git a/scene_test.go b/scene_test.go index 93d5f04..a465386 100644 --- a/scene_test.go +++ b/scene_test.go @@ -6,7 +6,7 @@ import ( "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) type failingSessionStore struct { @@ -56,7 +56,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -149,7 +149,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { plugin.NewScene("signup") bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -172,7 +172,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { plugin.NewScene("signup").SetEntry("start") bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -231,7 +231,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -279,7 +279,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -331,7 +331,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -386,7 +386,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -453,7 +453,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), payloadType: BotPayloadJson, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -509,7 +509,7 @@ func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), payloadType: BotPayloadJson, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -572,7 +572,7 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), payloadType: BotPayloadJson, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -648,7 +648,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -724,7 +724,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { }, "ping") bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -793,7 +793,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -838,7 +838,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat}, } @@ -865,7 +865,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { t.Run("find scene session get error", func(t *testing.T) { bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), sessionStore: failingSessionStore{getErr: getErr}, sceneScopePriority: []SceneScope{SceneScopeUser}, } @@ -881,7 +881,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { return ctx.Stay(), nil }) bot := &Bot[NoData]{ - logger: slog.CreateLogger(), + logger: sneklog.CreateLogger(), sessionStore: failingSessionStore{setErr: setErr}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } diff --git a/tgapi/api.go b/tgapi/api.go index 34c10b8..9235ce4 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -10,7 +10,7 @@ import ( "time" "git.scuroneko.dev/scuroneko/laniakea/utils" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) // APIOpts holds configuration options for initializing the Telegram API client. @@ -85,7 +85,7 @@ func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts { type API struct { token string client *http.Client - logger *slog.Logger + logger *sneklog.Logger useTestServer bool apiUrl string @@ -137,7 +137,7 @@ func (api *API) Close() error { // GetLogger returns the internal logger for custom logging. // See https://core.telegram.org/bots/api -func (api *API) GetLogger() *slog.Logger { +func (api *API) GetLogger() *sneklog.Logger { return api.logger } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 9f812c1..04074d0 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -11,7 +11,7 @@ import ( "time" "git.scuroneko.dev/scuroneko/laniakea/utils" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) const ( @@ -65,7 +65,7 @@ func (f UploaderFile) SetType(t UploaderFileType) UploaderFile { // (InputFile/multipart). For JSON-only calls (file_id, URL, plain params), use API. type Uploader struct { api *API - logger *slog.Logger + logger *sneklog.Logger } // NewUploader creates a multipart uploader bound to an API client. @@ -85,7 +85,7 @@ func (u *Uploader) Close() error { return u.logger.Close() } // GetLogger returns uploader logger instance. // See https://core.telegram.org/bots/api -func (u *Uploader) GetLogger() *slog.Logger { return u.logger } +func (u *Uploader) GetLogger() *sneklog.Logger { return u.logger } // UploaderRequest is a low-level multipart upload request wrapper. // diff --git a/utils/utils.go b/utils/utils.go index c665508..d9a554e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -3,22 +3,22 @@ package utils import ( "os" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) // GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL. -func GetLoggerLevel() slog.LogLevel { - level := slog.FATAL +func GetLoggerLevel() sneklog.LogLevel { + level := sneklog.FATAL if os.Getenv("DEBUG") == "true" { - level = slog.DEBUG + level = sneklog.DEBUG } return level } // CreateLogger creates a logger with the shared default policy: // JSON stdout output, provided prefix, and provided level. -func CreateLogger(prefix string, level slog.LogLevel) *slog.Logger { - logger := slog.CreateLogger().Level(level) +func CreateLogger(prefix string, level sneklog.LogLevel) *sneklog.Logger { + logger := sneklog.CreateLogger().Level(level) if prefix != "" { logger.Prefix(prefix) } @@ -31,7 +31,7 @@ func CreateLogger(prefix string, level slog.LogLevel) *slog.Logger { // // The returned logger is always non-nil. When file writer creation fails, the // logger still writes to stdout and the error is returned to the caller. -func CreateFileLogger(prefix string, level slog.LogLevel, filePath string) (*slog.Logger, error) { +func CreateFileLogger(prefix string, level sneklog.LogLevel, filePath string) (*sneklog.Logger, error) { logger := CreateLogger(prefix, level) fileWriter, err := logger.CreateTextFileWriter(filePath) if err != nil { diff --git a/utils/utils_test.go b/utils/utils_test.go index 16e9a05..c69eacc 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -6,13 +6,13 @@ import ( "strings" "testing" - "git.scuroneko.dev/scuroneko/slog" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) { logPath := filepath.Join(t.TempDir(), "main.log") - logger, err := CreateFileLogger("TEST", slog.DEBUG, logPath) + logger, err := CreateFileLogger("TEST", sneklog.DEBUG, logPath) if err != nil { t.Fatalf("CreateFileLogger returned error: %v", err) } @@ -28,7 +28,7 @@ func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) { if !strings.Contains(string(data), "hello from file logger") { t.Fatalf("expected log message in file, got %q", string(data)) } - if !strings.Contains(string(data), "[TEST]") { + if !strings.Contains(string(data), "TEST:") { t.Fatalf("expected prefix in file, got %q", string(data)) } } -- 2.54.0 From fc4386df751ea17cd624df2eb3f338d342e3e1e1 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 28 Apr 2026 16:18:14 +0300 Subject: [PATCH 83/99] (new): log formatter options (refactor): API initialism names (tests): logger format coverage (doc): updated public names --- CHANGELOG.md | 2 + README.md | 14 ++--- README_RU.md | 14 ++--- bot.go | 32 ++++++----- bot_config.go | 8 +-- bot_opts.go | 30 +++++++--- bot_opts_loader.go | 85 +++++++++++++-------------- bot_opts_loader_test.go | 22 +++---- bot_register.go | 10 ++-- bot_test.go | 50 ++++++++-------- bot_utils.go | 9 +-- bot_webhook.go | 2 +- bot_webhook_test.go | 12 ++-- cmd_generator_test.go | 4 +- drafts.go | 28 ++++----- drafts_test.go | 6 +- go.mod | 2 +- go.sum | 4 +- handler.go | 24 ++++---- handler_test.go | 106 +++++++++++++++++----------------- keyboard.go | 52 ++++++++--------- keyboard_test.go | 14 ++--- msg_context.go | 114 ++++++++++++++++++------------------- msg_context_test.go | 50 ++++++++-------- plugins.go | 2 +- policy.go | 8 +-- policy_test.go | 32 +++++------ runners_test.go | 6 +- scene_handler.go | 4 +- scene_test.go | 56 +++++++++--------- tgapi/api.go | 74 +++++++++++++++--------- tgapi/api_test.go | 4 +- tgapi/errors.go | 3 - tgapi/messages_methods.go | 2 +- tgapi/messages_types.go | 10 ++-- tgapi/methods.go | 2 +- tgapi/methods_test.go | 10 ++-- tgapi/uploader_api.go | 25 ++++---- tgapi/uploader_api_test.go | 2 +- update_context.go | 6 +- utils/utils.go | 64 +++++++++++++++++---- utils/utils_test.go | 2 +- 42 files changed, 544 insertions(+), 462 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afae59b..6f4cd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,14 @@ - Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, and auto-managed plugin loggers, so bot tokens stay masked in both stdout and file-backed logs. - JSON `BotOpts` files now write `version`, reject newer unsupported config versions, keep older unversioned files loadable, and preserve the loaded file version in `BotOpts.FileConfigVersion`. - Active scenes now support scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution. +- Updated Go initialism names for JSON, URL, ID, and API helpers. ### Tests - Added regression coverage proving polling startup preserves an enabled request logger. - Updated file logger regression coverage for the current `sneklog` text prefix format. - Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking as well. - Added regression coverage for JSON config version handling and scene-local payload routing, including observer lifecycle events and callback fallthrough behavior. +- Updated logger helper tests for the explicit log format and formatter parameters. ## v1.0.0-rc.15 diff --git a/README.md b/README.md index 199eadd..09a8cd6 100644 --- a/README.md +++ b/README.md @@ -125,12 +125,12 @@ func main() { `BotOpts` can also be loaded from or saved to config files through the file codec API. Built in: -- `BotOptsFileJsonCodec` for JSON files. +- `BotOptsFileJSONCodec` for JSON files. Example: ```go -codec := laniakea.BotOptsFileJsonCodec{} +codec := laniakea.BotOptsFileJSONCodec{} opts, err := laniakea.LoadBotOptsFile(codec, "config.json") if err != nil { log.Fatal(err) @@ -145,7 +145,7 @@ if err != nil { Placeholders like `{{ TG_TOKEN }}` inside the file are expanded from environment variables before decoding. You can also implement your own codec for other formats by satisfying `BotOptsFileCodec`. -Only JSON is supported out of the box right now. If you want another format such as TOML, use `BotOptsFileJsonCodec` as the reference implementation for your own codec. +Only JSON is supported out of the box right now. If you want another format such as TOML, use `BotOptsFileJSONCodec` as the reference implementation for your own codec. See the full guide in the wiki: [Bot Options and Configuration](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration) @@ -198,12 +198,12 @@ Provides access to the incoming message and useful reply methods: - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard. - `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. -- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. -- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). +- `AnswerPhoto(photoID, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. +- `AnswerPhotoMarkdown(photoID, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). - `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button. - `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button. - `SendAction(action tgapi.ChatActionType)`: Sends a “typing”, “uploading photo”, etc., action. -- Fields: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId`, etc. +- Fields: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgID`, `CallbackQueryID`, etc. - And more methods and fields! ### tgapi: API and Uploader @@ -316,7 +316,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. ## ⚙️ Advanced Configuration -- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard. +- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJSON`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard. - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Localization**: `L10n` is safe for concurrent use once attached to the bot. - **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow. diff --git a/README_RU.md b/README_RU.md index 263340e..a9595f5 100644 --- a/README_RU.md +++ b/README_RU.md @@ -126,12 +126,12 @@ func main() { `BotOpts` можно не только собирать вручную или из environment, но и загружать и сохранять через file codec API. Из коробки доступно: -- `BotOptsFileJsonCodec` для JSON-файлов. +- `BotOptsFileJSONCodec` для JSON-файлов. Пример: ```go -codec := laniakea.BotOptsFileJsonCodec{} +codec := laniakea.BotOptsFileJSONCodec{} opts, err := laniakea.LoadBotOptsFile(codec, "config.json") if err != nil { log.Fatal(err) @@ -146,7 +146,7 @@ if err != nil { Плейсхолдеры вида `{{ TG_TOKEN }}` внутри файла перед декодированием разворачиваются из переменных окружения. Для других форматов можно реализовать собственный codec через интерфейс `BotOptsFileCodec`. -Из коробки сейчас поддерживается только JSON. Если нужен другой формат, например TOML, используй `BotOptsFileJsonCodec` как эталонную реализацию собственного codec. +Из коробки сейчас поддерживается только JSON. Если нужен другой формат, например TOML, используй `BotOptsFileJSONCodec` как эталонную реализацию собственного codec. Подробности есть в wiki: [Bot Options and Configuration RU](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration-RU) @@ -198,12 +198,12 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) error { - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой. - `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. -- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. -- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). +- `AnswerPhoto(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. +- `AnswerPhotoMarkdown(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). - `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки. - `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки. - `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д. -- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие. +- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgID`, `CallbackQueryID` и другие. - И много других методов и полей! ### App Data @@ -313,7 +313,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. ## ⚙️ Расширенная настройка -- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры. +- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJSON`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры. - **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. - **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. diff --git a/bot.go b/bot.go index 7d2083b..afc9176 100644 --- a/bot.go +++ b/bot.go @@ -50,8 +50,8 @@ type BotPayloadType string var ( // BotPayloadBase64 encodes callback data as a Base64 string. BotPayloadBase64 BotPayloadType = "base64" - // BotPayloadJson encodes callback data as a JSON string. - BotPayloadJson BotPayloadType = "json" + // BotPayloadJSON encodes callback data as a JSON string. + BotPayloadJSON BotPayloadType = "json" ) var ( @@ -90,6 +90,8 @@ type Bot[T AppData] struct { strictPayloadType bool maxWorkers int + logFormat utils.LogFormat + logFormatter *sneklog.Formatter logger *sneklog.Logger // Main bot logger (JSON stdout + optional file) requestLogger *sneklog.Logger // Optional request-level API logging useReqLogger bool @@ -159,10 +161,12 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { limiter.SetGlobalRate(opts.RateLimit) apiOpts := tgapi.NewAPIOpts(opts.Token). - SetAPIUrl(opts.APIUrl). + SetAPIURL(opts.APIURL). UseTestServer(opts.UseTestServer). SetLimiter(limiter). - SetLimiterDrop(opts.DropRLOverflow) + SetLimiterDrop(opts.DropRLOverflow). + SetLogFormat(opts.LogFormat). + SetLogFormatter(opts.LogFormatter) api := tgapi.NewAPI(apiOpts) uploader := tgapi.NewUploader(api) @@ -188,6 +192,8 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { debug: opts.Debug, prefixes: prefixes, token: opts.Token, + logFormat: opts.LogFormat, + logFormatter: opts.LogFormatter, useReqLogger: opts.UseRequestLogger, plugins: make([]Plugin[T], 0), @@ -239,21 +245,21 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { } // SetLogger replaces the main bot logger. -func (b *Bot[T]) SetLogger(l *sneklog.Logger) *Bot[T] { - b.logger = l - return b +func (bot *Bot[T]) SetLogger(l *sneklog.Logger) *Bot[T] { + bot.logger = l + return bot } // SetRequestLogger replaces the request-level logger. -func (b *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] { - b.requestLogger = l - return b +func (bot *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] { + bot.requestLogger = l + return bot } // SetWebHookLogger replaces the webhook logger. -func (b *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { - b.webHookLogger = l - return b +func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { + bot.webHookLogger = l + return bot } // Close gracefully shuts down bot-owned resources. diff --git a/bot_config.go b/bot_config.go index ad179b5..e7752ad 100644 --- a/bot_config.go +++ b/bot_config.go @@ -19,7 +19,7 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { } // SetDraftProvider replaces the default DraftProvider with a custom one. -// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts. +// Useful for using LinearDraftIDGenerator to persist draft IDs across restarts. func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { if !bot.configMutable("SetDraftProvider") { return bot @@ -192,15 +192,15 @@ func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { level = sneklog.DEBUG } - bot.logger.Level(level) + bot.logger.SetLevel(level) if bot.requestLogger != nil { - bot.requestLogger.Level(level) + bot.requestLogger.SetLevel(level) } for _, p := range bot.plugins { if p.logger == nil { continue } - p.logger.Level(level) + p.logger.SetLevel(level) } return bot } diff --git a/bot_opts.go b/bot_opts.go index 36bfaab..d03e1e7 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -6,6 +6,8 @@ import ( "strings" "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/utils" + "git.scuroneko.dev/scuroneko/sneklog/v2" ) // BotOpts holds configuration options for initializing a Bot. @@ -45,8 +47,8 @@ type BotOpts struct { // UseTestServer uses Telegram's test server (https://api.test.telegram.org). UseTestServer bool - // APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted). - APIUrl string + // APIURL overrides the default Telegram API endpoint (useful for proxies or self-hosted). + APIURL string // RateLimit is the maximum number of API requests per second. // Telegram allows up to 30 req/s for most bots. Defaults to 30. @@ -68,6 +70,9 @@ type BotOpts struct { // // It is zero when the options were not loaded from a versioned file. FileConfigVersion int + + LogFormat utils.LogFormat + LogFormatter *sneklog.Formatter } // LoadOptsFromEnv loads BotOpts from environment variables. @@ -87,6 +92,7 @@ type BotOpts struct { // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow // - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format // - MAX_WORKERS: maximum number of concurrent update handlers (default: 32) +// - JSON_LOG: // // Returns a populated BotOpts. // NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing. @@ -125,14 +131,15 @@ func LoadOptsFromEnv() *BotOpts { WriteToFile: os.Getenv("WRITE_TO_FILE") == "true", UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", - APIUrl: os.Getenv("API_URL"), + APIURL: os.Getenv("API_URL"), RateLimit: rateLimit, DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", MaxWorkers: maxWorkers, - FileConfigVersion: ConfigVersion, + FileConfigVersion: 0, + LogFormat: utils.LogFormat(os.Getenv("LOG_FORMAT")), } } @@ -200,10 +207,10 @@ func (opts *BotOpts) SetUseTestServer(use bool) *BotOpts { return opts } -// SetAPIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted). +// SetAPIURL overrides the default Telegram API endpoint (useful for proxies or self-hosted). // If not set, defaults to "https://api.telegram.org". -func (opts *BotOpts) SetAPIUrl(url string) *BotOpts { - opts.APIUrl = url +func (opts *BotOpts) SetAPIURL(url string) *BotOpts { + opts.APIURL = url return opts } @@ -247,6 +254,15 @@ func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts { return opts } +func (opts *BotOpts) SetLogFormat(format utils.LogFormat) *BotOpts { + opts.LogFormat = format + return opts +} +func (opts *BotOpts) SetLogFormatter(formatter *sneklog.Formatter) *BotOpts { + opts.LogFormatter = formatter + return opts +} + // LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon. // Defaults to ["/"] if not set. func LoadPrefixesFromEnv() []string { diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 65e9856..99f4ba7 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -8,6 +8,7 @@ import ( "regexp" "git.scuroneko.dev/scuroneko/laniakea/tgapi" + "git.scuroneko.dev/scuroneko/laniakea/utils" ) // ConfigVersion is the current version of the built-in JSON BotOpts file format. @@ -17,35 +18,39 @@ const ConfigVersion = 1 // than this library knows how to decode. var ErrConfigVersionMismatch = fmt.Errorf("config version mismatch: expected %d", ConfigVersion) -// BotOptsFileJson is the JSON file representation of BotOpts. -type BotOptsFileJson struct { - Version int `json:"version"` - Token string `json:"token"` - UpdateTypes []tgapi.UpdateType `json:"update_types"` - Debug bool `json:"debug"` - ErrorTemplate string `json:"error_template"` - Prefixes []string `json:"prefixes"` - Logger struct { - LoggerBasePath string `json:"base_path"` - UseRequestLogger bool `json:"use_request_logger"` - WriteToFile bool `json:"write_to_file"` - } `json:"logger"` - API struct { - UseTestServer bool `json:"use_test_server"` - APIUrl string `json:"url"` - RateLimit int `json:"rate_limit"` - DropRLOverflow bool `json:"drop_overflow"` - } `json:"api"` - StrictPayloadType bool `json:"strict_payload_type"` - MaxWorkers int `json:"max_workers"` +type botOptsFileJSONLogger struct { + LoggerBasePath string `json:"base_path"` + UseRequestLogger bool `json:"use_request_logger"` + WriteToFile bool `json:"write_to_file"` + LogFormat utils.LogFormat `json:"log_format"` +} +type botOptsFileJSONAPI struct { + UseTestServer bool `json:"use_test_server"` + APIURL string `json:"url"` + RateLimit int `json:"rate_limit"` + DropRLOverflow bool `json:"drop_overflow"` } -// BotOptsFileJsonCodec encodes and decodes BotOpts using BotOptsFileJson. -type BotOptsFileJsonCodec struct{} +// BotOptsFileJSON is the JSON file representation of BotOpts. +type BotOptsFileJSON struct { + Version int `json:"version"` + Token string `json:"token"` + UpdateTypes []tgapi.UpdateType `json:"update_types"` + Debug bool `json:"debug"` + ErrorTemplate string `json:"error_template"` + Prefixes []string `json:"prefixes"` + Logger botOptsFileJSONLogger `json:"logger"` + API botOptsFileJSONAPI `json:"api"` + StrictPayloadType bool `json:"strict_payload_type"` + MaxWorkers int `json:"max_workers"` +} + +// BotOptsFileJSONCodec encodes and decodes BotOpts using BotOptsFileJSON. +type BotOptsFileJSONCodec struct{} // FromBytes decodes BotOpts from JSON file bytes. -func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { - fileOpts := new(BotOptsFileJson) +func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) { + fileOpts := new(BotOptsFileJSON) err := json.Unmarshal(data, fileOpts) if err != nil { return nil, err @@ -63,9 +68,10 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { LoggerBasePath: fileOpts.Logger.LoggerBasePath, UseRequestLogger: fileOpts.Logger.UseRequestLogger, WriteToFile: fileOpts.Logger.WriteToFile, + LogFormat: fileOpts.Logger.LogFormat, UseTestServer: fileOpts.API.UseTestServer, - APIUrl: fileOpts.API.APIUrl, + APIURL: fileOpts.API.APIURL, RateLimit: fileOpts.API.RateLimit, DropRLOverflow: fileOpts.API.DropRLOverflow, @@ -78,37 +84,26 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { } // ToBytes encodes BotOpts into JSON file bytes. -func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { - fileOpts := &BotOptsFileJson{ +func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) { + fileOpts := &BotOptsFileJSON{ Version: ConfigVersion, Token: opts.Token, UpdateTypes: opts.UpdateTypes, Debug: opts.Debug, ErrorTemplate: opts.ErrorTemplate, Prefixes: opts.Prefixes, - - Logger: struct { - LoggerBasePath string `json:"base_path"` - UseRequestLogger bool `json:"use_request_logger"` - WriteToFile bool `json:"write_to_file"` - }{ + Logger: botOptsFileJSONLogger{ LoggerBasePath: opts.LoggerBasePath, UseRequestLogger: opts.UseRequestLogger, WriteToFile: opts.WriteToFile, + LogFormat: opts.LogFormat, }, - - API: struct { - UseTestServer bool `json:"use_test_server"` - APIUrl string `json:"url"` - RateLimit int `json:"rate_limit"` - DropRLOverflow bool `json:"drop_overflow"` - }{ + API: botOptsFileJSONAPI{ UseTestServer: opts.UseTestServer, - APIUrl: opts.APIUrl, + APIURL: opts.APIURL, RateLimit: opts.RateLimit, DropRLOverflow: opts.DropRLOverflow, }, - StrictPayloadType: opts.StrictPayloadType, MaxWorkers: opts.MaxWorkers, } @@ -119,10 +114,10 @@ func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { return data, nil } -func (codec BotOptsFileJsonCodec) Load(filename string) (*BotOpts, error) { +func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) { return LoadBotOptsFile(codec, filename) } -func (codec BotOptsFileJsonCodec) Save(filename string, opts *BotOpts) error { +func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error { return SaveBotOptsFile(codec, filename, opts) } diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go index 09922ce..bb8c82d 100644 --- a/bot_opts_loader_test.go +++ b/bot_opts_loader_test.go @@ -10,8 +10,8 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) { - codec := BotOptsFileJsonCodec{} +func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) { + codec := BotOptsFileJSONCodec{} want := &BotOpts{ Token: "TOKEN", UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, @@ -22,7 +22,7 @@ func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) { UseRequestLogger: true, WriteToFile: true, UseTestServer: true, - APIUrl: "https://api.example.invalid", + APIURL: "https://api.example.invalid", RateLimit: 42, DropRLOverflow: true, StrictPayloadType: true, @@ -62,7 +62,7 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) { t.Fatalf("WriteFile returned error: %v", err) } - got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename) if err != nil { t.Fatalf("LoadBotOptsFile returned error: %v", err) } @@ -70,8 +70,8 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) { if got.Token != "TOKEN_FROM_ENV" { t.Fatalf("unexpected token: got %q want %q", got.Token, "TOKEN_FROM_ENV") } - if got.APIUrl != "https://api.example.invalid" { - t.Fatalf("unexpected api url: got %q want %q", got.APIUrl, "https://api.example.invalid") + if got.APIURL != "https://api.example.invalid" { + t.Fatalf("unexpected api url: got %q want %q", got.APIURL, "https://api.example.invalid") } if got.ErrorTemplate != "Error: %s" { t.Fatalf("unexpected error template: got %q", got.ErrorTemplate) @@ -88,7 +88,7 @@ func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) { t.Fatalf("WriteFile returned error: %v", err) } - if _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename); err == nil { + if _, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename); err == nil { t.Fatal("expected decode error, got nil") } } @@ -101,17 +101,17 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) { UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, ErrorTemplate: "Error: %s", Prefixes: []string{"/"}, - APIUrl: "https://api.example.invalid", + APIURL: "https://api.example.invalid", RateLimit: 30, MaxWorkers: 32, FileConfigVersion: ConfigVersion, } - if err := SaveBotOptsFile(BotOptsFileJsonCodec{}, filename, want); err != nil { + if err := SaveBotOptsFile(BotOptsFileJSONCodec{}, filename, want); err != nil { t.Fatalf("SaveBotOptsFile returned error: %v", err) } - got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename) if err != nil { t.Fatalf("LoadBotOptsFile returned error: %v", err) } @@ -132,7 +132,7 @@ func TestLoadBotOptsFileRejectsFutureConfigVersion(t *testing.T) { t.Fatalf("WriteFile returned error: %v", err) } - _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + _, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename) if !errors.Is(err, ErrConfigVersionMismatch) { t.Fatalf("expected ErrConfigVersionMismatch, got %v", err) } diff --git a/bot_register.go b/bot_register.go index c71fbb1..f12c250 100644 --- a/bot_register.go +++ b/bot_register.go @@ -28,7 +28,7 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { } cloned := clonePlugin(p) if cloned.logger == nil { - cloned.logger = utils.CreateLogger(cloned.name, level) + cloned.logger = utils.CreateLogger(cloned.name, level, bot.logFormat, bot.logFormatter) } bot.addTokenReplacer(cloned.logger) bot.plugins = append(bot.plugins, cloned) @@ -142,16 +142,16 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { return bot } w := writer(bot.appData) - bot.logger.AddWriter(w) + bot.logger.AddWriters(w) if bot.requestLogger != nil { - bot.requestLogger.AddWriter(w) + bot.requestLogger.AddWriters(w) } for _, l := range bot.managedExtraLoggers() { - l.AddWriter(w) + l.AddWriters(w) } for _, p := range bot.plugins { if p.logger != nil { - p.logger.AddWriter(w) + p.logger.AddWriters(w) } } bot.addTokenReplacer(bot.logger, bot.requestLogger) diff --git a/bot_test.go b/bot_test.go index 1551a8e..7104985 100644 --- a/bot_test.go +++ b/bot_test.go @@ -59,7 +59,7 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) { } func TestAddPluginsSnapshotsConfiguration(t *testing.T) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} plugin := NewPlugin[NoData]("demo") cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start") @@ -89,8 +89,8 @@ func TestBotPayloadTypeConfiguration(t *testing.T) { if got := bot.GetPayloadType(); got != BotPayloadBase64 { t.Fatalf("unexpected initial payload type: %q", got) } - bot.SetPayloadType(BotPayloadJson) - if got := bot.GetPayloadType(); got != BotPayloadJson { + bot.SetPayloadType(BotPayloadJSON) + if got := bot.GetPayloadType(); got != BotPayloadJSON { t.Fatalf("unexpected updated payload type: %q", got) } bot.SetStrictPayloadType(true) @@ -100,7 +100,7 @@ func TestBotPayloadTypeConfiguration(t *testing.T) { } func TestAddPluginsSkipsNilPlugin(t *testing.T) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} plugin := NewPlugin[NoData]("demo") bot.AddPlugins(nil, plugin) @@ -166,7 +166,7 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) { t.Fatalf("failed to open api log: %v", err) } defer func() { _ = apiFile.Close() }() - bot.api.GetLogger().AddWriter(bot.api.GetLogger().CreateTextWriter(apiFile)) + bot.api.GetLogger().AddWriters(bot.api.GetLogger().CreateTextWriter(apiFile)) uploaderPath := filepath.Join(tempDir, "uploader.log") uploaderFile, err := os.OpenFile(uploaderPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) @@ -174,7 +174,7 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) { t.Fatalf("failed to open uploader log: %v", err) } defer func() { _ = uploaderFile.Close() }() - bot.uploader.GetLogger().AddWriter(bot.uploader.GetLogger().CreateTextWriter(uploaderFile)) + bot.uploader.GetLogger().AddWriters(bot.uploader.GetLogger().CreateTextWriter(uploaderFile)) bot.logger.Infoln("main secret-token") bot.requestLogger.Infoln("request secret-token") @@ -226,7 +226,7 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) { func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) { bot := &Bot[NoData]{ token: "secret-token", - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), } defer func() { _ = bot.logger.Close() }() @@ -240,7 +240,7 @@ func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) { } defer func() { _ = file.Close() }() - bot.plugins[0].logger.AddWriter(bot.plugins[0].logger.CreateTextWriter(file)) + bot.plugins[0].logger.AddWriters(bot.plugins[0].logger.CreateTextWriter(file)) bot.plugins[0].logger.Infoln("plugin secret-token") data, err := os.ReadFile(logPath) @@ -276,7 +276,7 @@ func TestNextPollRetryDelay(t *testing.T) { } func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} called := false bot.AddAppDataLoggerWriter(func(db NoData) sneklog.LoggerWriter { @@ -292,7 +292,7 @@ func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) { func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) { type testDB struct{} - bot := &Bot[*testDB]{logger: sneklog.CreateLogger()} + bot := &Bot[*testDB]{logger: sneklog.NewLogger()} var db *testDB bot.SetAppData(db) @@ -336,13 +336,13 @@ func TestShouldWarnOnValueAppData(t *testing.T) { func TestSetAppDataMarksValueWarningOnce(t *testing.T) { type testDB struct{} - bot := &Bot[testDB]{logger: sneklog.CreateLogger()} + bot := &Bot[testDB]{logger: sneklog.NewLogger()} bot.SetAppData(testDB{}) if !bot.warnedValueData { t.Fatal("expected value-typed app data to mark warning state") } - ptrBot := &Bot[*testDB]{logger: sneklog.CreateLogger()} + ptrBot := &Bot[*testDB]{logger: sneklog.NewLogger()} ptrBot.SetAppData(&testDB{}) if ptrBot.warnedValueData { t.Fatal("did not expect pointer-typed app data to mark warning state") @@ -350,7 +350,7 @@ func TestSetAppDataMarksValueWarningOnce(t *testing.T) { } func TestSetObserverAndGetObserver(t *testing.T) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} observer := testObserver{} if got := bot.GetObserver(); got != nil { @@ -364,7 +364,7 @@ func TestSetObserverAndGetObserver(t *testing.T) { } func TestSetObserverNilClearsObserver(t *testing.T) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} bot.SetObserver(testObserver{}) if bot.GetObserver() == nil { @@ -382,7 +382,7 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) { cancel() bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, updateQueue: make(chan *tgapi.Update, 1), @@ -401,9 +401,9 @@ func TestRunWithContextKeepsEnabledRequestLogger(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - requestLogger := sneklog.CreateLogger() + requestLogger := sneklog.NewLogger() bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), requestLogger: requestLogger, useReqLogger: true, prefixes: []string{"/"}, @@ -438,14 +438,14 @@ func TestCloseDoesNotDeleteWebhook(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("http://example.invalid"). + SetAPIURL("http://example.invalid"). SetHTTPClient(client), ) uploader := tgapi.NewUploader(api) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - webHookLogger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), + webHookLogger: sneklog.NewLogger(), api: api, uploader: uploader, } @@ -475,7 +475,7 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("http://example.invalid"). + SetAPIURL("http://example.invalid"). SetHTTPClient(client), ) defer func() { @@ -483,7 +483,7 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { }() bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), api: api, prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, @@ -515,7 +515,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { makeBot := func() *Bot[*testDB] { return &Bot[*testDB]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, payloadType: BotPayloadBase64, @@ -587,7 +587,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { } t.Cleanup(bot.finishRun) - bot.SetPayloadType(BotPayloadJson) + bot.SetPayloadType(BotPayloadJSON) if bot.payloadType != BotPayloadBase64 { t.Fatalf("payloadType mutated after configuration freeze: got %q want %q", bot.payloadType, BotPayloadBase64) } @@ -707,7 +707,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })}, runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })}, diff --git a/bot_utils.go b/bot_utils.go index 340db54..90b2c86 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -76,11 +76,12 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { level = sneklog.DEBUG } + format, formatter := opts.LogFormat, opts.LogFormatter if bot.logger == nil { - bot.logger = utils.CreateLogger("BOT", level) + bot.logger = utils.CreateLogger("BOT", level, format, formatter) if opts.WriteToFile { path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("BOT", level, path) + logger, err := utils.CreateFileLogger("BOT", level, path, format, formatter) if err != nil { bot.logger.Errorln(err) } else { @@ -90,10 +91,10 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { } if opts.UseRequestLogger && bot.requestLogger == nil { - bot.requestLogger = utils.CreateLogger("REQUESTS", level) + bot.requestLogger = utils.CreateLogger("REQUESTS", level, format, formatter) if opts.WriteToFile { path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) - logger, err := utils.CreateFileLogger("REQUESTS", level, path) + logger, err := utils.CreateFileLogger("REQUESTS", level, path, format, formatter) if err != nil { bot.logger.Errorln(err) } else { diff --git a/bot_webhook.go b/bot_webhook.go index 3198dae..72ea5cb 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -152,7 +152,7 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp return err } - bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel()) + bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter) bot.addTokenReplacer(bot.webHookLogger) if opts.SecretToken == "" { bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 328df04..00c95e4 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -35,7 +35,7 @@ func TestEnqueueUpdateCopiesValue(t *testing.T) { func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: sneklog.CreateLogger(), + webHookLogger: sneklog.NewLogger(), } t.Cleanup(func() { _ = bot.webHookLogger.Close() @@ -66,7 +66,7 @@ func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) { bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), updateQueue: make(chan *tgapi.Update, 1), maxWorkers: 1, } @@ -86,7 +86,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { var calls atomic.Int32 bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), updateQueue: make(chan *tgapi.Update, 1), maxWorkers: 1, runners: []Runner[NoData]{ @@ -185,7 +185,7 @@ func TestValidateWebhookTLSFiles(t *testing.T) { func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: sneklog.CreateLogger(), + webHookLogger: sneklog.NewLogger(), } t.Cleanup(func() { _ = bot.webHookLogger.Close() @@ -213,7 +213,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { } api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("http://example.invalid"). + SetAPIURL("http://example.invalid"). SetHTTPClient(client), ) defer func() { @@ -222,7 +222,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { bot := &Bot[NoData]{ api: api, - webHookLogger: sneklog.CreateLogger(), + webHookLogger: sneklog.NewLogger(), } t.Cleanup(func() { _ = bot.webHookLogger.Close() diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 56157df..ee6bc7d 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -34,7 +34,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { } api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -51,7 +51,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { bot := &Bot[NoData]{ api: api, - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{*plugin}, } diff --git a/drafts.go b/drafts.go index 033957a..c8968c4 100644 --- a/drafts.go +++ b/drafts.go @@ -9,29 +9,29 @@ import ( ) // Interface for generating unique draft IDs. -type draftIdGenerator interface { +type draftIDGenerator interface { // Next returns the next unique draft ID. Next() uint64 } -// RandomDraftIdGenerator generates draft IDs using cryptographically secure random numbers. +// RandomDraftIDGenerator generates draft IDs using cryptographically secure random numbers. // Suitable for distributed systems or when ID predictability is undesirable. -type RandomDraftIdGenerator struct{} +type RandomDraftIDGenerator struct{} // Next returns a random 64-bit unsigned integer. -func (g *RandomDraftIdGenerator) Next() uint64 { +func (g *RandomDraftIDGenerator) Next() uint64 { return rand.Uint64() } -// LinearDraftIdGenerator generates draft IDs using a monotonically increasing counter. +// LinearDraftIDGenerator generates draft IDs using a monotonically increasing counter. // Useful for debugging, persistence, or when drafts must be ordered. -type LinearDraftIdGenerator struct { - lastId atomic.Uint64 +type LinearDraftIDGenerator struct { + lastID atomic.Uint64 } -// Next returns the next linear ID, atomically incremented. -func (g *LinearDraftIdGenerator) Next() uint64 { - return g.lastId.Add(1) +// Next returns the next linear ID, atomically incremented.о +func (g *LinearDraftIDGenerator) Next() uint64 { + return g.lastID.Add(1) } // DraftProvider manages a collection of Drafts and a shared draft ID generator. @@ -41,7 +41,7 @@ type DraftProvider struct { mu sync.RWMutex api *tgapi.API drafts map[uint64]*Draft - generator draftIdGenerator + generator draftIDGenerator } // NewRandomDraftProvider creates a new DraftProvider using random draft IDs. @@ -50,7 +50,7 @@ type DraftProvider struct { // All drafts created via this provider will have unpredictable, unique IDs. func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { return &DraftProvider{ - api: api, generator: &RandomDraftIdGenerator{}, + api: api, generator: &RandomDraftIDGenerator{}, drafts: make(map[uint64]*Draft), } } @@ -63,8 +63,8 @@ func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { // This is useful when you need to store draft IDs externally (e.g., in a database) // and want to reconstruct drafts after restart. func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { - g := &LinearDraftIdGenerator{} - g.lastId.Store(startValue) + g := &LinearDraftIDGenerator{} + g.lastID.Store(startValue) return &DraftProvider{ api: api, generator: g, diff --git a/drafts_test.go b/drafts_test.go index 230414b..1ad9de0 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -13,18 +13,18 @@ func TestDraftFlushRequiresChatID(t *testing.T) { draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone) draft.Message = "hello" - if err := draft.Flush(); err != ErrDraftChatIDZero { + if err := draft.Flush(); !errors.Is(err, ErrDraftChatIDZero) { t.Fatalf("expected ErrDraftChatIDZero, got %v", err) } } func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { ctx := &MsgContext{ - Api: &tgapi.API{}, + API: &tgapi.API{}, Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), draftProvider: NewRandomDraftProvider(&tgapi.API{}), } diff --git a/go.mod b/go.mod index 48585f1..b1952c8 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ retract v1.0.0-rc.5 require ( git.scuroneko.dev/scuroneko/extypes v1.2.3 - git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1 + git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0 github.com/alitto/pond/v2 v2.7.1 golang.org/x/time v0.15.0 ) diff --git a/go.sum b/go.sum index 8e3936a..c414d3e 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s= git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors= -git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1 h1:nDGLsvxbYoSxSk9wrGCmb+tn5jPM+KYlBX+3ciyC2Yo= -git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1/go.mod h1:q8XnLXzLdGjW0Jtcbh9/+G9WmfD68rsPQvLXEPxvum4= +git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0 h1:gaPe5azwuDTh48jRB/P2FUgOs7f1ToNr0S+NBizKvY8= +git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0/go.mod h1:q8XnLXzLdGjW0Jtcbh9/+G9WmfD68rsPQvLXEPxvum4= github.com/alitto/pond/v2 v2.7.1 h1:QxMbcfjcVTa0pyxX5Ib1226mM8u8D7gKUVkCUU4DYIw= github.com/alitto/pond/v2 v2.7.1/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= diff --git a/handler.go b/handler.go index 16046f2..07bc6ef 100644 --- a/handler.go +++ b/handler.go @@ -26,7 +26,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { defer cancel() msgCtx := &MsgContext{ - Update: *u, Api: bot.api, + Update: *u, API: bot.api, Logger: bot.logger, errorTemplate: bot.errorTemplate, l10n: bot.l10n, @@ -113,7 +113,7 @@ func cloneMsgContext(src *MsgContext) *MsgContext { return &cloned } -func encodeJsonPayload(d CallbackData) (string, error) { +func encodeJSONPayload(d CallbackData) (string, error) { b, err := json.Marshal(d) if err != nil { return "", err @@ -121,14 +121,14 @@ func encodeJsonPayload(d CallbackData) (string, error) { return string(b), nil } -func decodeJsonPayload(s string) (CallbackData, error) { +func decodeJSONPayload(s string) (CallbackData, error) { var data CallbackData err := json.Unmarshal([]byte(s), &data) return data, err } func encodeBase64Payload(d CallbackData) (string, error) { - data, err := encodeJsonPayload(d) + data, err := encodeJSONPayload(d) if err != nil { return "", err } @@ -142,7 +142,7 @@ func decodeBase64Payload(s string) (CallbackData, error) { if err != nil { return CallbackData{}, err } - return decodeJsonPayload(string(b)) + return decodeJSONPayload(string(b)) } func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) { @@ -155,18 +155,18 @@ func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackD if strict { return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64) } - data, err = decodeJsonPayload(s) + data, err = decodeJSONPayload(s) if err != nil { return CallbackData{}, "", err } - return data, BotPayloadJson, nil - case BotPayloadJson: - data, err := decodeJsonPayload(s) + return data, BotPayloadJSON, nil + case BotPayloadJSON: + data, err := decodeJSONPayload(s) if err == nil { - return data, BotPayloadJson, nil + return data, BotPayloadJSON, nil } if strict { - return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson) + return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJSON) } data, err = decodeBase64Payload(s) if err != nil { @@ -183,7 +183,7 @@ func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { return CallbackData{}, err } if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil { - bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson()) + bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJSON()) } return data, nil } diff --git a/handler_test.go b/handler_test.go index 47406dc..3b76ebb 100644 --- a/handler_test.go +++ b/handler_test.go @@ -55,7 +55,7 @@ func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { } func TestBotMiddlewareReceivesLogger(t *testing.T) { - logger := sneklog.CreateLogger() + logger := sneklog.NewLogger() called := false bot := &Bot[NoData]{ @@ -391,14 +391,14 @@ func TestPrepareUpdateCtxContract(t *testing.T) { if ctx.ChatID != tt.wantChatID { t.Fatalf("unexpected ChatID: got %d want %d", ctx.ChatID, tt.wantChatID) } - if ctx.CallbackQueryId != tt.wantCallbackID { - t.Fatalf("unexpected CallbackQueryId: got %q want %q", ctx.CallbackQueryId, tt.wantCallbackID) + if ctx.CallbackQueryID != tt.wantCallbackID { + t.Fatalf("unexpected CallbackQueryID: got %q want %q", ctx.CallbackQueryID, tt.wantCallbackID) } - if ctx.CallbackMsgId != tt.wantCallbackMsgID { - t.Fatalf("unexpected CallbackMsgId: got %d want %d", ctx.CallbackMsgId, tt.wantCallbackMsgID) + if ctx.CallbackMsgID != tt.wantCallbackMsgID { + t.Fatalf("unexpected CallbackMsgID: got %d want %d", ctx.CallbackMsgID, tt.wantCallbackMsgID) } - if ctx.InlineMsgId != tt.wantInlineMsgID { - t.Fatalf("unexpected InlineMsgId: got %q want %q", ctx.InlineMsgId, tt.wantInlineMsgID) + if ctx.InlineMsgID != tt.wantInlineMsgID { + t.Fatalf("unexpected InlineMsgID: got %q want %q", ctx.InlineMsgID, tt.wantInlineMsgID) } if ctx.Text != "" { t.Fatalf("prepareUpdateCtx must not populate Text, got %q", ctx.Text) @@ -468,7 +468,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -514,7 +514,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{ clonePlugin(first), clonePlugin(second), @@ -543,7 +543,7 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{clonePlugin(plugin)}, observer: observer, } @@ -607,7 +607,7 @@ func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, observer: observer, } @@ -658,7 +658,7 @@ func TestHandleMessageFallbackRunsForPlainText(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, } bot.AddPlugins(plugin) @@ -691,7 +691,7 @@ func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, } bot.AddPlugins(plugin) @@ -726,7 +726,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, } bot.AddPlugins(plugin) @@ -771,7 +771,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { }, "ping") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -808,7 +808,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { ) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -845,17 +845,17 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { ) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } - data, err := encodeJsonPayload(CallbackData{ + data, err := encodeJSONPayload(CallbackData{ Command: "approve", Args: []string{"7", "looks", "good"}, }) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -898,7 +898,7 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -940,7 +940,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -968,14 +968,14 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { plugin := NewPlugin[NoData]("test") plugin.NewPayload(func(ctx *MsgContext, db NoData) error { called = true - if ctx.CallbackQueryId != "cb-msg" { - t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) + if ctx.CallbackQueryID != "cb-msg" { + t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) } - if ctx.CallbackMsgId != 55 { - t.Fatalf("unexpected CallbackMsgId: %d", ctx.CallbackMsgId) + if ctx.CallbackMsgID != 55 { + t.Fatalf("unexpected CallbackMsgID: %d", ctx.CallbackMsgID) } - if ctx.InlineMsgId != "" { - t.Fatalf("did not expect InlineMsgId, got %q", ctx.InlineMsgId) + if ctx.InlineMsgID != "" { + t.Fatalf("did not expect InlineMsgID, got %q", ctx.InlineMsgID) } if ctx.Msg == nil { t.Fatal("expected callback message context") @@ -993,14 +993,14 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { }, "approve") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } - data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}}) + data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -1027,14 +1027,14 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { plugin := NewPlugin[NoData]("test") plugin.NewPayload(func(ctx *MsgContext, db NoData) error { called = true - if ctx.CallbackQueryId != "cb-inline" { - t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) + if ctx.CallbackQueryID != "cb-inline" { + t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) } - if ctx.CallbackMsgId != 0 { - t.Fatalf("did not expect CallbackMsgId, got %d", ctx.CallbackMsgId) + if ctx.CallbackMsgID != 0 { + t.Fatalf("did not expect CallbackMsgID, got %d", ctx.CallbackMsgID) } - if ctx.InlineMsgId != "inline-55" { - t.Fatalf("unexpected InlineMsgId: %q", ctx.InlineMsgId) + if ctx.InlineMsgID != "inline-55" { + t.Fatalf("unexpected InlineMsgID: %q", ctx.InlineMsgID) } if ctx.Msg != nil { t.Fatalf("did not expect callback chat message context, got %#v", ctx.Msg) @@ -1052,14 +1052,14 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { }, "inline.approve") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } - data, err := encodeJsonPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}}) + data, err := encodeJSONPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -1086,15 +1086,15 @@ func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) { }, "approve") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, plugins: []Plugin[NoData]{clonePlugin(plugin)}, observer: observer, } - data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}}) + data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7"}}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -1137,15 +1137,15 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { }, "approve") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, plugins: []Plugin[NoData]{clonePlugin(plugin)}, observer: observer, } - data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}}) + data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7"}}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -1182,8 +1182,8 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { observer := &recordingObserver{} bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, observer: observer, } @@ -1202,7 +1202,7 @@ func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { }, Logger: bot.logger, ctx: context.Background(), - CallbackQueryId: "cb-bad", + CallbackQueryID: "cb-bad", From: &tgapi.User{ID: 7}, FromID: 7, sceneRuntime: bot, diff --git a/keyboard.go b/keyboard.go index e251ddc..309bef6 100644 --- a/keyboard.go +++ b/keyboard.go @@ -19,10 +19,10 @@ const ( // InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button. // // Use NewInlineKbButton() to start, then chain methods to configure: -// - SetIconCustomEmojiId() — adds a custom emoji icon +// - SetIconCustomEmojiID() — adds a custom emoji icon // - SetStyle() — sets visual style (danger/success/primary) -// - SetUrl() — makes button open a URL -// - SetCallbackDataJson() — attaches structured command + args for bot handling +// - SetURL() — makes button open a URL +// - SetCallbackDataJSON() — attaches structured command + args for bot handling // // Call build() to produce the final tgapi.InlineKeyboardButton. // Builder methods are immutable — each returns a copy. @@ -40,9 +40,9 @@ func NewInlineKbButton(text string) InlineKbButtonBuilder { return InlineKbButtonBuilder{text: text} } -// SetIconCustomEmojiId sets a custom emoji ID to display as the button's icon. +// SetIconCustomEmojiID sets a custom emoji ID to display as the button's icon. // This is a Telegram Bot API feature for custom emoji icons. -func (b InlineKbButtonBuilder) SetIconCustomEmojiId(id string) InlineKbButtonBuilder { +func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBuilder { b.iconCustomEmojiID = id return b } @@ -55,22 +55,22 @@ func (b InlineKbButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineK return b } -// SetUrl sets a URL that will be opened when the button is pressed. +// SetURL sets a URL that will be opened when the button is pressed. // If both URL and CallbackData are set, Telegram will prioritize URL. -func (b InlineKbButtonBuilder) SetUrl(url string) InlineKbButtonBuilder { +func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder { b.url = url return b } -// SetCallbackDataJson sets a structured callback payload that will be sent to the bot +// SetCallbackDataJSON sets a structured callback payload that will be sent to the bot // when the button is pressed. The command and arguments are serialized as JSON. // // Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool) // are safely serialized, but complex structs may not serialize usefully. // -// Example: SetCallbackDataJson("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. -func (b InlineKbButtonBuilder) SetCallbackDataJson(cmd string, args ...any) InlineKbButtonBuilder { - b.callbackData = NewCallbackData(cmd, args...).ToJson() +// Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. +func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKbButtonBuilder { + b.callbackData = NewCallbackData(cmd, args...).ToJSON() return b } @@ -107,12 +107,12 @@ type InlineKeyboard struct { payloadType BotPayloadType // Serialization format for callback data (JSON or Base64) } -// NewInlineKeyboardJson creates a new keyboard builder with the specified maximum +// NewInlineKeyboardJSON creates a new keyboard builder with the specified maximum // number of buttons per row. // -// Example: NewInlineKeyboardJson(3) creates a keyboard with at most 3 buttons per line. -func NewInlineKeyboardJson(maxRow int) *InlineKeyboard { - return NewInlineKeyboard(BotPayloadJson, maxRow) +// Example: NewInlineKeyboardJSON(3) creates a keyboard with at most 3 buttons per line. +func NewInlineKeyboardJSON(maxRow int) *InlineKeyboard { + return NewInlineKeyboard(BotPayloadJSON, maxRow) } // NewInlineKeyboardBase64 creates a new keyboard builder with the specified maximum @@ -126,7 +126,7 @@ func NewInlineKeyboardBase64(maxRow int) *InlineKeyboard { // NewInlineKeyboard creates a new keyboard builder with the specified payload encoding // type and maximum number of buttons per row. // -// Use NewInlineKeyboardJson or NewInlineKeyboardBase64 for the common cases. +// Use NewInlineKeyboardJSON or NewInlineKeyboardBase64 for the common cases. func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard { return &InlineKeyboard{ CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0), @@ -163,15 +163,15 @@ func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeybo return in } -// AddUrlButton adds a button that opens a URL when pressed. +// AddURLButton adds a button that opens a URL when pressed. // No callback data is attached. -func (in *InlineKeyboard) AddUrlButton(text, url string) *InlineKeyboard { +func (in *InlineKeyboard) AddURLButton(text, url string) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{Text: text, URL: url}) } -// AddUrlButtonStyle adds a button with a visual style that opens a URL. +// AddURLButtonStyle adds a button with a visual style that opens a URL. // Style must be one of: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary. -func (in *InlineKeyboard) AddUrlButtonStyle(text string, style tgapi.KeyboardButtonStyle, url string) *InlineKeyboard { +func (in *InlineKeyboard) AddURLButtonStyle(text string, style tgapi.KeyboardButtonStyle, url string) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{Text: text, Style: style, URL: url}) } @@ -253,15 +253,15 @@ func NewCallbackData(command string, args ...any) CallbackData { } } -// ToJson serializes the CallbackData to a JSON string. +// ToJSON serializes the CallbackData to a JSON string. // // If serialization fails (e.g., due to unmarshalable fields), returns a fallback // JSON object: {"cmd":""} to prevent breaking Telegram's API. // // This fallback ensures the bot receives a valid JSON payload even if internal // errors occur — avoiding "invalid callback_data" errors from Telegram. -func (d CallbackData) ToJson() string { - data, err := encodeJsonPayload(d) +func (d CallbackData) ToJSON() string { + data, err := encodeJSONPayload(d) if err != nil { // Fallback: return minimal valid JSON to avoid Telegram API rejection return `{"cmd":""}` @@ -280,14 +280,14 @@ func (d CallbackData) ToBase64() string { } // Encode serializes the CallbackData according to the specified payload type. -// Supported types: BotPayloadJson and BotPayloadBase64. +// Supported types: BotPayloadJSON and BotPayloadBase64. // For unknown types, returns an empty string. func (d CallbackData) Encode(t BotPayloadType) string { switch t { case BotPayloadBase64: return d.ToBase64() - case BotPayloadJson: - return d.ToJson() + case BotPayloadJSON: + return d.ToJSON() } return "" } diff --git a/keyboard_test.go b/keyboard_test.go index c9157d6..401c256 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -8,7 +8,7 @@ import ( ) func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) { - kb := NewInlineKeyboardJson(2). + kb := NewInlineKeyboardJSON(2). AddCallbackButton("A", "cmd", 1). AddCallbackButton("B", "cmd", 2). AddCallbackButton("C", "cmd", 3) @@ -33,7 +33,7 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { AddButton( NewInlineKbButton("Docs"). SetStyle(ButtonStylePrimary). - SetUrl("https://example.test"), + SetURL("https://example.test"), ) button := kb.Get().InlineKeyboard[0][0] @@ -46,8 +46,8 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { } func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) { - kb := NewInlineKeyboardJson(2) - if got := kb.GetPayloadType(); got != BotPayloadJson { + kb := NewInlineKeyboardJSON(2) + if got := kb.GetPayloadType(); got != BotPayloadJSON { t.Fatalf("unexpected initial payload type: %q", got) } kb.SetPayloadType(BotPayloadBase64) @@ -60,7 +60,7 @@ func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing. kb := NewInlineKeyboardBase64(1). AddCallbackButton("A", "cmd", 1, "two") - got, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, false) + got, _, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, false) if err != nil { t.Fatalf("decodePayload returned error: %v", err) } @@ -72,7 +72,7 @@ func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing. } func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) { - kb := NewInlineKeyboardJson(1). + kb := NewInlineKeyboardJSON(1). AddCallbackButton("A", "cmd", 1, "two") got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false) @@ -90,7 +90,7 @@ func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) { kb := NewInlineKeyboardBase64(1). AddCallbackButton("A", "cmd", 1) - _, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, true) + _, _, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, true) if !errors.Is(err, ErrPayloadTypeMismatch) { t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err) } diff --git a/msg_context.go b/msg_context.go index bf8ce89..841d7ec 100644 --- a/msg_context.go +++ b/msg_context.go @@ -24,14 +24,14 @@ import ( // - 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 +// - 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. +// InlineMsgID when there is no chat message. type MsgContext struct { - Api *tgapi.API + API *tgapi.API Update tgapi.Update // Msg is the normalized Telegram message for message-backed update kinds. @@ -48,15 +48,15 @@ type MsgContext struct { // It may fall back to the bot logger when the plugin has no dedicated logger. Logger *sneklog.Logger - // InlineMsgId is the inline message identifier for callback queries that target + // 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 + 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 + CallbackMsgID int + // CallbackQueryID is the Telegram callback query ID for payload handlers and // callback-backed scene handlers. - CallbackQueryId string + 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 @@ -95,7 +95,7 @@ type AnswerMessage struct { } // 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 { +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 @@ -105,11 +105,11 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard ParseMode: parseMode, } switch { - case messageId > 0 && ctx.Msg != nil: - params.MessageID = messageId + case messageID > 0 && ctx.Msg != nil: + params.MessageID = messageID params.ChatID = ctx.Msg.Chat.ID - case ctx.InlineMsgId != "": - params.InlineMessageID = ctx.InlineMsgId + case ctx.InlineMsgID != "": + params.InlineMessageID = ctx.InlineMsgID default: ctx.Logger.Errorln(ErrEditTargetMissing) return nil @@ -117,12 +117,12 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard if keyboard != nil { params.ReplyMarkup = keyboard.Get() } - msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params) + msg, _, err := ctx.API.EditMessageTextWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil } - resultMessageID := messageId + resultMessageID := messageID if msg.MessageID > 0 { resultMessageID = msg.MessageID } @@ -147,11 +147,11 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { // 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 == "" { + if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" { ctx.Logger.Errorln(ErrCallbackMessageMissing) return nil } - return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode) + return ctx.edit(ctx.CallbackMsgID, text, keyboard, parseMode) } // EditCallback edits the callback message using plain text (ParseNone). @@ -179,7 +179,7 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb } // Internal helper for media-caption edits. -func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +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 @@ -189,11 +189,11 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo ParseMode: parseMode, } switch { - case messageId > 0 && ctx.Msg != nil: + case messageID > 0 && ctx.Msg != nil: params.ChatID = ctx.Msg.Chat.ID - params.MessageID = messageId - case ctx.InlineMsgId != "": - params.InlineMessageID = ctx.InlineMsgId + params.MessageID = messageID + case ctx.InlineMsgID != "": + params.InlineMessageID = ctx.InlineMsgID default: ctx.Logger.Errorln(ErrEditTargetMissing) return nil @@ -202,12 +202,12 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo params.ReplyMarkup = kb.Get() } - msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params) + msg, _, err := ctx.API.EditMessageCaptionWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil } - resultMessageID := messageId + resultMessageID := messageID if msg.MessageID > 0 { resultMessageID = msg.MessageID } @@ -265,7 +265,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID } - msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params) + msg, err := ctx.API.SendMessageWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -371,7 +371,7 @@ func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMo } // Internal helper for photo replies with optional caption and keyboard. -func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MsgContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) return nil @@ -384,7 +384,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par ChatID: ctx.Msg.Chat.ID, Caption: text, ParseMode: parseMode, - Photo: photoId, + Photo: photoID, } if kb != nil { params.ReplyMarkup = kb.Get() @@ -396,7 +396,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID) } - msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params) + msg, err := ctx.API.SendPhotoWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -407,44 +407,44 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par } // AnswerPhoto sends a photo with plain text caption. -func (ctx *MsgContext) AnswerPhoto(photoId, text string) *AnswerMessage { - return ctx.answerPhoto(photoId, text, nil, tgapi.ParseNone) +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) +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) +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) +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) +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) +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 { +func (ctx *MsgContext) delete(messageID int) { + if messageID == 0 { ctx.Logger.Errorln(ErrMessageIDZero) return } @@ -452,9 +452,9 @@ func (ctx *MsgContext) delete(messageId int) { ctx.Logger.Errorln(ErrMessageContextNil) return } - _, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{ + _, err := ctx.API.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{ ChatID: ctx.Msg.Chat.ID, - MessageID: messageId, + MessageID: messageID, }) if err != nil { ctx.Logger.Errorln(err) @@ -466,20 +466,20 @@ 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 { + if ctx.CallbackMsgID == 0 { ctx.Logger.Errorln(ErrCallbackMessageMissing) return } - ctx.delete(ctx.CallbackMsgId) + 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 { + if len(ctx.CallbackQueryID) == 0 { return } - _, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{ - CallbackQueryID: ctx.CallbackQueryId, + _, err := ctx.API.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{ + CallbackQueryID: ctx.CallbackQueryID, Text: text, ShowAlert: showAlert, URL: url, }) if err != nil { @@ -496,8 +496,8 @@ func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery( // 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) } +// 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) { @@ -511,7 +511,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { if ctx.Msg.MessageThreadID > 0 { params.MessageThreadID = ctx.Msg.MessageThreadID } - _, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params) + _, err := ctx.API.SendChatActionWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) } @@ -528,7 +528,7 @@ func (ctx *MsgContext) error(err error) { } text := fmt.Sprintf(ctx.errorTemplate, err.Error()) - if ctx.CallbackQueryId != "" { + if ctx.CallbackQueryID != "" { ctx.answerCallbackQuery("", text, false) } else { ctx.answer(text, nil, tgapi.ParseNone) @@ -543,7 +543,7 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { ctx.Logger.Errorln(ErrMessageContextNil) return nil } - if ctx.Api == nil { + if ctx.API == nil { ctx.Logger.Errorln(ErrAPIIsNil) return nil } @@ -552,10 +552,10 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { return nil } - if ctx.Api.Limiter != 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 { + if err := ctx.API.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { ctx.Logger.Errorln(err) return nil } diff --git a/msg_context_test.go b/msg_context_test.go index a6dee1e..79b43ef 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -35,7 +35,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -45,12 +45,12 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, }, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } answer := ctx.AnswerPhoto("photo-id", "caption") @@ -190,7 +190,7 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -200,9 +200,9 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } @@ -226,7 +226,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -236,9 +236,9 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } @@ -255,7 +255,7 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -265,10 +265,10 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) { }() ctx := &MsgContext{ - Api: api, - Logger: sneklog.CreateLogger(), + API: api, + Logger: sneklog.NewLogger(), errorTemplate: "%s", - CallbackQueryId: "cb-1", + CallbackQueryID: "cb-1", } ctx.error(AsInternalError(errors.New("boom"))) @@ -298,7 +298,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -308,10 +308,10 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { }() ctx := &MsgContext{ - Api: api, - Logger: sneklog.CreateLogger(), + API: api, + Logger: sneklog.NewLogger(), errorTemplate: "Oops: %s", - CallbackQueryId: "cb-1", + CallbackQueryID: "cb-1", } ctx.error(AsUserError(errors.New("boom"))) @@ -327,7 +327,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { func TestAnswerRejectsEmptyMessage(t *testing.T) { ctx := &MsgContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } if answer := ctx.Answer(""); answer != nil { @@ -345,7 +345,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -355,9 +355,9 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil { @@ -429,7 +429,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -439,11 +439,11 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } - kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd") + kb := NewInlineKeyboardJSON(1).AddCallbackButton("A", "cmd") text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32) messages := ctx.KeyboardLong(text, kb) diff --git a/plugins.go b/plugins.go index 6190bf4..ac001c0 100644 --- a/plugins.go +++ b/plugins.go @@ -264,7 +264,7 @@ func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor switch t { case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery: if p.logger == nil { - logger := utils.CreateLogger(p.name, utils.GetLoggerLevel()) + logger := utils.CreateLogger(p.name, utils.GetLoggerLevel(), utils.LogFormatText, nil) logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t) _ = logger.Close() return p diff --git a/policy.go b/policy.go index c331a99..9ea88a7 100644 --- a/policy.go +++ b/policy.go @@ -143,7 +143,7 @@ func RequireChatAdmin[T AppData]() Policy[T] { return AsInternalError(errors.New("chat-admin policy requires message chat context")) } - member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{ + member, err := ctx.API.GetChatMember(tgapi.GetChatMember{ ChatID: ctx.ChatID, UserID: ctx.FromID, }) @@ -166,7 +166,7 @@ func RequireChatCreator[T AppData]() Policy[T] { return AsInternalError(errors.New("chat-creator policy requires message chat context")) } - member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{ + member, err := ctx.API.GetChatMember(tgapi.GetChatMember{ ChatID: ctx.ChatID, UserID: ctx.FromID, }) @@ -189,12 +189,12 @@ func RequireBotAdmin[T AppData]() Policy[T] { return AsInternalError(errors.New("bot-admin policy requires message chat context")) } - bot, err := ctx.Api.GetMe() + bot, err := ctx.API.GetMe() if err != nil { return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err)) } - member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{ + member, err := ctx.API.GetChatMember(tgapi.GetChatMember{ ChatID: ctx.ChatID, UserID: bot.ID, }) diff --git a/policy_test.go b/policy_test.go index d0925d4..d48d508 100644 --- a/policy_test.go +++ b/policy_test.go @@ -37,7 +37,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -47,9 +47,9 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } @@ -73,7 +73,7 @@ func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) { Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil { @@ -86,7 +86,7 @@ func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) { Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup}, }, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } err := RequirePrivateChat[NoData]()(ctx, NoData{}) @@ -127,7 +127,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -137,10 +137,10 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, ChatID: -2001, FromID: 55, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil { @@ -168,7 +168,7 @@ func TestAllPoliciesReturnsFirstError(t *testing.T) { }, ) - err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) + err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, want) { t.Fatalf("expected first policy error, got %v", err) } @@ -180,7 +180,7 @@ func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { func(ctx *MsgContext, data NoData) error { return nil }, ) - if err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}); err != nil { + if err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { t.Fatalf("expected later success to allow access, got %v", err) } } @@ -192,7 +192,7 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { func(ctx *MsgContext, data NoData) error { return internal }, ) - err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) + err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, internal) { t.Fatalf("expected internal error, got %v", err) } @@ -205,7 +205,7 @@ func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) { func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) }, ) - err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) + err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, first) { t.Fatalf("expected first deny error, got %v", err) } @@ -215,7 +215,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { inverted := NotPolicy(func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) }) - if err := inverted(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}); err != nil { + if err := inverted(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { t.Fatalf("expected inverted deny to succeed, got %v", err) } @@ -223,7 +223,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { preserve := NotPolicy(func(ctx *MsgContext, data NoData) error { return internal }) - err := preserve(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) + err := preserve(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, internal) { t.Fatalf("expected internal error to be preserved, got %v", err) } @@ -233,7 +233,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { t.Run("allow", func(t *testing.T) { observer := &recordingObserver{} ctx := &MsgContext{ - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), ctx: context.Background(), observer: observer, FromID: 10, @@ -258,7 +258,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { t.Run("deny", func(t *testing.T) { observer := &recordingObserver{} ctx := &MsgContext{ - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), ctx: context.Background(), observer: observer, errorTemplate: "%s", diff --git a/runners_test.go b/runners_test.go index 351150a..3d3f5b8 100644 --- a/runners_test.go +++ b/runners_test.go @@ -17,7 +17,7 @@ type runnerObserver struct { func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { var calls atomic.Int32 bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { calls.Add(1) @@ -39,7 +39,7 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), runners: []Runner[NoData]{ NewRunner("background", func(*Bot[NoData]) error { if calls.Add(1) == 1 { @@ -71,7 +71,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) { wantErr := errors.New("runner failed") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), observer: observer, runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { diff --git a/scene_handler.go b/scene_handler.go index 161398d..b4c0c22 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -218,12 +218,14 @@ func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from return } - to := from + var to string switch result.Action { case SceneActionNext: to = result.Next case SceneActionExit: to = "" + default: + to = from } bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{ diff --git a/scene_test.go b/scene_test.go index a465386..d904431 100644 --- a/scene_test.go +++ b/scene_test.go @@ -15,15 +15,15 @@ type failingSessionStore struct { deleteErr error } -func (s failingSessionStore) Get(key string) (SceneSession, error) { +func (s failingSessionStore) Get(string) (SceneSession, error) { return SceneSession{}, s.getErr } -func (s failingSessionStore) Set(key string, session SceneSession) error { +func (s failingSessionStore) Set(string, SceneSession) error { return s.setErr } -func (s failingSessionStore) Delete(key string) error { +func (s failingSessionStore) Delete(string) error { return s.deleteErr } @@ -56,7 +56,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -149,7 +149,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { plugin.NewScene("signup") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -172,7 +172,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { plugin.NewScene("signup").SetEntry("start") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -231,7 +231,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -279,7 +279,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -331,7 +331,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -386,7 +386,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -453,8 +453,8 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -469,9 +469,9 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - data, err := encodeJsonPayload(CallbackData{Command: "confirm", Args: []string{"7", "ok"}}) + data, err := encodeJSONPayload(CallbackData{Command: "confirm", Args: []string{"7", "ok"}}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -509,8 +509,8 @@ func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, observer: observer, @@ -526,9 +526,9 @@ func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - data, err := encodeJsonPayload(CallbackData{Command: "confirm"}) + data, err := encodeJSONPayload(CallbackData{Command: "confirm"}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -572,8 +572,8 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -596,9 +596,9 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { t.Fatal("expected scene key to be built") } - data, err := encodeJsonPayload(CallbackData{Command: "ping"}) + data, err := encodeJSONPayload(CallbackData{Command: "ping"}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -648,7 +648,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -724,7 +724,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { }, "ping") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -793,7 +793,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -838,7 +838,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat}, } @@ -865,7 +865,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { t.Run("find scene session get error", func(t *testing.T) { bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: failingSessionStore{getErr: getErr}, sceneScopePriority: []SceneScope{SceneScopeUser}, } @@ -881,7 +881,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { return ctx.Stay(), nil }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: failingSessionStore{setErr: setErr}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } diff --git a/tgapi/api.go b/tgapi/api.go index 9235ce4..ee09625 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -19,7 +19,10 @@ type APIOpts struct { token string client *http.Client useTestServer bool - apiUrl string + apiURL string + + logFormat utils.LogFormat + logFormatter *sneklog.Formatter limiter *utils.RateLimiter dropOverflowLimit bool @@ -32,7 +35,7 @@ func NewAPIOpts(token string) *APIOpts { token: token, client: nil, useTestServer: false, - apiUrl: "https://api.telegram.org", + apiURL: "https://api.telegram.org", } } @@ -52,15 +55,24 @@ func (opts *APIOpts) UseTestServer(use bool) *APIOpts { return opts } -// SetAPIUrl overrides the default Telegram API URL. +// SetAPIURL overrides the default Telegram API URL. // Useful for self-hosted bots or proxies. -func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts { - if apiUrl != "" { - opts.apiUrl = apiUrl +func (opts *APIOpts) SetAPIURL(apiURL string) *APIOpts { + if apiURL != "" { + opts.apiURL = apiURL } return opts } +func (opts *APIOpts) SetLogFormat(format utils.LogFormat) *APIOpts { + opts.logFormat = format + return opts +} +func (opts *APIOpts) SetLogFormatter(formatter *sneklog.Formatter) *APIOpts { + opts.logFormatter = formatter + return opts +} + // SetLimiter sets a rate limiter to enforce Telegram's API limits. // Recommended: use utils.NewRateLimiter() for correct per-chat and global throttling. func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts { @@ -87,7 +99,10 @@ type API struct { client *http.Client logger *sneklog.Logger useTestServer bool - apiUrl string + apiURL string + + logFormat utils.LogFormat + logFormatter *sneklog.Formatter pool *workerPool Limiter *utils.RateLimiter @@ -97,12 +112,13 @@ type API struct { // NewAPI creates a new API client from options. // Always call Close() when done to release resources. func NewAPI(opts *APIOpts) *API { - l := utils.CreateLogger("API", utils.GetLoggerLevel()) if opts == nil { - l.Errorln("Set API options") - _ = l.Close() return nil } + logger := utils.CreateLogger( + "API", utils.GetLoggerLevel(), + opts.logFormat, opts.logFormatter, + ) client := opts.client if client == nil { @@ -113,11 +129,15 @@ func NewAPI(opts *APIOpts) *API { pool.start() return &API{ - token: opts.token, - client: client, - logger: l, - useTestServer: opts.useTestServer, - apiUrl: opts.apiUrl, + token: opts.token, + client: client, + logger: logger, + useTestServer: opts.useTestServer, + apiURL: opts.apiURL, + + logFormat: opts.logFormat, + logFormatter: opts.logFormatter, + pool: pool, Limiter: opts.limiter, dropOverflowLimit: opts.dropOverflowLimit, @@ -147,9 +167,9 @@ type ResponseParameters struct { RetryAfter *int `json:"retry_after,omitempty"` } -// ApiResponse is the standard Telegram Bot API response structure. +// TelegramResponse is the standard Telegram Bot API response structure. // Generic over Result type R. -type ApiResponse[R any] struct { +type TelegramResponse[R any] struct { Ok bool `json:"ok"` Description string `json:"description,omitempty"` Result R `json:"result,omitempty"` @@ -166,7 +186,7 @@ type ApiResponse[R any] struct { type TelegramRequest[R, P any] struct { method string params P - chatId int64 + chatID int64 } // NewRequest creates a low-level TelegramRequest with no associated chat ID. @@ -176,8 +196,8 @@ func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { // NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID. // The chat ID is used for per-chat rate limiting. -func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { - return TelegramRequest[R, P]{method, params, chatId} +func NewRequestWithChatID[R, P any](method string, params P, chatID int64) TelegramRequest[R, P] { + return TelegramRequest[R, P]{method, params, chatID} } func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { @@ -191,7 +211,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro if api.useTestServer { methodPrefix = "/test" } - url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method) + url := fmt.Sprintf("%s/bot%s%s/%s", api.apiURL, api.token, methodPrefix, r.method) req, err := http.NewRequestWithContext(ctx, "POST", url, nil) if err != nil { return zero, fmt.Errorf("failed to create request: %w", err) @@ -204,7 +224,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro for { // Apply rate limiting before making the request if api.Limiter != nil { - if err := api.Limiter.Check(ctx, api.dropOverflowLimit, r.chatId); err != nil { + if err := api.Limiter.Check(ctx, api.dropOverflowLimit, r.chatID); err != nil { return zero, err } } @@ -235,12 +255,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro // Handle rate limiting (429) if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { after := *response.Parameters.RetryAfter - api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatId) + api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatID) // Apply cooldown to global or chat-specific limiter if api.Limiter != nil { - if r.chatId > 0 { - api.Limiter.SetChatLock(r.chatId, after) + if r.chatID > 0 { + api.Limiter.SetChatLock(r.chatID, after) } else { api.Limiter.SetGlobalLock(after) } @@ -302,8 +322,8 @@ func readBody(body io.ReadCloser) ([]byte, error) { } // Internal helper that parses a typed Telegram API response body. -func parseBody[R any](data []byte) (ApiResponse[R], error) { - var resp ApiResponse[R] +func parseBody[R any](data []byte) (TelegramResponse[R], error) { + var resp TelegramResponse[R] err := json.Unmarshal(data, &resp) if err != nil { return resp, fmt.Errorf("failed to unmarshal JSON: %w", err) diff --git a/tgapi/api_test.go b/tgapi/api_test.go index 5873223..c7432aa 100644 --- a/tgapi/api_test.go +++ b/tgapi/api_test.go @@ -40,7 +40,7 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -77,7 +77,7 @@ func TestAPICloseClosesIdleConnections(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(&http.Client{Transport: transport}), ) diff --git a/tgapi/errors.go b/tgapi/errors.go index 6c142ae..bbf1f04 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -2,9 +2,6 @@ package tgapi import "errors" -// ErrRateLimit reports that a request exceeded the configured rate limiter. -var ErrRateLimit = errors.New("rate limit exceeded") - // ErrPoolUnexpected reports an unexpected result type returned from the worker pool. var ErrPoolUnexpected = errors.New("unexpected response from pool") diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index bf1b786..7584e12 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -472,7 +472,7 @@ func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatAc // See https://core.telegram.org/bots/api#setmessagereaction type SetMessageReaction struct { ChatID int64 `json:"chat_id"` - MessageId int `json:"message_id"` + MessageID int `json:"message_id"` Reaction []ReactionType `json:"reaction"` IsBig bool `json:"is_big,omitempty"` } diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 964a3f5..4d12516 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -103,7 +103,7 @@ type Message struct { SenderBusinessBot *User `json:"sender_business_bot,omitempty"` SenderTag string `json:"sender_tag,omitempty"` Date int `json:"date"` - BusinessConnectionId string `json:"business_connection_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` Chat *Chat `json:"chat,omitempty"` ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"` @@ -121,7 +121,7 @@ type Message struct { HasProtectedContent bool `json:"has_protected_content,omitempty"` IsFromOffline bool `json:"is_from_offline,omitempty"` IsPaidPost bool `json:"is_paid_post,omitempty"` - MediaGroupId string `json:"media_group_id,omitempty"` + MediaGroupID string `json:"media_group_id,omitempty"` AuthorSignature string `json:"author_signature,omitempty"` PaidStarCount int `json:"paid_star_count,omitempty"` @@ -316,8 +316,8 @@ const ( MessageEntityCashtag MessageEntityType = "cashtag" // MessageEntityBotCommand identifies a bot command entity. MessageEntityBotCommand MessageEntityType = "bot_command" - // MessageEntityUrl identifies a URL entity. - MessageEntityUrl MessageEntityType = "url" + // MessageEntityURL identifies a URL entity. + MessageEntityURL MessageEntityType = "url" // MessageEntityEmail identifies an email entity. MessageEntityEmail MessageEntityType = "email" // MessageEntityPhoneNumber identifies a phone number entity. @@ -537,7 +537,7 @@ const ( // ChatActionUploadVideoNote tells Telegram the bot is uploading a video note. ChatActionUploadVideoNote ChatActionType = "upload_video_note" // ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote. - ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote + ChatActionUploadVideoNone = ChatActionUploadVideoNote ) // MessageReactionUpdated represents a change of a reaction on a message. diff --git a/tgapi/methods.go b/tgapi/methods.go index 1d06720..3e1d888 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -256,7 +256,7 @@ func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser, if api.useTestServer { methodPrefix = "/test" } - u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link) + u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiURL, api.token, methodPrefix, link) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { diff --git a/tgapi/methods_test.go b/tgapi/methods_test.go index 5769fd0..2b794fc 100644 --- a/tgapi/methods_test.go +++ b/tgapi/methods_test.go @@ -23,7 +23,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -47,7 +47,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) { func TestOpenFileByLinkStreamsResponseBody(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(&http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return &http.Response{ @@ -94,7 +94,7 @@ func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -131,7 +131,7 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -174,7 +174,7 @@ func TestSetChatMenuButtonSendsStructuredMenuButton(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 04074d0..13694af 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -70,12 +70,13 @@ type Uploader struct { // NewUploader creates a multipart uploader bound to an API client. func NewUploader(api *API) *Uploader { - logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel()) if api == nil { - logger.Errorln("api is nil") - _ = logger.Close() return nil } + logger := utils.CreateLogger( + "UPLOADER", utils.GetLoggerLevel(), + api.logFormat, api.logFormatter, + ) return &Uploader{api, logger} } @@ -97,18 +98,18 @@ type UploaderRequest[R, P any] struct { method string files []UploaderFile params P - chatId int64 + chatID int64 } // NewUploaderRequest creates a low-level multipart upload request with no associated chat ID. func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { - return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0} + return UploaderRequest[R, P]{method: method, files: files, params: params, chatID: 0} } // NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID. // The chat ID is used for per-chat rate limiting. -func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] { - return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId} +func NewUploaderRequestWithChatID[R, P any](method string, params P, chatID int64, files ...UploaderFile) UploaderRequest[R, P] { + return UploaderRequest[R, P]{method: method, files: files, params: params, chatID: chatID} } func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { @@ -118,11 +119,11 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, if up.api.useTestServer { methodPrefix = "/test" } - url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, r.method) + url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiURL, up.api.token, methodPrefix, r.method) for { if up.api.Limiter != nil { - if err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatId); err != nil { + if err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatID); err != nil { return zero, err } } @@ -161,10 +162,10 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, if !response.Ok { if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { after := *response.Parameters.RetryAfter - up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatId) + up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatID) if up.api.Limiter != nil { - if r.chatId > 0 { - up.api.Limiter.SetChatLock(r.chatId, after) + if r.chatID > 0 { + up.api.Limiter.SetChatLock(r.chatID, after) } else { up.api.Limiter.SetGlobalLock(after) } diff --git a/tgapi/uploader_api_test.go b/tgapi/uploader_api_test.go index 88f328f..f5a2915 100644 --- a/tgapi/uploader_api_test.go +++ b/tgapi/uploader_api_test.go @@ -40,7 +40,7 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { diff --git a/update_context.go b/update_context.go index 150e7ac..cb2167b 100644 --- a/update_context.go +++ b/update_context.go @@ -142,15 +142,15 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { if u.CallbackQuery != nil { if u.CallbackQuery.Message != nil { ctx.Msg = u.CallbackQuery.Message - ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID + ctx.CallbackMsgID = u.CallbackQuery.Message.MessageID if u.CallbackQuery.Message.Chat != nil { chat = u.CallbackQuery.Message.Chat } } if u.CallbackQuery.InlineMessageID != nil { - ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID + ctx.InlineMsgID = *u.CallbackQuery.InlineMessageID } - ctx.CallbackQueryId = u.CallbackQuery.ID + ctx.CallbackQueryID = u.CallbackQuery.ID from = &u.CallbackQuery.From } case tgapi.UpdateTypeShippingQuery: diff --git a/utils/utils.go b/utils/utils.go index d9a554e..cc94244 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,6 +6,13 @@ import ( "git.scuroneko.dev/scuroneko/sneklog/v2" ) +type LogFormat string + +const ( + LogFormatText LogFormat = "text" + LogFormatJSON LogFormat = "json" +) + // GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL. func GetLoggerLevel() sneklog.LogLevel { level := sneklog.FATAL @@ -17,12 +24,28 @@ func GetLoggerLevel() sneklog.LogLevel { // CreateLogger creates a logger with the shared default policy: // JSON stdout output, provided prefix, and provided level. -func CreateLogger(prefix string, level sneklog.LogLevel) *sneklog.Logger { - logger := sneklog.CreateLogger().Level(level) - if prefix != "" { - logger.Prefix(prefix) +func CreateLogger( + name string, level sneklog.LogLevel, + format LogFormat, formatter *sneklog.Formatter, +) *sneklog.Logger { + logger := sneklog.NewLogger().SetLevel(level) + if name != "" { + logger.SetName(name) + } + switch format { + case LogFormatJSON: + writer := logger.CreateJsonStdoutWriter() + if formatter != nil { + writer.SetFormatter(formatter) + } + logger.AddWriters(writer) + default: + writer := logger.CreateTextStdoutWriter() + if formatter != nil { + writer.SetFormatter(formatter) + } + logger.AddWriters(writer) } - logger.AddWriter(logger.CreateJsonStdoutWriter()) return logger } @@ -31,12 +54,31 @@ func CreateLogger(prefix string, level sneklog.LogLevel) *sneklog.Logger { // // The returned logger is always non-nil. When file writer creation fails, the // logger still writes to stdout and the error is returned to the caller. -func CreateFileLogger(prefix string, level sneklog.LogLevel, filePath string) (*sneklog.Logger, error) { - logger := CreateLogger(prefix, level) - fileWriter, err := logger.CreateTextFileWriter(filePath) - if err != nil { - return logger, err +func CreateFileLogger( + prefix string, level sneklog.LogLevel, filePath string, + format LogFormat, formatter *sneklog.Formatter, +) (*sneklog.Logger, error) { + logger := CreateLogger(prefix, level, format, formatter) + + switch format { + case LogFormatJSON: + writer, err := logger.CreateJsonFileWriter(filePath) + if err != nil { + return logger, err + } + if formatter != nil { + writer.SetFormatter(formatter) + } + logger.AddWriters(writer) + default: + writer, err := logger.CreateTextFileWriter(filePath) + if err != nil { + return logger, err + } + if formatter != nil { + writer.SetFormatter(formatter) + } + logger.AddWriters(writer) } - logger.AddWriter(fileWriter) return logger, nil } diff --git a/utils/utils_test.go b/utils/utils_test.go index c69eacc..eae1aec 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -12,7 +12,7 @@ import ( func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) { logPath := filepath.Join(t.TempDir(), "main.log") - logger, err := CreateFileLogger("TEST", sneklog.DEBUG, logPath) + logger, err := CreateFileLogger("TEST", sneklog.DEBUG, logPath, LogFormatText, nil) if err != nil { t.Fatalf("CreateFileLogger returned error: %v", err) } -- 2.54.0 From 667fa3cc6140901715a9c3dad94009031f30c8a8 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 29 Apr 2026 12:31:41 +0300 Subject: [PATCH 84/99] (new): updates iterator (new): sneklog migration (refactor): idiomatic names (fix): polling lifecycle (tests): polling regressions (doc): rc16 changelog --- CHANGELOG.md | 28 ++++++++++++++---- bot.go | 2 +- bot_test.go | 54 ++++++++++++++++++++++++++++++++-- methods.go | 20 +++++++++++++ methods_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 methods_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f4cd31..c430c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,35 @@ ## v1.0.0-rc.16 +### Breaking Changes +- Replaced `git.scuroneko.dev/scuroneko/slog` with `git.scuroneko.dev/scuroneko/sneklog/v2` across public logger APIs, including `AppDataLogger`, logger getters, and custom logger setters. +- Renamed exported `Json`, `Url`, and `Id` identifiers to idiomatic `JSON`, `URL`, and `ID` spellings, including `BotOpts.APIURL`, `BotOpts.SetAPIURL(...)`, `tgapi.APIOpts.SetAPIURL(...)`, `BotOptsFileJSONCodec`, `BotPayloadJSON`, and related README examples. +- Made the request logger field internal; use `Bot.SetRequestLogger(...)` and `Bot.GetRequestLogger()` instead of accessing `Bot.RequestLogger` directly. + +### Added +- Added `Bot.UpdatesIter(...)` as an iterator wrapper around a single `Bot.Updates(...)` call, including error delivery through the iterator. +- Added scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution. +- Added configurable logger output through `BotOpts.LogFormat`, `BotOpts.SetLogFormat(...)`, `BotOpts.SetLogFormatter(...)`, `tgapi.APIOpts.SetLogFormat(...)`, and `tgapi.APIOpts.SetLogFormatter(...)`. +- Added JSON BotOpts file format versioning through `ConfigVersion`, `ErrConfigVersionMismatch`, and `BotOpts.FileConfigVersion`. +- Added `Bot.SetLogger(...)`, `Bot.SetRequestLogger(...)`, `Bot.SetWebHookLogger(...)`, `Bot.GetRequestLogger()`, and `Bot.GetWebHookLogger()` helpers for explicit logger customization. + ### Changed -- Updated `slog` to `v2`. -- Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, and auto-managed plugin loggers, so bot tokens stay masked in both stdout and file-backed logs. +- Updated `pond/v2` to `v2.7.1`. +- `Bot.RunWithContext(...)` now closes an explicitly set request logger when `UseRequestLogger` is false and closes webhook loggers before long-polling startup. +- Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, app-data logger writers, and auto-managed plugin loggers. - JSON `BotOpts` files now write `version`, reject newer unsupported config versions, keep older unversioned files loadable, and preserve the loaded file version in `BotOpts.FileConfigVersion`. -- Active scenes now support scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution. -- Updated Go initialism names for JSON, URL, ID, and API helpers. +- `Bot.RunWithContext(...)` treats `context.DeadlineExceeded` like `context.Canceled` and exits polling without retry logging. +- README and README_RU now use the current `JSON`, `URL`, and `ID` public API names. + +### Fixed +- Fixed the go-lint workflow file to end with a newline. ### Tests +- Added regression coverage for `Bot.UpdatesIter(...)` error delivery and early iterator stop behavior. +- Added regression coverage proving `Bot.RunWithContext(...)` preserves polling retry attempts and backoff delays across repeated getUpdates failures. - Added regression coverage proving polling startup preserves an enabled request logger. - Updated file logger regression coverage for the current `sneklog` text prefix format. -- Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking as well. +- Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking. - Added regression coverage for JSON config version handling and scene-local payload routing, including observer lifecycle events and callback fallthrough behavior. - Updated logger helper tests for the explicit log format and formatter parameters. diff --git a/bot.go b/bot.go index afc9176..7f3a8ff 100644 --- a/bot.go +++ b/bot.go @@ -442,7 +442,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { default: updates, err := bot.Updates(ctx) if err != nil { - if errors.Is(err, context.Canceled) { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } bot.logger.Errorln("failed to fetch updates:", err) diff --git a/bot_test.go b/bot_test.go index 7104985..c35b5fb 100644 --- a/bot_test.go +++ b/bot_test.go @@ -24,12 +24,13 @@ func (f pollingRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, erro type pollingRetryObserver struct { recordingObserver - cancel context.CancelFunc + cancel context.CancelFunc + cancelAfter int } func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRetryEvent) { o.recordingObserver.OnPollingRetry(ctx, ev) - if o.cancel != nil { + if o.cancel != nil && (o.cancelAfter == 0 || len(o.retries) >= o.cancelAfter) { o.cancel() } } @@ -510,6 +511,55 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { } } +func TestRunWithContextPreservesPollingRetryBackoff(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observer := &pollingRetryObserver{cancel: cancel, cancelAfter: 2} + + client := &http.Client{ + Transport: pollingRoundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":false,"error_code":500,"description":"boom"}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("http://example.invalid"). + SetHTTPClient(client), + ) + defer func() { + _ = api.Close() + }() + + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + api: api, + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + observer: observer, + } + + if err := bot.RunWithContext(ctx); err != nil { + t.Fatalf("RunWithContext returned error: %v", err) + } + + if len(observer.retries) != 2 { + t.Fatalf("expected two polling retry events, got %d", len(observer.retries)) + } + if got := observer.retries[0]; got.Attempt != 1 || got.Delay != time.Second { + t.Fatalf("unexpected first retry event: %#v", got) + } + if got := observer.retries[1]; got.Attempt != 2 || got.Delay != 2*time.Second { + t.Fatalf("unexpected second retry event: %#v", got) + } +} + func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { type testDB struct{ Name string } diff --git a/methods.go b/methods.go index b72802b..50e24e6 100644 --- a/methods.go +++ b/methods.go @@ -3,6 +3,7 @@ package laniakea import ( "context" "encoding/json" + "iter" "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) @@ -67,3 +68,22 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { } return updates, err } + +// UpdatesIter fetches updates once and yields each update in order. +// +// If fetching updates fails, the iterator yields the error once with a zero +// update and then stops. +func (bot *Bot[T]) UpdatesIter(ctx context.Context) iter.Seq2[tgapi.Update, error] { + return func(yield func(tgapi.Update, error) bool) { + updates, err := bot.Updates(ctx) + if err != nil { + yield(tgapi.Update{}, err) + return + } + for _, u := range updates { + if !yield(u, nil) { + return + } + } + } +} diff --git a/methods_test.go b/methods_test.go new file mode 100644 index 0000000..1e95a79 --- /dev/null +++ b/methods_test.go @@ -0,0 +1,78 @@ +package laniakea + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +func TestUpdatesIterYieldsFetchError(t *testing.T) { + bot := newUpdatesIterTestBot(t, `{"ok":false,"error_code":500,"description":"boom"}`) + + var gotErr error + var gotUpdates int + bot.UpdatesIter(context.Background())(func(update tgapi.Update, err error) bool { + gotUpdates++ + if update.UpdateID != 0 { + t.Fatalf("expected zero update on error, got %d", update.UpdateID) + } + gotErr = err + return true + }) + + if gotUpdates != 1 { + t.Fatalf("expected one yielded error, got %d yields", gotUpdates) + } + if gotErr == nil { + t.Fatal("expected fetch error") + } + if !strings.Contains(gotErr.Error(), "boom") { + t.Fatalf("expected Telegram error description, got %v", gotErr) + } +} + +func TestUpdatesIterStopsWhenYieldReturnsFalse(t *testing.T) { + bot := newUpdatesIterTestBot(t, `{"ok":true,"result":[{"update_id":11},{"update_id":12}]}`) + + var gotIDs []int + bot.UpdatesIter(context.Background())(func(update tgapi.Update, err error) bool { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + gotIDs = append(gotIDs, update.UpdateID) + return false + }) + + if len(gotIDs) != 1 || gotIDs[0] != 11 { + t.Fatalf("expected only first update, got %v", gotIDs) + } +} + +func newUpdatesIterTestBot(t *testing.T, response string) *Bot[NoData] { + t.Helper() + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("https://example.test"). + SetHTTPClient(&http.Client{ + Transport: pollingRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(response)), + }, nil + }), + }), + ) + t.Cleanup(func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }) + + return &Bot[NoData]{api: api} +} -- 2.54.0 From 4807dec6aef2156d49038da0b7c9b60558102e3a Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 29 Apr 2026 13:11:54 +0300 Subject: [PATCH 85/99] (fix): observer lifecycle (tests): webhook runtime (doc): logger options --- CHANGELOG.md | 10 +++++++++ bot_opts.go | 9 ++++++-- bot_webhook_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++ handler.go | 8 ++++++++ handler_test.go | 43 +++++++++++++++++++++++++++++++++++++- tgapi/api.go | 3 +++ utils/utils.go | 7 +++++-- utils/version.go | 4 ++-- 8 files changed, 127 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c430c14..b99d636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v1.0.0 + +### Changed +- Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced. +- `BotOpts`, `tgapi.APIOpts`, and logger utility godoc now document `LOG_FORMAT`, `LogFormat`, and logger formatting options consistently. + +### Tests +- Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. +- Added webhook runtime regression coverage for request enqueue through worker execution of a command handler. + ## v1.0.0-rc.16 ### Breaking Changes diff --git a/bot_opts.go b/bot_opts.go index d03e1e7..790927d 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -71,7 +71,9 @@ type BotOpts struct { // It is zero when the options were not loaded from a versioned file. FileConfigVersion int - LogFormat utils.LogFormat + // LogFormat selects text or JSON output for bot-managed loggers. + LogFormat utils.LogFormat + // LogFormatter customizes bot-managed logger writers when supported. LogFormatter *sneklog.Formatter } @@ -92,7 +94,7 @@ type BotOpts struct { // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow // - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format // - MAX_WORKERS: maximum number of concurrent update handlers (default: 32) -// - JSON_LOG: +// - LOG_FORMAT: logger output format, "text" or "json" (default: "text") // // Returns a populated BotOpts. // NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing. @@ -254,10 +256,13 @@ func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts { return opts } +// SetLogFormat sets the output format used by bot-managed loggers. func (opts *BotOpts) SetLogFormat(format utils.LogFormat) *BotOpts { opts.LogFormat = format return opts } + +// SetLogFormatter sets the formatter used by bot-managed logger writers. func (opts *BotOpts) SetLogFormatter(formatter *sneklog.Formatter) *BotOpts { opts.LogFormatter = formatter return opts diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 00c95e4..7df0fb2 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -9,6 +9,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/sneklog/v2" @@ -108,6 +109,55 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { } } +func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { + var calls atomic.Int32 + plugin := NewPlugin[NoData]("demo") + plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + calls.Add(1) + return nil + }, "start") + + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + webHookLogger: sneklog.NewLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{*plugin}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + } + t.Cleanup(func() { + _ = bot.logger.Close() + _ = bot.webHookLogger.Close() + }) + + err := bot.runWebhookRuntime(context.Background(), func(ctx context.Context) error { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":9,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"from":{"id":2,"is_bot":false,"first_name":"Test"},"text":"/start"}}`)) + rec := httptest.NewRecorder() + + updateHandler(ctx, bot, "").ServeHTTP(rec, req) + if rec.Result().StatusCode != http.StatusOK { + t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) + } + + deadline := time.After(time.Second) + for calls.Load() == 0 { + select { + case <-deadline: + t.Fatal("webhook runtime did not process enqueued update") + default: + time.Sleep(time.Millisecond) + } + } + return nil + }) + if err != nil { + t.Fatalf("runWebhookRuntime returned error: %v", err) + } + if calls.Load() != 1 { + t.Fatalf("expected command handler to run once, got %d", calls.Load()) + } +} + func TestWebhookAllowedUpdatesUsesBotUpdateTypesByDefault(t *testing.T) { bot := &Bot[NoData]{ updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, diff --git a/handler.go b/handler.go index 07bc6ef..f4e2a54 100644 --- a/handler.go +++ b/handler.go @@ -46,6 +46,14 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { for _, middleware := range bot.middlewares { if !middleware.Execute(msgCtx, bot.appData) { + bot.safeEmitEvent(ctx, UpdateHandledEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + FromID: msgCtx.FromID, + ChatID: msgCtx.ChatID, + Duration: time.Since(startTime), + Handled: false, + }) return } } diff --git a/handler_test.go b/handler_test.go index 3b76ebb..f61b4ff 100644 --- a/handler_test.go +++ b/handler_test.go @@ -10,6 +10,7 @@ import ( ) type recordingObserver struct { + received []UpdateReceivedEvent started []HandlerStartedEvent finished []HandlerFinishedEvent errors []ErrorEvent @@ -19,7 +20,9 @@ type recordingObserver struct { retries []PollingRetryEvent } -func (*recordingObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {} +func (o *recordingObserver) OnReceiveUpdate(_ context.Context, ev UpdateReceivedEvent) { + o.received = append(o.received, ev) +} func (o *recordingObserver) OnHandledUpdate(_ context.Context, ev UpdateHandledEvent) { o.handled = append(o.handled, ev) } @@ -587,6 +590,44 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { } } +func TestHandleObserverCompletesUpdateWhenBotMiddlewareBlocks(t *testing.T) { + observer := &recordingObserver{} + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + observer: observer, + middlewares: []Middleware[NoData]{ + NewMiddleware("block", func(ctx *MsgContext, db NoData) bool { + return false + }), + }, + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 8, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Date: 1, + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, + Text: "/start", + }, + }) + + if len(observer.received) != 1 { + t.Fatalf("expected one received event, got %d", len(observer.received)) + } + if len(observer.handled) != 1 { + t.Fatalf("expected one handled event, got %d", len(observer.handled)) + } + got := observer.handled[0] + if got.UpdateID != 8 || got.UpdateType != tgapi.UpdateTypeMessage || got.ChatID != 42 || got.Handled { + t.Fatalf("unexpected handled event: %#v", got) + } + if len(observer.started) != 0 || len(observer.finished) != 0 || len(observer.errors) != 0 { + t.Fatalf("middleware block should not emit handler lifecycle or errors: started=%d finished=%d errors=%d", len(observer.started), len(observer.finished), len(observer.errors)) + } +} + func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { observer := &recordingObserver{} called := false diff --git a/tgapi/api.go b/tgapi/api.go index ee09625..74d67ab 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -64,10 +64,13 @@ func (opts *APIOpts) SetAPIURL(apiURL string) *APIOpts { return opts } +// SetLogFormat sets the output format used by API-managed loggers. func (opts *APIOpts) SetLogFormat(format utils.LogFormat) *APIOpts { opts.logFormat = format return opts } + +// SetLogFormatter sets the formatter used by API-managed logger writers. func (opts *APIOpts) SetLogFormatter(formatter *sneklog.Formatter) *APIOpts { opts.logFormatter = formatter return opts diff --git a/utils/utils.go b/utils/utils.go index cc94244..a0a7570 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,10 +6,13 @@ import ( "git.scuroneko.dev/scuroneko/sneklog/v2" ) +// LogFormat selects the writer format used by framework loggers. type LogFormat string const ( + // LogFormatText writes human-readable text logs. LogFormatText LogFormat = "text" + // LogFormatJSON writes structured JSON logs. LogFormatJSON LogFormat = "json" ) @@ -22,8 +25,8 @@ func GetLoggerLevel() sneklog.LogLevel { return level } -// CreateLogger creates a logger with the shared default policy: -// JSON stdout output, provided prefix, and provided level. +// CreateLogger creates a logger with stdout output, the provided name, level, +// format, and optional formatter. func CreateLogger( name string, level sneklog.LogLevel, format LogFormat, formatter *sneklog.Formatter, diff --git a/utils/version.go b/utils/version.go index abd6b55..aa4a98a 100644 --- a/utils/version.go +++ b/utils/version.go @@ -2,7 +2,7 @@ package utils const ( // VersionString is the module version string. - VersionString = "1.0.0-rc.16" + VersionString = "1.0.0" // VersionMajor is the module major version. VersionMajor = 1 // VersionMinor is the module minor version. @@ -10,5 +10,5 @@ const ( // VersionPatch is the module patch version. VersionPatch = 0 // VersionBeta is the prerelease counter for the current version. - VersionBeta = 16 + VersionBeta = 0 ) -- 2.54.0 From b123709f284e982ec8d471455d4d4d224c5132d4 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 29 Apr 2026 16:36:25 +0300 Subject: [PATCH 86/99] (fix): msg context upsert (tests): callback upsert coverage (doc): changelog rules --- AGENTS.md | 5 ++- CHANGELOG.md | 6 ++++ msg_context.go | 36 ++++++++++++++++--- msg_context_test.go | 84 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae5a585..2d4ac66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,9 @@ Prefer the repository’s documented commands. If multiple choices exist, use th - Changes made only in `AGENTS.md` must not be added to `CHANGELOG.md`. - Add changes only to the section for the next version after the latest published git tag. - The agent must check the latest published tag, `CHANGELOG.md`, and `utils/version.go` before editing the changelog. +- Before editing `CHANGELOG.md`, the agent must inspect the full diff between the latest published tag and the current worktree, for example `git diff --name-status -- .` and targeted `git diff -- `. +- Changelog entries must be based on all user-visible changes present between the latest published tag and the current files, including earlier uncommitted or pre-existing worktree changes, not only changes made in the current turn. +- The agent must not add changelog entries for changes that are not present in the diff from the latest published tag, and must remove or rewrite stale entries that no longer match that diff. - The agent must verify that the target changelog version matches the version declared in `utils/version.go`. - If the latest published tag is, for example, `v1.0.0`, and `CHANGELOG.md` does not yet contain the next version section, the agent must stop and ask the user which version the change belongs to: 1. `v1.0.1` @@ -103,7 +106,7 @@ Prefer the repository’s documented commands. If multiple choices exist, use th 3. `v2.0.0` - The agent must not guess the next version when that section is missing. - If the user-selected version does not match `utils/version.go`, the agent must warn about the mismatch and require the version file to be updated before proceeding. -- Changelog entries must describe all user-visible behavior changes made in the turn, including API additions, fixes, behavior changes, and breaking changes. +- Changelog entries must describe all user-visible behavior changes in the diff from the latest published tag, including API additions, fixes, behavior changes, and breaking changes. - When a framework backlog item recorded in `TODO.md` is completed, the agent must also update the backlog status using the existing format: 1. move the completed item into the top of the `Done` section; 2. replace the numbered backlog label with a version tag, for example `1. Scene Model` becomes `[v2.0.0] Scene Model`; diff --git a/CHANGELOG.md b/CHANGELOG.md index b99d636..035e2eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,19 @@ ## v1.0.0 +### Added +- Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code. +- Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. + ### Changed +- Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. - Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced. - `BotOpts`, `tgapi.APIOpts`, and logger utility godoc now document `LOG_FORMAT`, `LogFormat`, and logger formatting options consistently. ### Tests - Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. - Added webhook runtime regression coverage for request enqueue through worker execution of a command handler. +- Added regression coverage for inline callback keyboard upserts and callback target detection. ## v1.0.0-rc.16 diff --git a/msg_context.go b/msg_context.go index 841d7ec..a63bb3b 100644 --- a/msg_context.go +++ b/msg_context.go @@ -756,10 +756,7 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { return ErrCantFindSession } - session := SceneSession{ - Scene: scene.Name, - Step: step, - } + session := SceneSession{Scene: scene.Name, Step: step} return ctx.sceneRuntime.setSession(key, session) } @@ -790,3 +787,34 @@ func (ctx *MsgContext) ExitScene() error { return ctx.sceneRuntime.deleteSession(key) } + +// IsCallback reports whether the context belongs to a callback query. +func (ctx *MsgContext) IsCallback() bool { + return ctx.CallbackQueryID != "" || ctx.CallbackMsgID > 0 || ctx.InlineMsgID != "" +} + +// HasPhoto reports whether the current message contains a photo payload. +func (ctx *MsgContext) HasPhoto() bool { + return ctx.Msg != nil && ctx.Msg.Photo.Len() > 0 +} + +func (ctx *MsgContext) upsertKeyboard(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { + if ctx.IsCallback() { + if ctx.HasPhoto() { + ctx.CallbackDelete() + return ctx.answer(text, keyboard, parseMode) + } + return ctx.editCallback(text, keyboard, parseMode) + } + return ctx.answer(text, keyboard, parseMode) +} + +// UpsertKeyboard edits a callback message or sends a new plain-text message with a keyboard. +func (ctx *MsgContext) UpsertKeyboard(text string, keyboard *InlineKeyboard) *AnswerMessage { + return ctx.upsertKeyboard(text, keyboard, tgapi.ParseNone) +} + +// UpsertKeyboardMarkdown edits a callback message or sends a new MarkdownV2 message with a keyboard. +func (ctx *MsgContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { + return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMDV2) +} diff --git a/msg_context_test.go b/msg_context_test.go index 79b43ef..f9f71bb 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -324,6 +324,90 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { } } +func TestIsCallbackIncludesInlineCallbackTargets(t *testing.T) { + tests := []struct { + name string + ctx MsgContext + want bool + }{ + {name: "callback query id", ctx: MsgContext{CallbackQueryID: "cb-1"}, want: true}, + {name: "callback message id", ctx: MsgContext{CallbackMsgID: 12}, want: true}, + {name: "inline message id", ctx: MsgContext{InlineMsgID: "inline-1"}, want: true}, + {name: "not callback", ctx: MsgContext{}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.ctx.IsCallback(); got != tt.want { + t.Fatalf("IsCallback() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUpsertKeyboardEditsInlineCallback(t *testing.T) { + var requests int + var gotPath string + var gotBody map[string]any + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + gotPath = req.URL.Path + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if err := json.Unmarshal(body, &gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MsgContext{ + API: api, + InlineMsgID: "inline-1", + Logger: sneklog.NewLogger(), + } + kb := NewInlineKeyboardJSON(1).AddCallbackButton("A", "cmd") + + answer := ctx.UpsertKeyboard("updated", kb) + if answer == nil { + t.Fatal("expected answer message") + } + if requests != 1 { + t.Fatalf("expected one edit request, got %d", requests) + } + if gotPath != "/bottoken/editMessageText" { + t.Fatalf("unexpected request path: %s", gotPath) + } + if got := gotBody["inline_message_id"]; got != "inline-1" { + t.Fatalf("unexpected inline_message_id: %v", got) + } + if got := gotBody["text"]; got != "updated" { + t.Fatalf("unexpected text: %v", got) + } + if _, ok := gotBody["reply_markup"]; !ok { + t.Fatal("expected reply_markup in edit request") + } +} + func TestAnswerRejectsEmptyMessage(t *testing.T) { ctx := &MsgContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, -- 2.54.0 From 269ccec0079b5e0a067633f88e45078ae1d1ebb3 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 30 Apr 2026 11:19:46 +0300 Subject: [PATCH 87/99] (new): command groups (refactor): command cloning (tests): command groups (doc): changelog --- CHANGELOG.md | 2 + bot_utils.go | 34 +------- commands.go | 222 ++++++++++++++++++++++++++++++++++++++++++++++++ plugins.go | 177 +++++++------------------------------- plugins_test.go | 80 +++++++++++++++++ scene.go | 18 ++++ 6 files changed, 355 insertions(+), 178 deletions(-) create mode 100644 commands.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 035e2eb..b9d0774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code. - Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. +- Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. @@ -15,6 +16,7 @@ - Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. - Added webhook runtime regression coverage for request enqueue through worker execution of a command handler. - Added regression coverage for inline callback keyboard upserts and callback target detection. +- Added regression coverage for command group prefixing, middleware order, clone behavior, and plugin registration. ## v1.0.0-rc.16 diff --git a/bot_utils.go b/bot_utils.go index 90b2c86..521bacc 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -176,43 +176,15 @@ func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] { } for name, command := range p.commands { - cloned.commands[name] = cloneCommand(command) + cloned.commands[name] = command.clone() } for name, command := range p.payloads { - cloned.payloads[name] = cloneCommand(command) + cloned.payloads[name] = command.clone() } for name, scene := range p.scenes { - cloned.scenes[name] = cloneScene(scene) + cloned.scenes[name] = scene.clone() } maps.Copy(cloned.handlers, p.handlers) return cloned } - -func cloneCommand[T AppData](command *Command[T]) *Command[T] { - if command == nil { - return nil - } - - cloned := *command - cloned.args = append(extypes.Slice[CommandArg](nil), command.args...) - cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...) - return &cloned -} - -func cloneScene[T AppData](scene *Scene[T]) *Scene[T] { - if scene == nil { - return nil - } - - cloned := *scene - cloned.steps = make(map[string]SceneHandler[T], len(scene.steps)) - cloned.commands = make(map[string]SceneHandler[T], len(scene.commands)) - cloned.payloads = make(map[string]SceneHandler[T], len(scene.payloads)) - - maps.Copy(cloned.steps, scene.steps) - maps.Copy(cloned.commands, scene.commands) - maps.Copy(cloned.payloads, scene.payloads) - - return &cloned -} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..d98fd79 --- /dev/null +++ b/commands.go @@ -0,0 +1,222 @@ +package laniakea + +import ( + "errors" + "fmt" + "regexp" + + "git.scuroneko.dev/scuroneko/extypes" +) + +// CommandValueType defines the expected type of command argument. +type CommandValueType string + +const ( + // CommandValueStringType expects any non-empty string. + CommandValueStringType CommandValueType = "string" + // CommandValueIntType expects a decimal integer (digits only). + CommandValueIntType CommandValueType = "int" + // CommandValueBoolType expects a exact "true" or "false". + CommandValueBoolType CommandValueType = "bool" + // CommandValueAnyType accepts any input without validation. + CommandValueAnyType CommandValueType = "any" +) + +var ( + // CommandRegexInt matches one or more digits. + CommandRegexInt = regexp.MustCompile(`^\d+$`) + // CommandRegexString matches any non-empty string. + CommandRegexString = regexp.MustCompile(`^.+$`) + // CommandRegexBool matches true or false. + CommandRegexBool = regexp.MustCompile(`^(true|false)$`) +) + +// ErrCmdArgCountMismatch is returned when the number of provided arguments +// is less than the number of required arguments. +var ErrCmdArgCountMismatch = errors.New("command arg count mismatch") + +// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation. +var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") + +var ( + errCommandNotFound = errors.New("command not found") + errPayloadNotFound = errors.New("payload not found") +) + +// CommandArg defines a single argument for a command, including type, regex, +// and whether it is required. +type CommandArg struct { + valueType CommandValueType // Type of expected value + text string // Human-readable description (not used in validation) + regex *regexp.Regexp // Regex used to validate input + required bool // Whether this argument must be provided +} + +// NewCommandArg creates a new CommandArg with the given text and type. +// Uses a default regex based on the type (string or int). +// For CommandValueAnyType, no validation is performed. +func NewCommandArg(text string) CommandArg { + return CommandArg{CommandValueAnyType, text, CommandRegexString, false} +} + +// SetValueType sets expected value type and switches built-in validation regexp. +func (c CommandArg) SetValueType(t CommandValueType) CommandArg { + regex := CommandRegexString + switch t { + case CommandValueIntType: + regex = CommandRegexInt + case CommandValueBoolType: + regex = CommandRegexBool + case CommandValueAnyType: + regex = nil // Skip validation + } + c.valueType = t + c.regex = regex + return c +} + +// SetRequired marks this argument as required. +// Returns the receiver for method chaining. +func (c CommandArg) SetRequired() CommandArg { + c.required = true + return c +} + +// CommandExecutor is the function type that executes a command. +// It receives the message context and injected application data. +// Returning a non-nil error routes it through the bot's error handler. +type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error + +// Command represents a bot command with arguments, description, and executor. +// Can be registered in a Plugin and optionally skipped from auto-generation. +type Command[T AppData] struct { + command string // The command trigger (e.g., "/start") + description string // Human-readable description for help + exec CommandExecutor[T] // Function to execute when command is triggered + args extypes.Slice[CommandArg] // List of expected arguments + middlewares extypes.Slice[Middleware[T]] // Optional middleware chain + skipAutoCmd bool // If true, this command won't be auto-added to help menus +} + +// NewCommand creates a new Command with the given executor, command string, and arguments. +// The command string should not include the leading slash (e.g., "start", not "/start"). +func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { + return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} +} + +// NewPayload creates a new Command with the given executor, command payload string, and arguments. +// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. +func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { + return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} +} + +// Use adds a middleware to the command's execution chain. +// Middlewares are executed in the order they are added. +func (c *Command[T]) Use(m Middleware[T]) *Command[T] { + c.middlewares = c.middlewares.Push(m) + return c +} + +// SetDescription sets the human-readable description of the command. +func (c *Command[T]) SetDescription(desc string) *Command[T] { + c.description = desc + return c +} + +// SkipCommandAutoGen marks this command to be excluded from auto-generated help menus. +func (c *Command[T]) SkipCommandAutoGen() *Command[T] { + c.skipAutoCmd = true + return c +} + +// Internal helper that validates provided command arguments. +func (c *Command[T]) validateArgs(args []string) error { + for i := range c.args.Len() { + if i >= len(args) && c.args.Get(i).required { + return ErrCmdArgCountMismatch + } + } + + // Validate each argument against its regex + for i, arg := range args { + if i >= c.args.Len() { + // Extra arguments beyond defined args are ignored + break + } + cmdArg := c.args.Get(i) + if cmdArg.regex == nil { + continue // Skip validation for CommandValueAnyType + } + if !cmdArg.regex.MatchString(arg) { + return ErrCmdArgRegexpMismatch + } + } + return nil +} + +func (c *Command[T]) clone() *Command[T] { + if c == nil { + return nil + } + + cloned := *c + cloned.args = append(extypes.Slice[CommandArg](nil), c.args...) + cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), c.middlewares...) + return &cloned +} + +// CommandGroup builds a set of commands with a shared name prefix and middleware. +type CommandGroup[T any] struct { + prefix string + separator string + + middlewares extypes.Slice[Middleware[T]] + commands extypes.Slice[*Command[T]] +} + +// NewCommandGroup creates a command group that prefixes every added command. +func NewCommandGroup[T any](prefix string) *CommandGroup[T] { + return &CommandGroup[T]{ + prefix: prefix, separator: "", + + middlewares: make([]Middleware[T], 0), + commands: make([]*Command[T], 0), + } +} + +// SetSeparator sets the text inserted between the group prefix and command name. +func (g *CommandGroup[T]) SetSeparator(separator string) *CommandGroup[T] { + g.separator = separator + return g +} + +// Use adds middleware that runs before each command's own middleware. +func (g *CommandGroup[T]) Use(m Middleware[T]) *CommandGroup[T] { + g.middlewares = append(g.middlewares, m) + return g +} + +// AddCommand adds a prefixed copy of cmd to the group. +func (g *CommandGroup[T]) AddCommand(cmd *Command[T]) *CommandGroup[T] { + if cmd == nil { + return g + } + newCmd := cmd.clone() + newCmd.command = fmt.Sprintf("%s%s%s", g.prefix, g.separator, cmd.command) + g.commands = g.commands.Push(newCmd) + return g +} + +// Build returns command copies with group middleware prepended. +func (g *CommandGroup[T]) Build() []*Command[T] { + commands := make([]*Command[T], 0) + for _, cmd := range g.commands { + cloned := cmd.clone() + cloned.middlewares = append( + append(extypes.Slice[Middleware[T]]{}, g.middlewares...), + cloned.middlewares..., + ) + commands = append(commands, cloned) + } + return commands +} diff --git a/plugins.go b/plugins.go index ac001c0..a51e4bc 100644 --- a/plugins.go +++ b/plugins.go @@ -2,7 +2,6 @@ package laniakea import ( "errors" - "regexp" "git.scuroneko.dev/scuroneko/extypes" "git.scuroneko.dev/scuroneko/laniakea/tgapi" @@ -10,152 +9,6 @@ import ( "git.scuroneko.dev/scuroneko/sneklog/v2" ) -// CommandValueType defines the expected type of command argument. -type CommandValueType string - -const ( - // CommandValueStringType expects any non-empty string. - CommandValueStringType CommandValueType = "string" - // CommandValueIntType expects a decimal integer (digits only). - CommandValueIntType CommandValueType = "int" - // CommandValueBoolType expects a exact "true" or "false". - CommandValueBoolType CommandValueType = "bool" - // CommandValueAnyType accepts any input without validation. - CommandValueAnyType CommandValueType = "any" -) - -var ( - // CommandRegexInt matches one or more digits. - CommandRegexInt = regexp.MustCompile(`^\d+$`) - // CommandRegexString matches any non-empty string. - CommandRegexString = regexp.MustCompile(`^.+$`) - // CommandRegexBool matches true or false. - CommandRegexBool = regexp.MustCompile(`^(true|false)$`) -) - -// ErrCmdArgCountMismatch is returned when the number of provided arguments -// is less than the number of required arguments. -var ErrCmdArgCountMismatch = errors.New("command arg count mismatch") - -// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation. -var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") - -var ( - errCommandNotFound = errors.New("command not found") - errPayloadNotFound = errors.New("payload not found") -) - -// CommandArg defines a single argument for a command, including type, regex, -// and whether it is required. -type CommandArg struct { - valueType CommandValueType // Type of expected value - text string // Human-readable description (not used in validation) - regex *regexp.Regexp // Regex used to validate input - required bool // Whether this argument must be provided -} - -// NewCommandArg creates a new CommandArg with the given text and type. -// Uses a default regex based on the type (string or int). -// For CommandValueAnyType, no validation is performed. -func NewCommandArg(text string) CommandArg { - return CommandArg{CommandValueAnyType, text, CommandRegexString, false} -} - -// SetValueType sets expected value type and switches built-in validation regexp. -func (c CommandArg) SetValueType(t CommandValueType) CommandArg { - regex := CommandRegexString - switch t { - case CommandValueIntType: - regex = CommandRegexInt - case CommandValueBoolType: - regex = CommandRegexBool - case CommandValueAnyType: - regex = nil // Skip validation - } - c.valueType = t - c.regex = regex - return c -} - -// SetRequired marks this argument as required. -// Returns the receiver for method chaining. -func (c CommandArg) SetRequired() CommandArg { - c.required = true - return c -} - -// CommandExecutor is the function type that executes a command. -// It receives the message context and injected application data. -// Returning a non-nil error routes it through the bot's error handler. -type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error - -// Command represents a bot command with arguments, description, and executor. -// Can be registered in a Plugin and optionally skipped from auto-generation. -type Command[T AppData] struct { - command string // The command trigger (e.g., "/start") - description string // Human-readable description for help - exec CommandExecutor[T] // Function to execute when command is triggered - args extypes.Slice[CommandArg] // List of expected arguments - middlewares extypes.Slice[Middleware[T]] // Optional middleware chain - skipAutoCmd bool // If true, this command won't be auto-added to help menus -} - -// NewCommand creates a new Command with the given executor, command string, and arguments. -// The command string should not include the leading slash (e.g., "start", not "/start"). -func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} -} - -// NewPayload creates a new Command with the given executor, command payload string, and arguments. -// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. -func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} -} - -// Use adds a middleware to the command's execution chain. -// Middlewares are executed in the order they are added. -func (c *Command[T]) Use(m Middleware[T]) *Command[T] { - c.middlewares = c.middlewares.Push(m) - return c -} - -// SetDescription sets the human-readable description of the command. -func (c *Command[T]) SetDescription(desc string) *Command[T] { - c.description = desc - return c -} - -// SkipCommandAutoGen marks this command to be excluded from auto-generated help menus. -func (c *Command[T]) SkipCommandAutoGen() *Command[T] { - c.skipAutoCmd = true - return c -} - -// Internal helper that validates provided command arguments. -func (c *Command[T]) validateArgs(args []string) error { - for i := range c.args.Len() { - if i >= len(args) && c.args.Get(i).required { - return ErrCmdArgCountMismatch - } - } - - // Validate each argument against its regex - for i, arg := range args { - if i >= c.args.Len() { - // Extra arguments beyond defined args are ignored - break - } - cmdArg := c.args.Get(i) - if cmdArg.regex == nil { - continue // Skip validation for CommandValueAnyType - } - if !cmdArg.regex.MatchString(arg) { - return ErrCmdArgRegexpMismatch - } - } - return nil -} - // Plugin represents a collection of commands and payloads (e.g., callback handlers), // with shared middleware and configuration. // @@ -225,6 +78,36 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { return p } +// CommandGroup configures and registers a prefixed command group. +func (p *Plugin[T]) CommandGroup(prefix string, groupFunc func(group *CommandGroup[T])) *Plugin[T] { + if groupFunc == nil { + return p + } + group := NewCommandGroup[T](prefix) + groupFunc(group) + if len(group.commands) == 0 { + return p + } + for _, cmd := range group.Build() { + p.AddCommand(cmd) + } + return p +} + +// AddCommandGroup registers every command built by group. +func (p *Plugin[T]) AddCommandGroup(group *CommandGroup[T]) *Plugin[T] { + if group == nil { + return p + } + if len(group.commands) == 0 { + return p + } + for _, cmd := range group.Build() { + p.AddCommand(cmd) + } + return p +} + // NewPayload creates and immediately adds a new payload command to the plugin. // Returns the created payload command for further configuration. func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { diff --git a/plugins_test.go b/plugins_test.go index 1518cf3..a3e0699 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -38,3 +38,83 @@ func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { t.Fatalf("expected both args to validate, got %v", err) } } + +func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) { + groupMiddleware := NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true }) + commandMiddleware := NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }) + cmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"). + SetDescription("Ban user"). + Use(commandMiddleware) + + group := NewCommandGroup[NoData]("admin"). + SetSeparator("_"). + Use(groupMiddleware). + AddCommand(cmd) + + built := group.Build() + if len(built) != 1 { + t.Fatalf("expected one command, got %d", len(built)) + } + + grouped := built[0] + if grouped.command != "admin_ban" { + t.Fatalf("expected prefixed command name, got %q", grouped.command) + } + if grouped.description != "Ban user" { + t.Fatalf("expected description to be copied, got %q", grouped.description) + } + if cmd.command != "ban" { + t.Fatalf("expected original command name to stay unchanged, got %q", cmd.command) + } + if len(cmd.middlewares) != 1 || cmd.middlewares[0].name != "command" { + t.Fatalf("expected original command middleware to stay unchanged, got %#v", cmd.middlewares) + } + if len(grouped.middlewares) != 2 { + t.Fatalf("expected group and command middleware, got %d", len(grouped.middlewares)) + } + if grouped.middlewares[0].name != "group" || grouped.middlewares[1].name != "command" { + t.Fatalf("expected group middleware before command middleware, got %q then %q", grouped.middlewares[0].name, grouped.middlewares[1].name) + } +} + +func TestCommandGroupBuildIsRepeatable(t *testing.T) { + group := NewCommandGroup[NoData]("admin"). + Use(NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })). + AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"). + Use(NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }))) + + first := group.Build() + second := group.Build() + + if len(first) != 1 || len(second) != 1 { + t.Fatalf("expected one command from each build, got %d and %d", len(first), len(second)) + } + if len(first[0].middlewares) != 2 { + t.Fatalf("expected first build to have two middlewares, got %d", len(first[0].middlewares)) + } + if len(second[0].middlewares) != 2 { + t.Fatalf("expected second build to have two middlewares, got %d", len(second[0].middlewares)) + } + if first[0] == second[0] { + t.Fatal("expected repeated Build calls to return distinct command copies") + } +} + +func TestPluginCommandGroupRegistersBuiltCommands(t *testing.T) { + plugin := NewPlugin[NoData]("admin") + + plugin.CommandGroup("admin", func(group *CommandGroup[NoData]) { + group.SetSeparator("_") + group.AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban")) + }) + + if _, ok := plugin.commands["admin_ban"]; !ok { + t.Fatal("expected plugin to register prefixed command") + } + if _, ok := plugin.commands["ban"]; ok { + t.Fatal("expected plugin not to register unprefixed command") + } + + plugin.CommandGroup("ignored", nil) + plugin.AddCommandGroup(nil) +} diff --git a/scene.go b/scene.go index c3ec067..a868d31 100644 --- a/scene.go +++ b/scene.go @@ -2,6 +2,7 @@ package laniakea import ( "encoding/json" + "maps" "sync" ) @@ -111,6 +112,23 @@ func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, e return result, true, err } +func (s *Scene[T]) clone() *Scene[T] { + if s == nil { + return nil + } + + cloned := *s + cloned.steps = make(map[string]SceneHandler[T], len(s.steps)) + cloned.commands = make(map[string]SceneHandler[T], len(s.commands)) + cloned.payloads = make(map[string]SceneHandler[T], len(s.payloads)) + + maps.Copy(cloned.steps, s.steps) + maps.Copy(cloned.commands, s.commands) + maps.Copy(cloned.payloads, s.payloads) + + return &cloned +} + // SceneSession stores the active scene state for one session key. type SceneSession struct { // Scene is the registered scene name for the active session. -- 2.54.0 From 071fc2375e8d4560daca841c57664f0dadc78450 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 30 Apr 2026 13:56:36 +0300 Subject: [PATCH 88/99] (draft): telegram markdown v2 string builder --- CHANGELOG.md | 14 +- README.md | 36 +-- README_RU.md | 36 +-- bot.go | 42 ++-- bot_opts.go | 16 +- bot_opts_loader.go | 13 +- bot_opts_loader_test.go | 30 +-- bot_register.go | 2 +- bot_test.go | 6 +- bot_webhook.go | 126 +++++------ bot_webhook_test.go | 61 +++-- cmd_generator.go | 6 +- cmd_generator_test.go | 8 +- commands.go | 58 +++-- doc.go | 2 +- handler_test.go | 44 ++-- keyboard.go | 28 +-- keyboard_test.go | 2 +- msg_context.go | 42 ++-- plugins.go | 64 +++--- plugins_test.go | 18 +- runners.go | 82 +++---- runners_test.go | 8 +- scene_test.go | 36 +-- tgapi/api.go | 4 +- tgapi/chat_methods.go | 8 +- tgapi/messages_types.go | 2 +- tgapi/methods_types.go | 8 +- tgapi/parse_mode_test.go | 2 +- tgmd/doc.go | 2 + tgmd/message_builder.go | 295 +++++++++++++++++++++++++ tgmd/message_builder_test.go | 416 +++++++++++++++++++++++++++++++++++ tgmd/utils.go | 71 ++++++ tgmd/utils_test.go | 24 ++ 34 files changed, 1227 insertions(+), 385 deletions(-) create mode 100644 tgmd/doc.go create mode 100644 tgmd/message_builder.go create mode 100644 tgmd/message_builder_test.go create mode 100644 tgmd/utils.go create mode 100644 tgmd/utils_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d0774..82879cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,27 @@ ## v1.0.0 +### Breaking Changes +- Renamed final public APIs to idiomatic names before the stable release: `RunWebhookWithContext(...)`, `RunWebhook(...)`, `CloseWebhook()`, `BotWebhookOpts`, `NewBotWebhookOpts()`, `SetWebhookLogger(...)`, and `GetWebhookLogger()`. +- Renamed plugin builder helpers from `NewCommand(...)`, `NewPayload(...)`, and `NewScene(...)` to `Command(...)`, `Payload(...)`, and `Scene(...)`; `NewCommand(...)` and `NewPayload(...)` now take the command string before the executor. +- Renamed command argument value constants to `CommandValueString`, `CommandValueInt`, `CommandValueBool`, and `CommandValueAny`; `NewCommandArg(...)` now defaults to unvalidated `CommandValueAny`. +- Renamed runner builders from `Onetime(...)` and `Timeout(...)` to `Once(...)` and `Every(...)`. +- Renamed remaining public acronym/casing outliers including `AnswerCallback...`, `ParseMarkdownV2`, `ParseMarkdown`, `GetChatMemberCount`, `DropRateLimitOverflow`, `SetDropRateLimitOverflow`, and inline keyboard builder APIs. + ### Added - Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code. - Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. - Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware. +- Added the `tgmd` package with Telegram Markdown formatting helpers and a message entity builder. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. - Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced. -- `BotOpts`, `tgapi.APIOpts`, and logger utility godoc now document `LOG_FORMAT`, `LogFormat`, and logger formatting options consistently. +- `BotOpts`, `tgapi.APIOpts`, logger utilities, README, and wiki pages now document the final stable API names and configuration options consistently. + +### Fixed +- Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized. +- Fixed webhook startup so a logger configured through `SetWebhookLogger(...)` is preserved. ### Tests - Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. diff --git a/README.md b/README.md index 09a8cd6..91cf0c1 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s * **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling). * **Context-Aware:** Pass custom application data or state contexts to your handlers. * **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`). -* **Polling and Webhook Runtime:** Run bots through long polling with `Run()` / `RunWithContext(...)` or through a bot-owned webhook server with `RunWebHookWithContext(...)`. +* **Polling and Webhook Runtime:** Run bots through long polling with `Run()` / `RunWithContext(...)` or through a bot-owned webhook server with `RunWebhookWithContext(...)`. --- @@ -80,15 +80,15 @@ func main() { p := laniakea.NewPlugin[laniakea.NoData]("ping") // 4. Add a command to the plugin. - // p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command. - p.AddCommand(p.NewCommand(echo, "echo")) + // p.Command("echo", echo) creates a command that triggers the 'echo' function on the "/echo" command. + p.Command("echo", echo) // 5. Add another command using an anonymous function (closure). // This command simply replies "Pong" when the user sends "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error { + p.Command("ping", func(ctx *laniakea.MsgContext, data laniakea.NoData) error { ctx.Answer("Pong") return nil - }, "ping")) + }) // 6. Configure the bot with a custom error template and add the plugin. // SetErrorTemplate sets a format string for errors (where %s will be replaced by the actual error). @@ -112,13 +112,13 @@ func main() { 1. `BotOpts`: Holds configuration like the API token. 2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData if you don't need it. 3. `NewPlugin`: Creates a logical group for commands and middlewares. -4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash). +4. `Command`: Creates and registers a command. The first argument is the command name without the slash, the second is the handler function (`func(*MsgContext, T) error`). 5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling. 6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails. -9. `RunWebHookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling. -10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebHookWithContext()` returns, create a new bot instance for the next session. +9. `RunWebhookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling. +10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebhookWithContext()` returns, create a new bot instance for the next session. ## File-Based Config @@ -151,7 +151,7 @@ See the full guide in the wiki: [Bot Options and Configuration](https://git.scur ## Webhook Runtime -Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`. +Laniakea also supports a bot-owned webhook runtime through `RunWebhookWithContext(...)` and `RunWebhook(...)`. Use it when: - Telegram should push updates to your HTTP endpoint instead of your bot polling for them. @@ -159,11 +159,11 @@ Use it when: - You want Laniakea to register the webhook and own the local HTTP server. Production notes: -- Set `BotWebHookOpts.SecretToken` for request authentication. -- `BotWebHookOpts.SecretToken` is required when `BotWebHookOpts.UseStatusPath` is enabled. -- Keep `BotWebHookOpts.Path` specific instead of serving webhook traffic on `/`. -- If you switch an existing deployment from webhook mode to long polling, delete the webhook first with `CloseWebHook()` or `tgapi.DeleteWebhook(...)`. Telegram keeps webhook delivery active until it is removed. -- Use `RunWebHookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown. +- Set `BotWebhookOpts.SecretToken` for request authentication. +- `BotWebhookOpts.SecretToken` is required when `BotWebhookOpts.UseStatusPath` is enabled. +- Keep `BotWebhookOpts.Path` specific instead of serving webhook traffic on `/`. +- If you switch an existing deployment from webhook mode to long polling, delete the webhook first with `CloseWebhook()` or `tgapi.DeleteWebhook(...)`. Telegram keeps webhook delivery active until it is removed. +- Use `RunWebhookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown. See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime) @@ -173,7 +173,7 @@ See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/Scur Plugins are the main way to organize code. A plugin can have multiple commands and middlewares. ```go plugin := laniakea.NewPlugin[*MyDB]("admin") -plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +plugin.Command("ban", banUser) bot.AddPlugins(plugin) ``` @@ -238,7 +238,7 @@ Scenes model multi-step conversations inside a plugin. Each active scene is stor ```go plugin := laniakea.NewPlugin[MyDB]("signup") -plugin.NewScene("signup"). +plugin.Scene("signup"). SetScope(laniakea.SceneScopeUserChat). SetEntry("ask_name"). OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) { @@ -288,7 +288,7 @@ Use `AddMiddleware` on a plugin to add one or more shared middleware functions. plugin := laniakea.NewPlugin[*MyDB]("admin") plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) -plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +plugin.Command("ban", banUser) ``` ### Example Middlewares @@ -320,7 +320,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Localization**: `L10n` is safe for concurrent use once attached to the bot. - **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow. -- **Lifecycle**: `RunWithContext(...)` and `RunWebHookWithContext(...)` do not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run. +- **Lifecycle**: `RunWithContext(...)` and `RunWebhookWithContext(...)` do not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run. ## Telegram Update Handling - Commands and payloads are handled through plugins. diff --git a/README_RU.md b/README_RU.md index a9595f5..8e22937 100644 --- a/README_RU.md +++ b/README_RU.md @@ -24,7 +24,7 @@ * **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). * **Контекст данных:** Передавайте общие данные приложения или state в обработчики. * **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`. -* **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebHookWithContext(...)`. +* **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebhookWithContext(...)`. --- @@ -81,15 +81,15 @@ func main() { p := laniakea.NewPlugin[laniakea.NoData]("ping") // 4. Добавляем команду в плагин. - // p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo". - p.AddCommand(p.NewCommand(echo, "echo")) + // p.Command("echo", echo) создаёт команду, которая вызывает функцию 'echo' по команде "/echo". + p.Command("echo", echo) // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". - p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error { + p.Command("ping", func(ctx *laniakea.MsgContext, data laniakea.NoData) error { ctx.Answer("Pong") return nil - }, "ping")) + }) // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. // SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки). @@ -113,13 +113,13 @@ func main() { 1. `BotOpts`: Содержит конфигурацию, например, токен API. 2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. -4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша). +4. `Command`: Создаёт и регистрирует команду. Первый аргумент — имя команды без слеша, второй — функция-обработчик (`func(*MsgContext, T) error`). 5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки. 6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. -9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling. -10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот. +9. `RunWebhookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling. +10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebhookWithContext()` для следующего запуска создавайте новый бот. ## Конфиг из файла @@ -152,7 +152,7 @@ if err != nil { ## Webhook Runtime -Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`. +Laniakea также поддерживает bot-owned webhook runtime через `RunWebhookWithContext(...)` и `RunWebhook(...)`. Используй его, когда: - Telegram должен сам отправлять update на твой HTTP endpoint вместо polling. @@ -160,11 +160,11 @@ Laniakea также поддерживает bot-owned webhook runtime чере - Ты хочешь, чтобы Laniakea сама регистрировала webhook и владела локальным HTTP server. Практические замечания: -- Задавай `BotWebHookOpts.SecretToken` для аутентификации запросов. -- Непустой `BotWebHookOpts.SecretToken` обязателен, если включён `BotWebHookOpts.UseStatusPath`. -- Используй явный `BotWebHookOpts.Path`, а не `/`. -- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebHook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него. -- Запускай `RunWebHookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`. +- Задавай `BotWebhookOpts.SecretToken` для аутентификации запросов. +- Непустой `BotWebhookOpts.SecretToken` обязателен, если включён `BotWebhookOpts.UseStatusPath`. +- Используй явный `BotWebhookOpts.Path`, а не `/`. +- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebhook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него. +- Запускай `RunWebhookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`. Полное руководство есть в wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime-RU) @@ -174,7 +174,7 @@ Laniakea также поддерживает bot-owned webhook runtime чере ```go plugin := laniakea.NewPlugin[*MyDB]("admin") -plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +plugin.Command("ban", banUser) bot.AddPlugins(plugin) ``` @@ -226,7 +226,7 @@ bot.SetAppData(db) ```go plugin := laniakea.NewPlugin[MyDB]("signup") -plugin.NewScene("signup"). +plugin.Scene("signup"). SetScope(laniakea.SceneScopeUserChat). SetEntry("ask_name"). OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) { @@ -285,7 +285,7 @@ func(ctx *MsgContext, db T) bool plugin := laniakea.NewPlugin[*MyDB]("admin") plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware)) plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware)) -plugin.AddCommand(plugin.NewCommand(banUser, "ban")) +plugin.Command("ban", banUser) ``` ### Примеры middleware @@ -317,7 +317,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. - **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. -- **Жизненный цикл**: `RunWithContext(...)` и `RunWebHookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. +- **Жизненный цикл**: `RunWithContext(...)` и `RunWebhookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска. ## Обработка Telegram Updates - Команды и payload-ы обрабатываются через плагины. diff --git a/bot.go b/bot.go index 7f3a8ff..782403b 100644 --- a/bot.go +++ b/bot.go @@ -59,7 +59,7 @@ var ( ErrNoPrefixes = errors.New("no prefixes defined") // ErrNoPlugins reports that the bot was started without any registered plugins. ErrNoPlugins = errors.New("no plugins defined") - // ErrBotAlreadyRun reports that Run, RunWithContext, or RunWebHookWithContext was called more than once. + // ErrBotAlreadyRun reports that Run, RunWithContext, or RunWebhookWithContext was called more than once. ErrBotAlreadyRun = errors.New("bot can only be run once") // ErrTokenRequired reports that BotOpts.Token was empty. @@ -78,8 +78,8 @@ var ( // - Localization and draft message support // // Runtime accessors are safe for concurrent use. Configure the bot before Run, -// RunWithContext, or RunWebHookWithContext. -// A Bot is single-use: after Run, RunWithContext, or RunWebHookWithContext returns, +// RunWithContext, or RunWebhookWithContext. +// A Bot is single-use: after Run, RunWithContext, or RunWebhookWithContext returns, // create a new Bot for the next session. type Bot[T AppData] struct { token string @@ -95,7 +95,7 @@ type Bot[T AppData] struct { logger *sneklog.Logger // Main bot logger (JSON stdout + optional file) requestLogger *sneklog.Logger // Optional request-level API logging useReqLogger bool - webHookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext. + webhookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebhookWithContext. extraLoggers extypes.Slice[*sneklog.Logger] // API, Uploader, and custom loggers plugins []Plugin[T] // Command/event handlers @@ -164,7 +164,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { SetAPIURL(opts.APIURL). UseTestServer(opts.UseTestServer). SetLimiter(limiter). - SetLimiterDrop(opts.DropRLOverflow). + SetDropRateLimitOverflow(opts.DropRateLimitOverflow). SetLogFormat(opts.LogFormat). SetLogFormatter(opts.LogFormatter) api := tgapi.NewAPI(apiOpts) @@ -256,12 +256,16 @@ func (bot *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] { return bot } -// SetWebHookLogger replaces the webhook logger. -func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { - bot.webHookLogger = l +// SetWebhookLogger replaces the webhook logger. +func (bot *Bot[T]) SetWebhookLogger(l *sneklog.Logger) *Bot[T] { + bot.webhookLogger = l return bot } +func (bot *Bot[T]) GetAPI() *tgapi.API { return bot.api } + +func (bot *Bot[T]) GetUploader() *tgapi.Uploader { return bot.uploader } + // Close gracefully shuts down bot-owned resources. // // Close shuts down, in order: @@ -272,7 +276,7 @@ func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { // - RequestLogger (if enabled) // - Main logger // -// RunWithContext and RunWebHookWithContext do not call Close automatically. +// RunWithContext and RunWebhookWithContext do not call Close automatically. // The caller is responsible for invoking Close after runtime returns to release // these resources. // @@ -294,11 +298,11 @@ func (bot *Bot[T]) Close() error { e = append(e, err) } } - if bot.webHookLogger != nil { - if err := bot.webHookLogger.Close(); err != nil { + if bot.webhookLogger != nil { + if err := bot.webhookLogger.Close(); err != nil { logCloseErr(err) } - bot.webHookLogger = nil + bot.webhookLogger = nil } if bot.uploader != nil { if err := bot.uploader.Close(); err != nil { @@ -354,8 +358,8 @@ func (bot *Bot[T]) GetLogger() *sneklog.Logger { return bot.logger } // GetRequestLogger returns the request-level logger, if configured. func (bot *Bot[T]) GetRequestLogger() *sneklog.Logger { return bot.requestLogger } -// GetWebHookLogger returns the webhook logger, if configured. -func (bot *Bot[T]) GetWebHookLogger() *sneklog.Logger { return bot.webHookLogger } +// GetWebhookLogger returns the webhook logger, if configured. +func (bot *Bot[T]) GetWebhookLogger() *sneklog.Logger { return bot.webhookLogger } // GetLoggerLevel returns the effective log level derived from the bot's debug // flag. @@ -387,7 +391,7 @@ func (bot *Bot[T]) L10n(lang, key string) string { // - Waits for registered runners to exit // // If you are switching an existing deployment from webhook delivery to polling, -// delete the current webhook first with CloseWebHook or tgapi.DeleteWebhook. +// delete the current webhook first with CloseWebhook or tgapi.DeleteWebhook. // Telegram keeps webhook delivery active until the webhook is removed. // // RunWithContext does not close API, uploader, or logger resources on return. @@ -414,13 +418,13 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { } bot.requestLogger = nil } - if bot.webHookLogger != nil { - bot.logger.Warnln("Bot#webHookLogger present. You shouldn't set this, if ran in Long Polling mode!") - err := bot.webHookLogger.Close() + if bot.webhookLogger != nil { + bot.logger.Warnln("Bot#webhookLogger present. You shouldn't set this, if ran in Long Polling mode!") + err := bot.webhookLogger.Close() if err != nil { bot.logger.Errorln(err) } - bot.webHookLogger = nil + bot.webhookLogger = nil } bot.ExecRunners(ctx) diff --git a/bot_opts.go b/bot_opts.go index 790927d..7e0c793 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -54,9 +54,9 @@ type BotOpts struct { // Telegram allows up to 30 req/s for most bots. Defaults to 30. RateLimit int - // DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing. + // DropRateLimitOverflow drops incoming updates when rate limit is exceeded instead of queuing. // Use this to prioritize responsiveness over reliability. - DropRLOverflow bool + DropRateLimitOverflow bool // StrictPayloadType disables callback payload fallback decoding. // When enabled, the bot accepts only the configured default payload type. @@ -135,9 +135,9 @@ func LoadOptsFromEnv() *BotOpts { UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", APIURL: os.Getenv("API_URL"), - RateLimit: rateLimit, - DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", - StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", + RateLimit: rateLimit, + DropRateLimitOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", + StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", MaxWorkers: maxWorkers, FileConfigVersion: 0, @@ -223,10 +223,10 @@ func (opts *BotOpts) SetRateLimit(limit int) *BotOpts { return opts } -// SetDropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing. +// SetDropRateLimitOverflow drops incoming updates when rate limit is exceeded instead of queuing. // Use this to prioritize responsiveness over reliability. Default is false. -func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts { - opts.DropRLOverflow = drop +func (opts *BotOpts) SetDropRateLimitOverflow(drop bool) *BotOpts { + opts.DropRateLimitOverflow = drop return opts } diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 99f4ba7..4e86993 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -70,10 +70,10 @@ func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) { WriteToFile: fileOpts.Logger.WriteToFile, LogFormat: fileOpts.Logger.LogFormat, - UseTestServer: fileOpts.API.UseTestServer, - APIURL: fileOpts.API.APIURL, - RateLimit: fileOpts.API.RateLimit, - DropRLOverflow: fileOpts.API.DropRLOverflow, + UseTestServer: fileOpts.API.UseTestServer, + APIURL: fileOpts.API.APIURL, + RateLimit: fileOpts.API.RateLimit, + DropRateLimitOverflow: fileOpts.API.DropRLOverflow, StrictPayloadType: fileOpts.StrictPayloadType, MaxWorkers: fileOpts.MaxWorkers, @@ -102,7 +102,7 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) { UseTestServer: opts.UseTestServer, APIURL: opts.APIURL, RateLimit: opts.RateLimit, - DropRLOverflow: opts.DropRLOverflow, + DropRLOverflow: opts.DropRateLimitOverflow, }, StrictPayloadType: opts.StrictPayloadType, MaxWorkers: opts.MaxWorkers, @@ -114,9 +114,12 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) { return data, nil } +// Load reads BotOpts from a JSON config file. func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) { return LoadBotOptsFile(codec, filename) } + +// Save writes BotOpts to a JSON config file. func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error { return SaveBotOptsFile(codec, filename, opts) } diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go index bb8c82d..63419ca 100644 --- a/bot_opts_loader_test.go +++ b/bot_opts_loader_test.go @@ -13,21 +13,21 @@ import ( func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) { codec := BotOptsFileJSONCodec{} want := &BotOpts{ - Token: "TOKEN", - UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, - Debug: true, - ErrorTemplate: "Error: %s", - Prefixes: []string{"/", "!"}, - LoggerBasePath: "/tmp/logs", - UseRequestLogger: true, - WriteToFile: true, - UseTestServer: true, - APIURL: "https://api.example.invalid", - RateLimit: 42, - DropRLOverflow: true, - StrictPayloadType: true, - MaxWorkers: 64, - FileConfigVersion: ConfigVersion, + Token: "TOKEN", + UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, + Debug: true, + ErrorTemplate: "Error: %s", + Prefixes: []string{"/", "!"}, + LoggerBasePath: "/tmp/logs", + UseRequestLogger: true, + WriteToFile: true, + UseTestServer: true, + APIURL: "https://api.example.invalid", + RateLimit: 42, + DropRateLimitOverflow: true, + StrictPayloadType: true, + MaxWorkers: 64, + FileConfigVersion: ConfigVersion, } data, err := codec.ToBytes(want) diff --git a/bot_register.go b/bot_register.go index f12c250..2791d3f 100644 --- a/bot_register.go +++ b/bot_register.go @@ -95,7 +95,7 @@ func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] { // - Scheduled tasks (e.g., daily announcements) // // Runners start from the bot runtime entry points, immediately after -// RunWithContext or RunWebHookWithContext begins. +// RunWithContext or RunWebhookWithContext begins. // // Example: // diff --git a/bot_test.go b/bot_test.go index c35b5fb..9ca08a9 100644 --- a/bot_test.go +++ b/bot_test.go @@ -63,13 +63,13 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { bot := &Bot[NoData]{logger: sneklog.NewLogger()} plugin := NewPlugin[NoData]("demo") - cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start") + cmd := plugin.Command("start", func(ctx *MsgContext, db NoData) error { return nil }) plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })) bot.AddPlugins(plugin) cmd.SetDescription("mutated after registration") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "late") + plugin.Command("late", func(ctx *MsgContext, db NoData) error { return nil }) plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true })) registered := bot.plugins[0] @@ -446,7 +446,7 @@ func TestCloseDoesNotDeleteWebhook(t *testing.T) { bot := &Bot[NoData]{ logger: sneklog.NewLogger(), - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), api: api, uploader: uploader, } diff --git a/bot_webhook.go b/bot_webhook.go index 72ea5cb..6d26778 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -15,8 +15,8 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/utils" ) -// BotWebHookOpts configures Telegram webhook registration and the local HTTP server. -type BotWebHookOpts struct { +// BotWebhookOpts configures Telegram webhook registration and the local HTTP server. +type BotWebhookOpts struct { Path string LocalPort int UseStatusPath bool @@ -30,9 +30,9 @@ type BotWebHookOpts struct { SecretToken string } -// NewBotWebHookOpts returns webhook options with the default path, local port, and max connections. -func NewBotWebHookOpts() *BotWebHookOpts { - return &BotWebHookOpts{ +// NewBotWebhookOpts returns webhook options with the default path, local port, and max connections. +func NewBotWebhookOpts() *BotWebhookOpts { + return &BotWebhookOpts{ Path: "/", LocalPort: 8080, MaxConnections: 40, @@ -40,38 +40,38 @@ func NewBotWebHookOpts() *BotWebHookOpts { } // SetPath sets the local HTTP path that receives Telegram webhook requests. -func (opts *BotWebHookOpts) SetPath(path string) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetPath(path string) *BotWebhookOpts { opts.Path = path return opts } // SetLocalPort sets the local HTTP port used by the webhook server. -func (opts *BotWebHookOpts) SetLocalPort(port int) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetLocalPort(port int) *BotWebhookOpts { opts.LocalPort = port return opts } // SetUseStatusPath enables or disables the optional /status endpoint. // A non-empty SecretToken is required when this endpoint is enabled. -func (opts *BotWebHookOpts) SetUseStatusPath(use bool) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetUseStatusPath(use bool) *BotWebhookOpts { opts.UseStatusPath = use return opts } // SetURL sets the public base URL Telegram should call for incoming updates. -func (opts *BotWebHookOpts) SetURL(url string) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetURL(url string) *BotWebhookOpts { opts.URL = url return opts } // SetCertificate sets the self-signed webhook certificate bytes to upload. -func (opts *BotWebHookOpts) SetCertificate(certificate []byte) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetCertificate(certificate []byte) *BotWebhookOpts { opts.Certificate = certificate return opts } // MustLoadCertificate loads a webhook certificate from disk and panics on failure. -func (opts *BotWebHookOpts) MustLoadCertificate(filename string) *BotWebHookOpts { +func (opts *BotWebhookOpts) MustLoadCertificate(filename string) *BotWebhookOpts { f, err := os.Open(filename) if err != nil { panic(err) @@ -87,37 +87,37 @@ func (opts *BotWebHookOpts) MustLoadCertificate(filename string) *BotWebHookOpts } // SetIPAddress sets the fixed IP address Telegram should use for webhook delivery. -func (opts *BotWebHookOpts) SetIPAddress(ip string) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetIPAddress(ip string) *BotWebhookOpts { opts.IPAddress = ip return opts } // SetMaxConnections sets Telegram's maximum number of simultaneous webhook connections. -func (opts *BotWebHookOpts) SetMaxConnections(max int8) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetMaxConnections(max int8) *BotWebhookOpts { opts.MaxConnections = max return opts } // SetAllowedUpdates sets the Telegram update types that should be delivered to the webhook. -func (opts *BotWebHookOpts) SetAllowedUpdates(updates ...tgapi.UpdateType) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetAllowedUpdates(updates ...tgapi.UpdateType) *BotWebhookOpts { opts.AllowedUpdates = append([]tgapi.UpdateType(nil), updates...) return opts } // SetDropPendingUpdates configures whether Telegram should drop pending updates while setting the webhook. -func (opts *BotWebHookOpts) SetDropPendingUpdates(drop bool) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetDropPendingUpdates(drop bool) *BotWebhookOpts { opts.DropPendingUpdates = drop return opts } // SetSecretToken sets the secret token expected in Telegram webhook requests. // The same token is also required to access /status when that endpoint is enabled. -func (opts *BotWebHookOpts) SetSecretToken(secretToken string) *BotWebHookOpts { +func (opts *BotWebhookOpts) SetSecretToken(secretToken string) *BotWebhookOpts { opts.SecretToken = secretToken return opts } -// RunWebHookWithContext registers the webhook and serves incoming updates until ctx is canceled. +// RunWebhookWithContext registers the webhook and serves incoming updates until ctx is canceled. // // The bot uses the same update queue, worker pool, runner startup, and single-use lifecycle // guarantees as RunWithContext. When opts.AllowedUpdates is empty, the bot-level update types @@ -126,9 +126,9 @@ func (opts *BotWebHookOpts) SetSecretToken(secretToken string) *BotWebHookOpts { // // When two TLS files are provided, the method serves HTTPS using the existing key-then-cert // argument order. -func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOpts, tlsFiles ...string) error { +func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOpts, tlsFiles ...string) error { if opts == nil { - return errors.New("nil BotWebHookOpts") + return errors.New("nil BotWebhookOpts") } if len(bot.prefixes) == 0 { return ErrNoPrefixes @@ -137,44 +137,42 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp return ErrNoPlugins } if opts.URL == "" { - return errors.New("empty BotWebHookOpts.URL") + return errors.New("empty BotWebhookOpts.URL") } if opts.MaxConnections > 100 || opts.MaxConnections <= 0 { - return errors.New("BotWebHookOpts.MaxConnections must between 1 and 100") + return errors.New("BotWebhookOpts.MaxConnections must between 1 and 100") } if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { return err } if opts.UseStatusPath && opts.SecretToken == "" { - return errors.New("BotWebHookOpts.SecretToken required when status path is enabled") + return errors.New("BotWebhookOpts.SecretToken required when status path is enabled") } if err := validateWebhookTLSFiles(tlsFiles); err != nil { return err } - bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter) - bot.addTokenReplacer(bot.webHookLogger) - if opts.SecretToken == "" { - bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") - } - if opts.Certificate != nil && bot.uploader == nil { return errors.New("bot uploader nil, but certificate set") } return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { + if opts.SecretToken == "" { + bot.webhookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") + } + i, err := bot.api.GetWebhookInfoWithContext(runCtx) if err != nil { return err } if i.URL == "" { - bot.webHookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll") + bot.webhookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll") } else { _, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhook{}) if err != nil { return err } - bot.webHookLogger.Infof("Bot webhook deleted: %s", i.URL) + bot.webhookLogger.Infof("Bot webhook deleted: %s", i.URL) } allowedUpdates := bot.webhookAllowedUpdates(opts) @@ -207,48 +205,48 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp } if len(tlsFiles) == 2 { - return bot.runWebHookTLS(runCtx, opts, tlsFiles[0], tlsFiles[1]) + return bot.runWebhookTLS(runCtx, opts, tlsFiles[0], tlsFiles[1]) } - return bot.runWebHook(runCtx, opts) + return bot.runWebhook(runCtx, opts) }) } -// RunWebHook starts the webhook runtime with a background context. +// RunWebhook starts the webhook runtime with a background context. // -// It is shorthand for RunWebHookWithContext(context.Background(), opts, tlsFiles...). -func (bot *Bot[T]) RunWebHook(opts *BotWebHookOpts, tlsFiles ...string) error { - return bot.RunWebHookWithContext(context.Background(), opts, tlsFiles...) +// It is shorthand for RunWebhookWithContext(context.Background(), opts, tlsFiles...). +func (bot *Bot[T]) RunWebhook(opts *BotWebhookOpts, tlsFiles ...string) error { + return bot.RunWebhookWithContext(context.Background(), opts, tlsFiles...) } -// CloseWebHook removes the current Telegram webhook registration. +// CloseWebhook removes the current Telegram webhook registration. // // It is separate from Close, which only releases local resources. // Call it before switching a deployment from webhook delivery to polling. -func (bot *Bot[T]) CloseWebHook() error { +func (bot *Bot[T]) CloseWebhook() error { var e []error if bot.api == nil { e = append(e, errors.New("bot api nil")) } else { if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil { - if bot.webHookLogger != nil { - bot.webHookLogger.Errorf("Failed to close webhook: %s", err.Error()) + if bot.webhookLogger != nil { + bot.webhookLogger.Errorf("Failed to close webhook: %s", err.Error()) } else if bot.logger != nil { bot.logger.Errorf("Failed to close webhook: %s", err.Error()) } e = append(e, err) } } - if bot.webHookLogger != nil { - if err := bot.webHookLogger.Close(); err != nil { + if bot.webhookLogger != nil { + if err := bot.webhookLogger.Close(); err != nil { e = append(e, err) } - bot.webHookLogger = nil + bot.webhookLogger = nil } return errors.Join(e...) } -func (bot *Bot[T]) webhookAllowedUpdates(opts *BotWebHookOpts) []tgapi.UpdateType { +func (bot *Bot[T]) webhookAllowedUpdates(opts *BotWebhookOpts) []tgapi.UpdateType { if len(opts.AllowedUpdates) > 0 { return append([]tgapi.UpdateType(nil), opts.AllowedUpdates...) } @@ -264,6 +262,10 @@ func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Conte runCtx, cancel := context.WithCancel(ctx) defer cancel() + if bot.webhookLogger == nil { + bot.webhookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter) + } + bot.addTokenReplacer(bot.webhookLogger) bot.ExecRunners(runCtx) workersDone := make(chan struct{}) @@ -315,12 +317,12 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http. var up tgapi.Update if err := json.Unmarshal(data, &up); err != nil { w.WriteHeader(http.StatusBadRequest) - bot.webHookLogger.Errorln(err) + bot.webhookLogger.Errorln(err) return } - bot.webHookLogger.Debugf("UPDATE id=%d type=%s size=%d from=%s", up.UpdateID, up.Type, len(data), r.RemoteAddr) + bot.webhookLogger.Debugf("UPDATE id=%d type=%s size=%d from=%s", up.UpdateID, up.Type, len(data), r.RemoteAddr) if err := bot.enqueueUpdate(ctx, up); err != nil { - bot.webHookLogger.Errorln(err) + bot.webhookLogger.Errorln(err) w.WriteHeader(http.StatusServiceUnavailable) return } @@ -328,7 +330,7 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http. } } -func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc { +func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { auth := "" if r.Header.Get("Authorization") != "" { @@ -343,24 +345,24 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc { i, err := bot.api.GetWebhookInfoWithContext(r.Context()) if err != nil { - bot.webHookLogger.Errorln(err) + bot.webhookLogger.Errorln(err) w.WriteHeader(http.StatusInternalServerError) return } data, err := json.MarshalIndent(i, "", " ") if err != nil { - bot.webHookLogger.Errorln(err) + bot.webhookLogger.Errorln(err) w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if _, err := fmt.Fprint(w, string(data)); err != nil { - bot.webHookLogger.Errorln(err) + bot.webhookLogger.Errorln(err) } } } -func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *http.ServeMux { +func (bot *Bot[T]) newWebhookMux(ctx context.Context, opts *BotWebhookOpts) *http.ServeMux { r := http.NewServeMux() if opts.UseStatusPath { r.HandleFunc("/status", statusHandler(bot, opts)) @@ -368,10 +370,10 @@ func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *htt r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken)) return r } -func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error { +func (bot *Bot[T]) runWebhook(ctx context.Context, opts *BotWebhookOpts) error { srv := &http.Server{ Addr: fmt.Sprintf(":%d", opts.LocalPort), - Handler: bot.newWebHookMux(ctx, opts), + Handler: bot.newWebhookMux(ctx, opts), } errCh := make(chan error, 1) @@ -384,7 +386,7 @@ func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error { errCh <- nil }() - bot.webHookLogger.Infoln(fmt.Sprintf("Bot WebHook started at %s; waiting for updates at %s", srv.Addr, opts.URL)) + bot.webhookLogger.Infoln(fmt.Sprintf("Bot Webhook started at %s; waiting for updates at %s", srv.Addr, opts.URL)) select { case <-ctx.Done(): @@ -401,10 +403,10 @@ func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error { return err } } -func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key, cert string) error { +func (bot *Bot[T]) runWebhookTLS(ctx context.Context, opts *BotWebhookOpts, key, cert string) error { srv := &http.Server{ Addr: fmt.Sprintf(":%d", opts.LocalPort), - Handler: bot.newWebHookMux(ctx, opts), + Handler: bot.newWebhookMux(ctx, opts), } errCh := make(chan error, 1) @@ -417,7 +419,7 @@ func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key, errCh <- nil }() - bot.webHookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL)) + bot.webhookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL)) select { case <-ctx.Done(): @@ -436,16 +438,16 @@ func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key, } func validateWebhookPath(path string, useStatusPath bool) error { if path == "" { - return errors.New("empty BotWebHookOpts.Path") + return errors.New("empty BotWebhookOpts.Path") } if !strings.HasPrefix(path, "/") { - return errors.New("BotWebHookOpts.Path must start with '/'") + return errors.New("BotWebhookOpts.Path must start with '/'") } if strings.Contains(path, "?") || strings.Contains(path, "#") { - return errors.New("BotWebHookOpts.Path must not contain query or fragment") + return errors.New("BotWebhookOpts.Path must not contain query or fragment") } if useStatusPath && path == "/status" { - return errors.New("BotWebHookOpts.Path must not be '/status' when status path is enabled") + return errors.New("BotWebhookOpts.Path must not be '/status' when status path is enabled") } return nil } diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 7df0fb2..6a00f17 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -36,10 +36,10 @@ func TestEnqueueUpdateCopiesValue(t *testing.T) { func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), } t.Cleanup(func() { - _ = bot.webHookLogger.Close() + _ = bot.webhookLogger.Close() }) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":7,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"text":"/start"}}`)) @@ -94,7 +94,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { NewRunner("runner", func(bot *Bot[NoData]) error { calls.Add(1) return nil - }).Onetime(true).Async(false), + }).Once(true).Async(false), }, } t.Cleanup(func() { @@ -109,17 +109,40 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { } } +func TestRunWebhookRuntimePreservesConfiguredWebhookLogger(t *testing.T) { + webhookLogger := sneklog.NewLogger() + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + webhookLogger: webhookLogger, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + } + t.Cleanup(func() { + _ = bot.logger.Close() + if bot.webhookLogger != nil { + _ = bot.webhookLogger.Close() + } + }) + + if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); err != nil { + t.Fatalf("runWebhookRuntime returned error: %v", err) + } + if bot.webhookLogger != webhookLogger { + t.Fatal("expected runWebhookRuntime to preserve configured webhook logger") + } +} + func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { var calls atomic.Int32 plugin := NewPlugin[NoData]("demo") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("start", func(ctx *MsgContext, db NoData) error { calls.Add(1) return nil - }, "start") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{*plugin}, updateQueue: make(chan *tgapi.Update, 1), @@ -127,7 +150,7 @@ func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { } t.Cleanup(func() { _ = bot.logger.Close() - _ = bot.webHookLogger.Close() + _ = bot.webhookLogger.Close() }) err := bot.runWebhookRuntime(context.Background(), func(ctx context.Context) error { @@ -162,7 +185,7 @@ func TestWebhookAllowedUpdatesUsesBotUpdateTypesByDefault(t *testing.T) { bot := &Bot[NoData]{ updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, } - opts := NewBotWebHookOpts() + opts := NewBotWebhookOpts() got := bot.webhookAllowedUpdates(opts) if len(got) != 2 { @@ -235,10 +258,10 @@ func TestValidateWebhookTLSFiles(t *testing.T) { func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), } t.Cleanup(func() { - _ = bot.webHookLogger.Close() + _ = bot.webhookLogger.Close() }) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1))) @@ -272,13 +295,13 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { bot := &Bot[NoData]{ api: api, - webHookLogger: sneklog.NewLogger(), + webhookLogger: sneklog.NewLogger(), } t.Cleanup(func() { - _ = bot.webHookLogger.Close() + _ = bot.webhookLogger.Close() }) - handler := statusHandler(bot, &BotWebHookOpts{SecretToken: "secret"}) + handler := statusHandler(bot, &BotWebhookOpts{SecretToken: "secret"}) tests := []struct { name string @@ -308,14 +331,14 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { } } -func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) { +func TestRunWebhookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) { bot := &Bot[NoData]{ prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, } - opts := NewBotWebHookOpts().SetURL("https://bot.example.com") + opts := NewBotWebhookOpts().SetURL("https://bot.example.com") - err := bot.RunWebHookWithContext(context.Background(), opts, "cert.pem") + err := bot.RunWebhookWithContext(context.Background(), opts, "cert.pem") if err == nil { t.Fatal("expected tls validation error, got nil") } @@ -324,16 +347,16 @@ func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing } } -func TestRunWebHookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) { +func TestRunWebhookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) { bot := &Bot[NoData]{ prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, } - opts := NewBotWebHookOpts(). + opts := NewBotWebhookOpts(). SetURL("https://bot.example.com"). SetUseStatusPath(true) - err := bot.RunWebHookWithContext(context.Background(), opts) + err := bot.RunWebhookWithContext(context.Background(), opts) if err == nil { t.Fatal("expected status-path secret validation error, got nil") } diff --git a/cmd_generator.go b/cmd_generator.go index 253757b..3008d50 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -10,8 +10,8 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -// CmdRegexp matches command names allowed for Telegram command registration. -var CmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$") +// cmdRegexp matches command names allowed for Telegram command registration. +var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$") // ErrTooManyCommands is returned when the total number of registered commands // exceeds Telegram's limit of 100 bot commands per bot. @@ -46,7 +46,7 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { } // Internal helper to validate Telegram command names. -func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) } +func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) } // Internal helper to collect non-skipped, valid commands from one plugin. func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { diff --git a/cmd_generator_test.go b/cmd_generator_test.go index ee6bc7d..72a1221 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -46,7 +46,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { plugin := NewPlugin[NoData]("overflow") exec := func(ctx *MsgContext, db NoData) error { return nil } for i := 0; i < 101; i++ { - plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) + plugin.Command("cmd"+strconv.Itoa(i), exec) } bot := &Bot[NoData]{ @@ -68,9 +68,9 @@ func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { plugin := NewPlugin[NoData]("sorted") exec := func(ctx *MsgContext, db NoData) error { return nil } - plugin.AddCommand(NewCommand(exec, "zeta")) - plugin.AddCommand(NewCommand(exec, "alpha")) - plugin.AddCommand(NewCommand(exec, "mid")) + plugin.Command("zeta", exec) + plugin.Command("alpha", exec) + plugin.Command("mid", exec) commands := gatherCommandsForPlugin(*plugin) got := make([]string, 0, len(commands)) diff --git a/commands.go b/commands.go index d98fd79..fd801b6 100644 --- a/commands.go +++ b/commands.go @@ -2,7 +2,6 @@ package laniakea import ( "errors" - "fmt" "regexp" "git.scuroneko.dev/scuroneko/extypes" @@ -12,14 +11,14 @@ import ( type CommandValueType string const ( - // CommandValueStringType expects any non-empty string. - CommandValueStringType CommandValueType = "string" - // CommandValueIntType expects a decimal integer (digits only). - CommandValueIntType CommandValueType = "int" - // CommandValueBoolType expects a exact "true" or "false". - CommandValueBoolType CommandValueType = "bool" - // CommandValueAnyType accepts any input without validation. - CommandValueAnyType CommandValueType = "any" + // CommandValueString expects any non-empty string. + CommandValueString CommandValueType = "string" + // CommandValueInt expects a decimal integer (digits only). + CommandValueInt CommandValueType = "int" + // CommandValueBool expects an exact "true" or "false". + CommandValueBool CommandValueType = "bool" + // CommandValueAny accepts any input without validation. + CommandValueAny CommandValueType = "any" ) var ( @@ -52,22 +51,23 @@ type CommandArg struct { required bool // Whether this argument must be provided } -// NewCommandArg creates a new CommandArg with the given text and type. -// Uses a default regex based on the type (string or int). -// For CommandValueAnyType, no validation is performed. +// NewCommandArg creates an optional argument without value validation. func NewCommandArg(text string) CommandArg { - return CommandArg{CommandValueAnyType, text, CommandRegexString, false} + return CommandArg{CommandValueAny, text, nil, false} } // SetValueType sets expected value type and switches built-in validation regexp. func (c CommandArg) SetValueType(t CommandValueType) CommandArg { - regex := CommandRegexString + var regex *regexp.Regexp switch t { - case CommandValueIntType: + case CommandValueInt: regex = CommandRegexInt - case CommandValueBoolType: + case CommandValueBool: regex = CommandRegexBool - case CommandValueAnyType: + case CommandValueString: + regex = CommandRegexString + case CommandValueAny: + default: regex = nil // Skip validation } c.valueType = t @@ -98,15 +98,15 @@ type Command[T AppData] struct { skipAutoCmd bool // If true, this command won't be auto-added to help menus } -// NewCommand creates a new Command with the given executor, command string, and arguments. +// NewCommand creates a new Command with the given command string, executor, and arguments. // The command string should not include the leading slash (e.g., "start", not "/start"). -func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { +func NewCommand[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} } -// NewPayload creates a new Command with the given executor, command payload string, and arguments. +// NewPayload creates a new callback payload handler command. // The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. -func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { +func NewPayload[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} } @@ -145,7 +145,7 @@ func (c *Command[T]) validateArgs(args []string) error { } cmdArg := c.args.Get(i) if cmdArg.regex == nil { - continue // Skip validation for CommandValueAnyType + continue // Skip validation for CommandValueAny. } if !cmdArg.regex.MatchString(arg) { return ErrCmdArgRegexpMismatch @@ -167,9 +167,7 @@ func (c *Command[T]) clone() *Command[T] { // CommandGroup builds a set of commands with a shared name prefix and middleware. type CommandGroup[T any] struct { - prefix string - separator string - + prefix string middlewares extypes.Slice[Middleware[T]] commands extypes.Slice[*Command[T]] } @@ -177,19 +175,13 @@ type CommandGroup[T any] struct { // NewCommandGroup creates a command group that prefixes every added command. func NewCommandGroup[T any](prefix string) *CommandGroup[T] { return &CommandGroup[T]{ - prefix: prefix, separator: "", + prefix: prefix, middlewares: make([]Middleware[T], 0), commands: make([]*Command[T], 0), } } -// SetSeparator sets the text inserted between the group prefix and command name. -func (g *CommandGroup[T]) SetSeparator(separator string) *CommandGroup[T] { - g.separator = separator - return g -} - // Use adds middleware that runs before each command's own middleware. func (g *CommandGroup[T]) Use(m Middleware[T]) *CommandGroup[T] { g.middlewares = append(g.middlewares, m) @@ -202,7 +194,7 @@ func (g *CommandGroup[T]) AddCommand(cmd *Command[T]) *CommandGroup[T] { return g } newCmd := cmd.clone() - newCmd.command = fmt.Sprintf("%s%s%s", g.prefix, g.separator, cmd.command) + newCmd.command = g.prefix + cmd.command g.commands = g.commands.Push(newCmd) return g } diff --git a/doc.go b/doc.go index 3be732d..f30fb46 100644 --- a/doc.go +++ b/doc.go @@ -27,7 +27,7 @@ Example usage: return bot.Run() -Configure bots, plugins, and localization before starting Run, RunWithContext, or RunWebHookWithContext. +Configure bots, plugins, and localization before starting Run, RunWithContext, or RunWebhookWithContext. Runtime accessors are safe for concurrent use unless stated otherwise. */ package laniakea diff --git a/handler_test.go b/handler_test.go index f61b4ff..6441097 100644 --- a/handler_test.go +++ b/handler_test.go @@ -757,10 +757,10 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { commandCalled := false fallbackCalled := false plugin := NewPlugin[NoData]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("start", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "start") + }) plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { fallbackCalled = true return nil @@ -794,7 +794,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { called = true if ctx.Msg == nil { t.Fatal("expected message context") @@ -809,7 +809,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID) } return nil - }, "ping") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -841,10 +841,10 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { var got banInput plugin := NewPlugin[NoData]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ban", func(ctx *MsgContext, db NoData) error { return ctx.BindArgs(&got) - }, "ban", - NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(), + }, + NewCommandArg("user_id").SetValueType(CommandValueInt).SetRequired(), NewCommandArg("reason").SetRequired(), ) @@ -878,10 +878,10 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { var got payloadInput plugin := NewPlugin[NoData]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { return ctx.BindArgs(&got) - }, "approve", - NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(), + }, + NewCommandArg("id").SetValueType(CommandValueInt).SetRequired(), NewCommandArg("note").SetRequired(), ) @@ -920,10 +920,10 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { updateCalled := false plugin := NewPlugin[NoData]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "ping") + }) plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error { updateCalled = true if ctx.Msg == nil { @@ -968,10 +968,10 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { updateCalled := false plugin := NewPlugin[NoData]("test") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "ping") + }) plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error { updateCalled = true if ctx.Msg == nil { @@ -1007,7 +1007,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { called = true if ctx.CallbackQueryID != "cb-msg" { t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) @@ -1031,7 +1031,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { t.Fatalf("unexpected callback args: got %v want %v", got, want) } return nil - }, "approve") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -1066,7 +1066,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + plugin.Payload("inline.approve", func(ctx *MsgContext, db NoData) error { called = true if ctx.CallbackQueryID != "cb-inline" { t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) @@ -1090,7 +1090,7 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { t.Fatalf("unexpected callback args: got %v want %v", got, want) } return nil - }, "inline.approve") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -1122,9 +1122,9 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("test") - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { return nil - }, "approve") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -1173,9 +1173,9 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("test") wantErr := AsInternalError(errors.New("boom")) - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { return wantErr - }, "approve") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), diff --git a/keyboard.go b/keyboard.go index 309bef6..b874afa 100644 --- a/keyboard.go +++ b/keyboard.go @@ -16,9 +16,9 @@ const ( ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary" ) -// InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button. +// InlineKeyboardButtonBuilder is a fluent builder for creating a single inline keyboard button. // -// Use NewInlineKbButton() to start, then chain methods to configure: +// Use NewInlineKeyboardButton() to start, then chain methods to configure: // - SetIconCustomEmojiID() — adds a custom emoji icon // - SetStyle() — sets visual style (danger/success/primary) // - SetURL() — makes button open a URL @@ -26,7 +26,7 @@ const ( // // Call build() to produce the final tgapi.InlineKeyboardButton. // Builder methods are immutable — each returns a copy. -type InlineKbButtonBuilder struct { +type InlineKeyboardButtonBuilder struct { text string iconCustomEmojiID string style tgapi.KeyboardButtonStyle @@ -34,15 +34,15 @@ type InlineKbButtonBuilder struct { callbackData string } -// NewInlineKbButton creates a new button builder with the given display text. +// NewInlineKeyboardButton creates a new button builder with the given display text. // The button will have no URL, no style, and no callback data by default. -func NewInlineKbButton(text string) InlineKbButtonBuilder { - return InlineKbButtonBuilder{text: text} +func NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder { + return InlineKeyboardButtonBuilder{text: text} } // SetIconCustomEmojiID sets a custom emoji ID to display as the button's icon. // This is a Telegram Bot API feature for custom emoji icons. -func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBuilder { +func (b InlineKeyboardButtonBuilder) SetIconCustomEmojiID(id string) InlineKeyboardButtonBuilder { b.iconCustomEmojiID = id return b } @@ -50,14 +50,14 @@ func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBui // SetStyle sets the visual style of the button. // Valid values: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary. // If not set, the button uses the default style. -func (b InlineKbButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineKbButtonBuilder { +func (b InlineKeyboardButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineKeyboardButtonBuilder { b.style = style return b } // SetURL sets a URL that will be opened when the button is pressed. // If both URL and CallbackData are set, Telegram will prioritize URL. -func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder { +func (b InlineKeyboardButtonBuilder) SetURL(url string) InlineKeyboardButtonBuilder { b.url = url return b } @@ -69,7 +69,7 @@ func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder { // are safely serialized, but complex structs may not serialize usefully. // // Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. -func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKbButtonBuilder { +func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKeyboardButtonBuilder { b.callbackData = NewCallbackData(cmd, args...).ToJSON() return b } @@ -77,13 +77,13 @@ func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) Inli // SetCallbackDataBase64 sets a structured callback payload encoded as Base64. // This can be useful when the JSON payload exceeds Telegram's callback data length limit. // Args are converted to strings using fmt.Sprint. -func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKbButtonBuilder { +func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKeyboardButtonBuilder { b.callbackData = NewCallbackData(cmd, args...).ToBase64() return b } // Internal helper that converts the builder state into a Telegram button. -func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { +func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton { return tgapi.InlineKeyboardButton{ Text: b.text, URL: b.url, @@ -194,9 +194,9 @@ func (in *InlineKeyboard) AddCallbackButtonStyle(text string, style tgapi.Keyboa }) } -// AddButton adds a button pre-configured via InlineKbButtonBuilder. +// AddButton adds a button pre-configured via InlineKeyboardButtonBuilder. // This is the most flexible way to create buttons with custom emoji, style, URL, and callback. -func (in *InlineKeyboard) AddButton(b InlineKbButtonBuilder) *InlineKeyboard { +func (in *InlineKeyboard) AddButton(b InlineKeyboardButtonBuilder) *InlineKeyboard { return in.append(b.build()) } diff --git a/keyboard_test.go b/keyboard_test.go index 401c256..ac1f5ac 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -31,7 +31,7 @@ func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) { func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { kb := NewInlineKeyboardBase64(3). AddButton( - NewInlineKbButton("Docs"). + NewInlineKeyboardButton("Docs"). SetStyle(ButtonStylePrimary). SetURL("https://example.test"), ) diff --git a/msg_context.go b/msg_context.go index a63bb3b..44862fe 100644 --- a/msg_context.go +++ b/msg_context.go @@ -142,7 +142,7 @@ func (m *AnswerMessage) Edit(text string) *AnswerMessage { // ⚠️ 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) + return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2) } // Internal helper for editing callback-linked messages. @@ -163,7 +163,7 @@ func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *Answ // // ⚠️ 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) + return ctx.editCallback(text, keyboard, tgapi.ParseMarkdownV2) } // EditCallbackf formats a string using fmt.Sprintf and edits the callback message with plain text. @@ -175,7 +175,7 @@ func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, ar // // ⚠️ 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) + return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2) } // Internal helper for media-caption edits. @@ -225,7 +225,7 @@ func (m *AnswerMessage) EditCaption(text string) *AnswerMessage { // // ⚠️ 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) + return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMarkdownV2) } // EditCaptionKeyboard edits the caption of a media message with a new inline keyboard (plain text). @@ -237,7 +237,7 @@ func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *An // // ⚠️ 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) + return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2) } // Internal helper for message replies with optional keyboard and parse mode. @@ -292,7 +292,7 @@ func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage { // // ⚠️ 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) + return ctx.answer(text, nil, tgapi.ParseMarkdownV2) } // Answerf formats a string using fmt.Sprintf and sends it as a plain text message. @@ -309,7 +309,7 @@ func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessag // // ⚠️ 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) + return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } // Keyboard sends a message with an inline keyboard (plain text). @@ -328,7 +328,7 @@ func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMe // // ⚠️ 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) + return ctx.answer(text, keyboard, tgapi.ParseMarkdownV2) } func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage { @@ -415,7 +415,7 @@ func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage { // // ⚠️ 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) + return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMarkdownV2) } // AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text). @@ -427,7 +427,7 @@ func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeybo // // ⚠️ 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) + return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMarkdownV2) } // AnswerPhotof formats a string and sends it as a photo caption (plain text). @@ -439,7 +439,7 @@ func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *Answ // // ⚠️ 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) + return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } // Internal helper that deletes a message by ID. @@ -487,17 +487,17 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { } } -// AnswerCbQuery answers the callback query with no text or alert. -func (ctx *MsgContext) AnswerCbQuery() { ctx.answerCallbackQuery("", "", false) } +// AnswerCallback answers the callback query with no text or alert. +func (ctx *MsgContext) AnswerCallback() { ctx.answerCallbackQuery("", "", false) } -// AnswerCbQueryText answers the callback query with a text notification. -func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery("", text, false) } +// AnswerCallbackText answers the callback query with a text notification. +func (ctx *MsgContext) AnswerCallbackText(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) } +// AnswerCallbackAlert answers the callback query with a user-visible alert. +func (ctx *MsgContext) AnswerCallbackAlert(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) } +// AnswerCallbackURL answers the callback query with a URL redirect. +func (ctx *MsgContext) AnswerCallbackURL(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) { @@ -575,7 +575,7 @@ func (ctx *MsgContext) NewDraft() *Draft { // with Markdown V2 parse mode enabled. // Uses the API limiter to avoid rate limiting. func (ctx *MsgContext) NewDraftMarkdown() *Draft { - return ctx.newDraft(tgapi.ParseMDV2) + return ctx.newDraft(tgapi.ParseMarkdownV2) } // Translate looks up a key in the current user's language. @@ -816,5 +816,5 @@ func (ctx *MsgContext) UpsertKeyboard(text string, keyboard *InlineKeyboard) *An // UpsertKeyboardMarkdown edits a callback message or sends a new MarkdownV2 message with a keyboard. func (ctx *MsgContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { - return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMDV2) + return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2) } diff --git a/plugins.go b/plugins.go index a51e4bc..a2ac49a 100644 --- a/plugins.go +++ b/plugins.go @@ -57,10 +57,10 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { return p } -// NewCommand creates and immediately adds a new command to the plugin. +// Command creates and immediately adds a new command to the plugin. // Returns the created command for further configuration. -func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - cmd := NewCommand(exec, command, args...) +func (p *Plugin[T]) Command(command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { + cmd := NewCommand(command, exec, args...) p.AddCommand(cmd) return cmd } @@ -78,6 +78,33 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { return p } +// Payload creates and immediately adds a new payload command to the plugin. +// Returns the created payload command for further configuration. +func (p *Plugin[T]) Payload(command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { + cmd := NewPayload(command, exec, args...) + p.AddPayload(cmd) + return cmd +} + +// Scene creates, registers, and returns a new scene owned by the plugin. +func (p *Plugin[T]) Scene(name string) *Scene[T] { + scene := NewScene[T](name) + scene.setPluginName(p.name) + p.AddScene(scene) + return scene +} + +// AddScene registers a multi-step scene in the plugin. +func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { + if scene == nil { + return p + } + scene.PluginName = p.name + scene.setPluginName(p.name) + p.scenes[scene.Name] = scene + return p +} + // CommandGroup configures and registers a prefixed command group. func (p *Plugin[T]) CommandGroup(prefix string, groupFunc func(group *CommandGroup[T])) *Plugin[T] { if groupFunc == nil { @@ -108,33 +135,6 @@ func (p *Plugin[T]) AddCommandGroup(group *CommandGroup[T]) *Plugin[T] { return p } -// NewPayload creates and immediately adds a new payload command to the plugin. -// Returns the created payload command for further configuration. -func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - cmd := NewPayload(exec, command, args...) - p.AddPayload(cmd) - return cmd -} - -// AddScene registers a multi-step scene in the plugin. -func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { - if scene == nil { - return p - } - scene.PluginName = p.name - scene.setPluginName(p.name) - p.scenes[scene.Name] = scene - return p -} - -// NewScene creates, registers, and returns a new scene owned by the plugin. -func (p *Plugin[T]) NewScene(name string) *Scene[T] { - scene := NewScene[T](name) - scene.setPluginName(p.name) - p.AddScene(scene) - return scene -} - // UsePolicy registers a Policy as plugin middleware for all plugin handlers. func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] { mw := RequirePolicy(name, policy) @@ -148,11 +148,11 @@ func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery: if p.logger == nil { logger := utils.CreateLogger(p.name, utils.GetLoggerLevel(), utils.LogFormatText, nil) - logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t) + logger.Warnf("%s can't be registered through AddUpdateHandler. Use AddPayload/Payload or AddCommand/Command", t) _ = logger.Close() return p } - p.logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t) + p.logger.Warnf("%s can't be registered through AddUpdateHandler. Use AddPayload/Payload or AddCommand/Command", t) return p } p.handlers[t] = handler diff --git a/plugins_test.go b/plugins_test.go index a3e0699..ac12fd8 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -6,7 +6,7 @@ import ( ) func TestValidateArgsRequiresFullMatch(t *testing.T) { - intCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) + intCmd := NewCommand("int", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).SetRequired()) if err := intCmd.validateArgs([]string{"123"}); err != nil { t.Fatalf("expected valid integer argument, got %v", err) } @@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) } - boolCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) + boolCmd := NewCommand("bool", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("flag").SetValueType(CommandValueBool).SetRequired()) if err := boolCmd.validateArgs([]string{"false"}); err != nil { t.Fatalf("expected valid bool argument, got %v", err) } @@ -25,8 +25,8 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { cmd := NewCommand( - func(ctx *MsgContext, db NoData) error { return nil }, "mixed", + func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("optional"), NewCommandArg("required").SetRequired(), ) @@ -42,12 +42,11 @@ func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) { groupMiddleware := NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true }) commandMiddleware := NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }) - cmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"). + cmd := NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }). SetDescription("Ban user"). Use(commandMiddleware) - group := NewCommandGroup[NoData]("admin"). - SetSeparator("_"). + group := NewCommandGroup[NoData]("admin_"). Use(groupMiddleware). AddCommand(cmd) @@ -80,7 +79,7 @@ func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) func TestCommandGroupBuildIsRepeatable(t *testing.T) { group := NewCommandGroup[NoData]("admin"). Use(NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })). - AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"). + AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }). Use(NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }))) first := group.Build() @@ -103,9 +102,8 @@ func TestCommandGroupBuildIsRepeatable(t *testing.T) { func TestPluginCommandGroupRegistersBuiltCommands(t *testing.T) { plugin := NewPlugin[NoData]("admin") - plugin.CommandGroup("admin", func(group *CommandGroup[NoData]) { - group.SetSeparator("_") - group.AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban")) + plugin.CommandGroup("admin_", func(group *CommandGroup[NoData]) { + group.AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil })) }) if _, ok := plugin.commands["admin_ban"]; !ok { diff --git a/runners.go b/runners.go index 7aa457c..72d075f 100644 --- a/runners.go +++ b/runners.go @@ -12,41 +12,41 @@ type RunnerFn[T AppData] func(*Bot[T]) error // Runner represents a configurable background or one-time task to be // executed by a Bot. // -// Runners are configured using builder methods: Onetime(), Async(), Timeout(). +// Runners are configured using builder methods: Once(), Async(), Every(). // Once Execute() is called, the Runner should not be modified. // // Execution semantics: -// - onetime=true, async=false: Run once synchronously (blocks). -// - onetime=true, async=true: Run once in a goroutine (non-blocking). -// - onetime=false, async=true: Run repeatedly in a goroutine with timeout. -// - onetime=false, async=false: Invalid configuration — ignored with warning. +// - once=true, async=false: Run once synchronously (blocks). +// - once=true, async=true: Run once in a goroutine (non-blocking). +// - once=false, async=true: Run repeatedly in a goroutine with timeout. +// - once=false, async=false: Invalid configuration — ignored with warning. type Runner[T AppData] struct { - name string // Human-readable name for logging - onetime bool // If true, runs once; if false, runs periodically - async bool // If true, runs in a goroutine; else, runs synchronously - timeout time.Duration // Duration to wait between periodic executions (ignored if onetime=true) - fn RunnerFn[T] // The function to execute + name string // Human-readable name for logging + once bool // If true, runs once; if false, runs periodically + async bool // If true, runs in a goroutine; else, runs synchronously + every time.Duration // Duration to wait between periodic executions (ignored if once=true) + fn RunnerFn[T] // The function to execute } // NewRunner creates a new Runner with the given name and function. // By default, the Runner is configured as async=true (non-blocking). // -// Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. +// Builder methods (Once, Async, Every) can be chained to customize behavior. // DO NOT call builder methods concurrently or after Execute(). func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { return Runner[T]{ - name: name, - fn: fn, - async: true, // Default: run asynchronously - timeout: 0, // Default: no timeout (ignored if onetime=true) + name: name, + fn: fn, + async: true, // Default: run asynchronously + every: 0, // Default: no timeout (ignored if once=true) } } -// Onetime sets whether the runner executes once or repeatedly. +// Once sets whether the runner executes once or repeatedly. // If true, the runner runs only once. // If false, the runner runs in a loop with the configured timeout. -func (r Runner[T]) Onetime(onetime bool) Runner[T] { - r.onetime = onetime +func (r Runner[T]) Once(once bool) Runner[T] { + r.once = once return r } @@ -54,56 +54,56 @@ func (r Runner[T]) Onetime(onetime bool) Runner[T] { // If true, the runner runs in a goroutine (non-blocking). // If false, the runner blocks the caller during execution. // -// Note: If onetime=false and async=false, the runner will be skipped with a warning. +// Note: If once=false and async=false, the runner will be skipped with a warning. func (r Runner[T]) Async(async bool) Runner[T] { r.async = async return r } -// Timeout sets the duration to wait between repeated executions for -// non-onetime runners. +// Every sets the duration to wait between repeated executions for +// non-once runners. // -// If onetime=true, this value is ignored. -// If onetime=false and async=true, this timeout determines the sleep interval +// If once=true, this value is ignored. +// If once=false and async=true, this timeout determines the sleep interval // between loop iterations. // // A zero value (time.Duration(0)) is allowed but may trigger a warning -// if used with a background (non-onetime) async runner. -func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] { - r.timeout = timeout +// if used with a background (non-once) async runner. +func (r Runner[T]) Every(timeout time.Duration) Runner[T] { + r.every = timeout return r } // ExecRunners executes all runners registered on the Bot with context-based lifecycle management. // // It logs warnings for misconfigured runners: -// - Sync, non-onetime runners are skipped (invalid configuration). -// - Background (non-onetime, async) runners without a timeout trigger a warning. +// - Sync, non-once runners are skipped (invalid configuration). +// - Background (non-once, async) runners without a timeout trigger a warning. // // Execution logic: -// - onetime + async: Runs once in a goroutine. -// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds. -// - !onetime + async: Runs in a loop with timeout between iterations until ctx.Done(). -// - !onetime + sync: Skipped with warning. +// - once + async: Runs once in a goroutine. +// - once + sync: Runs once synchronously; warns if slower than 2 seconds. +// - !once + async: Runs in a loop with timeout between iterations until ctx.Done(). +// - !once + sync: Skipped with warning. // // Background runners listen for ctx.Done() and gracefully shut down when the context is canceled. // // This method is typically called once during bot startup from RunWithContext or -// RunWebHookWithContext. +// RunWebhookWithContext. func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { // Validate configuration - if !runner.onetime && !runner.async { - bot.logger.Warnf("Runner %s not onetime, but sync — skipping\n", runner.name) + if !runner.once && !runner.async { + bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name) continue } - if !runner.onetime && runner.async && runner.timeout == 0 { + if !runner.once && runner.async && runner.every == 0 { bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name) continue } - if runner.onetime && runner.async { + if runner.once && runner.async { // One-time async: fire and forget bot.runnerOnceWG.Add(1) go func(r Runner[T]) { @@ -126,7 +126,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } }(runner) - } else if runner.onetime && !runner.async { + } else if runner.once && !runner.async { // One-time sync: block until done t := time.Now() err := runner.fn(bot) @@ -149,12 +149,12 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { if elapsed > time.Second*2 { bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) } - } else if !runner.onetime && runner.async { + } else if !runner.once && runner.async { // Background loop: periodic execution with graceful shutdown bot.runnerBgWG.Add(1) go func(r Runner[T]) { defer bot.runnerBgWG.Done() - ticker := time.NewTicker(r.timeout) + ticker := time.NewTicker(r.every) defer ticker.Stop() for { select { @@ -182,6 +182,6 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { } }(runner) } - // Note: !onetime && !async is already skipped above + // Note: !once && !async is already skipped above } } diff --git a/runners_test.go b/runners_test.go index 3d3f5b8..28a8b30 100644 --- a/runners_test.go +++ b/runners_test.go @@ -14,7 +14,7 @@ type runnerObserver struct { recordingObserver } -func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { +func TestExecRunnersRunsOnceSyncRunner(t *testing.T) { var calls atomic.Int32 bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -22,7 +22,7 @@ func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { NewRunner("sync-once", func(*Bot[NoData]) error { calls.Add(1) return nil - }).Onetime(true).Async(false), + }).Once(true).Async(false), }, } @@ -46,7 +46,7 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) { triggered <- struct{}{} } return nil - }).Timeout(5 * time.Millisecond), + }).Every(5 * time.Millisecond), }, } @@ -76,7 +76,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) { runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { return wantErr - }).Onetime(true).Async(false), + }).Once(true).Async(false), }, } diff --git a/scene_test.go b/scene_test.go index d904431..bde5f10 100644 --- a/scene_test.go +++ b/scene_test.go @@ -45,7 +45,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { called := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { called = true @@ -146,7 +146,7 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { t.Run("empty entry", func(t *testing.T) { plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup") + plugin.Scene("signup") bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -169,7 +169,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { t.Run("missing entry step", func(t *testing.T) { plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup").SetEntry("start") + plugin.Scene("signup").SetEntry("start") bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -210,7 +210,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { stepCalled = true @@ -269,7 +269,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil @@ -324,7 +324,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil @@ -376,7 +376,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("wizard") - scene := plugin.NewScene("signup"). + scene := plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil @@ -435,7 +435,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { stepCalled = true @@ -499,7 +499,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil @@ -563,8 +563,8 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewPayload(func(ctx *MsgContext, db NoData) error { return nil }, "ping") - plugin.NewScene("signup"). + plugin.Payload("ping", func(ctx *MsgContext, db NoData) error { return nil }) + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { stepCalled = true @@ -632,11 +632,11 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { commandCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "ping") - plugin.NewScene("signup"). + }) + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { if err := ctx.SaveData(struct { @@ -712,16 +712,16 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { stepCalled = true return ctx.Stay(), nil }) - plugin.NewCommand(func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MsgContext, db NoData) error { commandCalled = true return nil - }, "ping") + }) bot := &Bot[NoData]{ logger: sneklog.NewLogger(), @@ -779,7 +779,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { fallbackCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.NewScene("signup"). + plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { return ctx.Stay(), nil diff --git a/tgapi/api.go b/tgapi/api.go index 74d67ab..c6aea8a 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -83,10 +83,10 @@ func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts { return opts } -// SetLimiterDrop enables "drop mode" for rate limiting. +// SetDropRateLimitOverflow enables "drop mode" for rate limiting. // If true, requests exceeding limits return ErrDropOverflow immediately. // If false, requests block until capacity is available. -func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts { +func (opts *APIOpts) SetDropRateLimitOverflow(b bool) *APIOpts { opts.dropOverflowLimit = b return opts } diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index d33b720..79b9aa6 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -652,15 +652,15 @@ func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params Get return req.DoWithContext(ctx, api) } -// GetChatMembersCount holds parameters for the getChatMemberCount method. +// GetChatMemberCount holds parameters for the getChatMemberCount method. // See https://core.telegram.org/bots/api#getchatmembercount -type GetChatMembersCount struct { +type GetChatMemberCount struct { ChatID int64 `json:"chat_id"` } // GetChatMemberCount returns the number of members in a chat. // See https://core.telegram.org/bots/api#getchatmembercount -func (api *API) GetChatMemberCount(params GetChatMembersCount) (int, error) { +func (api *API) GetChatMemberCount(params GetChatMemberCount) (int, error) { req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) return req.Do(api) } @@ -668,7 +668,7 @@ func (api *API) GetChatMemberCount(params GetChatMembersCount) (int, error) { // GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount. // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatmembercount -func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMembersCount) (int, error) { +func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMemberCount) (int, error) { req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) return req.DoWithContext(ctx, api) } diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 4d12516..7529531 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -362,7 +362,7 @@ type MessageEntity struct { Language string `json:"language,omitempty"` CustomEmojiID string `json:"custom_emoji_id,omitempty"` - UnixTime int `json:"unix_time,omitempty"` + UnixTime int64 `json:"unix_time,omitempty"` DateTimeFormat string `json:"date_time_format,omitempty"` } diff --git a/tgapi/methods_types.go b/tgapi/methods_types.go index 0c4d0d9..cd58a99 100644 --- a/tgapi/methods_types.go +++ b/tgapi/methods_types.go @@ -4,12 +4,12 @@ package tgapi type ParseMode string const ( - // ParseMDV2 enables MarkdownV2 style parsing. - ParseMDV2 ParseMode = "MarkdownV2" + // ParseMarkdownV2 enables MarkdownV2 style parsing. + ParseMarkdownV2 ParseMode = "MarkdownV2" // ParseHTML enables HTML style parsing. ParseHTML ParseMode = "HTML" - // ParseMD enables legacy Markdown style parsing. - ParseMD ParseMode = "Markdown" + // ParseMarkdown enables legacy Markdown style parsing. + ParseMarkdown ParseMode = "Markdown" // ParseNone disables parse_mode and leaves plain-text requests unannotated. ParseNone ParseMode = "" ) diff --git a/tgapi/parse_mode_test.go b/tgapi/parse_mode_test.go index 8b1f2d3..1f23437 100644 --- a/tgapi/parse_mode_test.go +++ b/tgapi/parse_mode_test.go @@ -25,7 +25,7 @@ func TestParseModeStillSerializesExplicitModes(t *testing.T) { data, err := json.Marshal(SendMessage{ ChatID: 42, Text: "hello", - ParseMode: ParseMDV2, + ParseMode: ParseMarkdownV2, }) if err != nil { t.Fatalf("Marshal returned error: %v", err) diff --git a/tgmd/doc.go b/tgmd/doc.go new file mode 100644 index 0000000..f480af2 --- /dev/null +++ b/tgmd/doc.go @@ -0,0 +1,2 @@ +// Package tgmd provides small helpers for Telegram Markdown text. +package tgmd diff --git a/tgmd/message_builder.go b/tgmd/message_builder.go new file mode 100644 index 0000000..3e86bac --- /dev/null +++ b/tgmd/message_builder.go @@ -0,0 +1,295 @@ +package tgmd + +import ( + "strings" + "time" + + "git.scuroneko.dev/scuroneko/extypes" + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +//TODO GoDoc, tests. Maybe escape Markdown v2 + +// MessageBuilder builds Telegram message text with explicit message entities. +// MessageBuilder is not safe for concurrent use. +type MessageBuilder struct { + str string + offset int + entities extypes.Slice[tgapi.MessageEntity] + + entries extypes.Slice[*MessageBuilderEntry] + isDirty bool +} + +// NewMessageBuilder returns an empty MessageBuilder. +func NewMessageBuilder() *MessageBuilder { + return &MessageBuilder{ + entities: make([]tgapi.MessageEntity, 0), + entries: make(extypes.Slice[*MessageBuilderEntry], 0), + isDirty: false, + } +} + +// String returns the built message text. +func (b *MessageBuilder) String() string { + if b.isDirty { + b.update() + } + return b.str +} + +// Entities returns a copy of the built message entities. +func (b *MessageBuilder) Entities() []tgapi.MessageEntity { + if b.isDirty { + b.update() + } + return append([]tgapi.MessageEntity(nil), b.entities...) +} + +func (b *MessageBuilder) Build() (string, []tgapi.MessageEntity) { + if b.isDirty { + b.update() + } + return b.str, append([]tgapi.MessageEntity(nil), b.entities...) +} + +func (b *MessageBuilder) Reset() { + b.str = "" + b.offset = 0 + b.entities = b.entities[:0] + b.entries = b.entries[:0] + b.isDirty = false +} + +func (b *MessageBuilder) update() *MessageBuilder { + b.offset = 0 + + var textLen int + var entitiesLen int + for _, e := range b.entries { + textLen += len(e.text) // bytes, для Grow нормально + entitiesLen += len(e.entities) + } + + b.entities = make(extypes.Slice[tgapi.MessageEntity], 0, entitiesLen) + + var sb strings.Builder + sb.Grow(textLen) + + for _, e := range b.entries { + sb.WriteString(e.text) + + for _, entity := range e.entities { + entity.Offset += b.offset + b.entities = append(b.entities, entity) + } + + b.offset += e.length + } + + b.str = sb.String() + b.isDirty = false + return b +} +func (b *MessageBuilder) markDirty() { + b.isDirty = true +} + +type MessageBuilderEntry struct { + text string + length int + + b *MessageBuilder + entities extypes.Slice[tgapi.MessageEntity] +} + +// Add appends plain text to the message and returns its entry for formatting. +func (b *MessageBuilder) Add(text string) *MessageBuilderEntry { + e := &MessageBuilderEntry{ + b: b, + entities: make(extypes.Slice[tgapi.MessageEntity], 0), + + text: text, + length: telegramTextLen(text), + } + b.entries = b.entries.Push(e) + b.markDirty() + + return e +} + +func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityMention, + Offset: 0, Length: e.length, + }) + return e +} + +func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityHashtag, + Offset: 0, Length: e.length, + }) + return e +} + +func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityCashtag, + Offset: 0, Length: e.length, + }) + return e +} + +func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityBotCommand, + Offset: 0, Length: e.length, + }) + return e +} + +func (e *MessageBuilderEntry) Email() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityEmail, + Offset: 0, Length: e.length, + }) + return e +} + +func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityPhoneNumber, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityBold, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityItalic, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityUnderline, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityStrike, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntitySpoiler, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityBlockquote, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityExpandableBlockquote, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityCode, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityPre, + Offset: 0, Length: e.length, + }) + return e +} +func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityPre, + Offset: 0, Length: e.length, Language: lang, + }) + return e +} +func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityTextLink, + Offset: 0, Length: e.length, URL: url, + }) + return e +} +func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityTextMention, + Offset: 0, Length: e.length, User: user, + }) + return e +} +func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityCustomEmoji, + Offset: 0, Length: e.length, CustomEmojiID: emojiID, + }) + return e +} +func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityDateTime, + Offset: 0, Length: e.length, UnixTime: time.Unix(), + }) + return e +} +func (e *MessageBuilderEntry) DateTimeFormat(time time.Time, format string) *MessageBuilderEntry { + e.addEntity(tgapi.MessageEntity{ + Type: tgapi.MessageEntityDateTime, + Offset: 0, Length: e.length, + UnixTime: time.Unix(), DateTimeFormat: format, + }) + return e +} + +func telegramTextLen(text string) int { + n := 0 + for _, r := range text { + if r <= 0xFFFF { + n++ + } else { + n += 2 + } + } + return n +} + +func (e *MessageBuilderEntry) addEntity(entity tgapi.MessageEntity) { + if entity.Length <= 0 { + return + } + e.entities = append(e.entities, entity) + if e.b != nil { + e.b.markDirty() + } +} diff --git a/tgmd/message_builder_test.go b/tgmd/message_builder_test.go new file mode 100644 index 0000000..d4f0d69 --- /dev/null +++ b/tgmd/message_builder_test.go @@ -0,0 +1,416 @@ +package tgmd + +import ( + "reflect" + "testing" + "time" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +func TestMessageBuilder_BuildPlainText(t *testing.T) { + b := NewMessageBuilder() + + b.Add("Hello") + b.Add(", ") + b.Add("world") + + text, entities := b.Build() + + if text != "Hello, world" { + t.Fatalf("text = %q, want %q", text, "Hello, world") + } + + if len(entities) != 0 { + t.Fatalf("entities len = %d, want 0", len(entities)) + } +} + +func TestMessageBuilder_EntityOffsetsAreUTF16(t *testing.T) { + b := NewMessageBuilder() + + b.Add("Hi ") + b.Add("👋") // 2 UTF-16 code units + b.Add(" ") + b.Add("world").Bold() + + text, entities := b.Build() + + if text != "Hi 👋 world" { + t.Fatalf("text = %q, want %q", text, "Hi 👋 world") + } + + want := []tgapi.MessageEntity{ + { + Type: tgapi.MessageEntityBold, + Offset: 6, // H i space = 3, 👋 = 2, space = 1 + Length: 5, + }, + } + + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestMessageBuilder_EntityLengthIsUTF16(t *testing.T) { + b := NewMessageBuilder() + + b.Add("👋").Bold() + + text, entities := b.Build() + + if text != "👋" { + t.Fatalf("text = %q, want %q", text, "👋") + } + + want := []tgapi.MessageEntity{ + { + Type: tgapi.MessageEntityBold, + Offset: 0, + Length: 2, + }, + } + + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestMessageBuilder_MultipleEntitiesOnSameEntry(t *testing.T) { + b := NewMessageBuilder() + + b.Add("hello").Bold().Italic() + + _, entities := b.Build() + + want := []tgapi.MessageEntity{ + { + Type: tgapi.MessageEntityBold, + Offset: 0, + Length: 5, + }, + { + Type: tgapi.MessageEntityItalic, + Offset: 0, + Length: 5, + }, + } + + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestMessageBuilder_DoesNotDuplicateAfterRepeatedReads(t *testing.T) { + b := NewMessageBuilder() + + b.Add("hello").Bold() + + text1 := b.String() + entities1 := b.Entities() + + text2 := b.String() + entities2 := b.Entities() + + if text1 != text2 { + t.Fatalf("texts differ: %q != %q", text1, text2) + } + + if !reflect.DeepEqual(entities1, entities2) { + t.Fatalf("entities differ: %#v != %#v", entities1, entities2) + } + + want := []tgapi.MessageEntity{ + { + Type: tgapi.MessageEntityBold, + Offset: 0, + Length: 5, + }, + } + + if !reflect.DeepEqual(entities2, want) { + t.Fatalf("entities = %#v, want %#v", entities2, want) + } +} + +func TestMessageBuilder_AddEntityAfterStringMarksDirty(t *testing.T) { + b := NewMessageBuilder() + + entry := b.Add("hello") + + if got := b.String(); got != "hello" { + t.Fatalf("String() = %q, want %q", got, "hello") + } + + entry.Bold() + + entities := b.Entities() + + want := []tgapi.MessageEntity{ + { + Type: tgapi.MessageEntityBold, + Offset: 0, + Length: 5, + }, + } + + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestMessageBuilder_EntitiesReturnsCopy(t *testing.T) { + b := NewMessageBuilder() + + b.Add("hello").Bold() + + entities1 := b.Entities() + entities1[0].Offset = 999 + + entities2 := b.Entities() + + if entities2[0].Offset != 0 { + t.Fatalf("Entities() did not return copy: offset = %d, want 0", entities2[0].Offset) + } +} + +func TestMessageBuilder_BuildReturnsEntitiesCopy(t *testing.T) { + b := NewMessageBuilder() + + b.Add("hello").Bold() + + _, entities1 := b.Build() + entities1[0].Offset = 999 + + _, entities2 := b.Build() + + if entities2[0].Offset != 0 { + t.Fatalf("Build() did not return entities copy: offset = %d, want 0", entities2[0].Offset) + } +} + +func TestMessageBuilder_Reset(t *testing.T) { + b := NewMessageBuilder() + + b.Add("hello").Bold() + + if got := b.String(); got != "hello" { + t.Fatalf("String() before Reset = %q, want %q", got, "hello") + } + + b.Reset() + + text, entities := b.Build() + + if text != "" { + t.Fatalf("text after Reset = %q, want empty", text) + } + + if len(entities) != 0 { + t.Fatalf("entities len after Reset = %d, want 0", len(entities)) + } + + b.Add("world").Italic() + + text, entities = b.Build() + + if text != "world" { + t.Fatalf("text after reuse = %q, want %q", text, "world") + } + + want := []tgapi.MessageEntity{ + { + Type: tgapi.MessageEntityItalic, + Offset: 0, + Length: 5, + }, + } + + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities after reuse = %#v, want %#v", entities, want) + } +} + +func TestMessageBuilder_EmptyEntryDoesNotCreateEntity(t *testing.T) { + b := NewMessageBuilder() + + b.Add("").Bold() + b.Add("x") + + text, entities := b.Build() + + if text != "x" { + t.Fatalf("text = %q, want %q", text, "x") + } + + if len(entities) != 0 { + t.Fatalf("entities len = %d, want 0: %#v", len(entities), entities) + } +} + +func TestMessageBuilder_Link(t *testing.T) { + b := NewMessageBuilder() + + b.Add("OpenAI").Link("https://openai.com") + + _, entities := b.Build() + + want := []tgapi.MessageEntity{ + { + Type: tgapi.MessageEntityTextLink, + Offset: 0, + Length: 6, + URL: "https://openai.com", + }, + } + + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestMessageBuilder_CodeBlockWithLanguage(t *testing.T) { + b := NewMessageBuilder() + + b.Add("fmt.Println(\"hi\")").CodeBlockWithLanguage("go") + + _, entities := b.Build() + + want := []tgapi.MessageEntity{ + { + Type: tgapi.MessageEntityPre, + Offset: 0, + Length: 17, + Language: "go", + }, + } + + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestMessageBuilder_DateTimeFormat(t *testing.T) { + b := NewMessageBuilder() + + ts := time.Unix(1772323200, 0) + + b.Add("date").DateTimeFormat(ts, "MMMM d, yyyy") + + _, entities := b.Build() + + want := []tgapi.MessageEntity{ + { + Type: tgapi.MessageEntityDateTime, + Offset: 0, + Length: 4, + UnixTime: 1772323200, + DateTimeFormat: "MMMM d, yyyy", + }, + } + + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestTelegramTextLen(t *testing.T) { + tests := []struct { + name string + text string + want int + }{ + { + name: "ascii", + text: "hello", + want: 5, + }, + { + name: "cyrillic", + text: "привет", + want: 6, + }, + { + name: "emoji", + text: "👋", + want: 2, + }, + { + name: "mixed", + text: "a👋b", + want: 4, + }, + { + name: "zwj sequence", + text: "👨‍👩‍👧‍👦", + want: 11, + }, + { + name: "flag", + text: "🇫🇮", + want: 4, + }, + { + name: "variation selector", + text: "❤️", + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := telegramTextLen(tt.text) + if got != tt.want { + t.Fatalf("telegramTextLen(%q) = %d, want %d", tt.text, got, tt.want) + } + }) + } +} + +func TestMessageBuilder_SimpleEntityTypes(t *testing.T) { + tests := []struct { + name string + add func(*MessageBuilderEntry) + want tgapi.MessageEntityType + }{ + {"mention", func(e *MessageBuilderEntry) { e.Mention() }, tgapi.MessageEntityMention}, + {"hashtag", func(e *MessageBuilderEntry) { e.Hashtag() }, tgapi.MessageEntityHashtag}, + {"cashtag", func(e *MessageBuilderEntry) { e.Cashtag() }, tgapi.MessageEntityCashtag}, + {"bot command", func(e *MessageBuilderEntry) { e.BotCommand() }, tgapi.MessageEntityBotCommand}, + {"email", func(e *MessageBuilderEntry) { e.Email() }, tgapi.MessageEntityEmail}, + {"phone", func(e *MessageBuilderEntry) { e.Phone() }, tgapi.MessageEntityPhoneNumber}, + {"bold", func(e *MessageBuilderEntry) { e.Bold() }, tgapi.MessageEntityBold}, + {"italic", func(e *MessageBuilderEntry) { e.Italic() }, tgapi.MessageEntityItalic}, + {"underline", func(e *MessageBuilderEntry) { e.Underline() }, tgapi.MessageEntityUnderline}, + {"strikethrough", func(e *MessageBuilderEntry) { e.Strikethrough() }, tgapi.MessageEntityStrike}, + {"spoiler", func(e *MessageBuilderEntry) { e.Spoiler() }, tgapi.MessageEntitySpoiler}, + {"quote", func(e *MessageBuilderEntry) { e.Quote() }, tgapi.MessageEntityBlockquote}, + {"expandable quote", func(e *MessageBuilderEntry) { e.ExpandableQuote() }, tgapi.MessageEntityExpandableBlockquote}, + {"inline code", func(e *MessageBuilderEntry) { e.InlineCode() }, tgapi.MessageEntityCode}, + {"code block", func(e *MessageBuilderEntry) { e.CodeBlock() }, tgapi.MessageEntityPre}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := NewMessageBuilder() + + e := b.Add("hello") + tt.add(e) + + _, entities := b.Build() + + want := []tgapi.MessageEntity{ + { + Type: tt.want, + Offset: 0, + Length: 5, + }, + } + + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } + }) + } +} diff --git a/tgmd/utils.go b/tgmd/utils.go new file mode 100644 index 0000000..4de1911 --- /dev/null +++ b/tgmd/utils.go @@ -0,0 +1,71 @@ +package tgmd + +import ( + "strconv" + "strings" + + "git.scuroneko.dev/scuroneko/laniakea" +) + +// Helpers in this file generate Telegram MarkdownV2. +// All user-provided text is escaped. + +// TODO Markdown v2 escaping. GoDoc and tests + +// WithBold returns s wrapped as bold Telegram Markdown text. +func WithBold(s string) string { + return "*" + s + "*" +} + +// WithItalic returns s wrapped as italic Telegram Markdown text. +func WithItalic(s string) string { + return "_" + s + "_" +} + +func WithUnderline(s string) string { + return "__" + s + "__" +} +func WithStrikethrough(s string) string { + return "~" + s + "~" +} +func WithSpoiler(s string) string { + return "||" + s + "||" +} + +// WithLink returns a Telegram Markdown link for text and URL. +func WithLink(text, url string) string { + return "[" + text + "](" + url + ")" +} + +func WithMention(text string, userID uint64) string { + return "[" + text + "](tg://user?id=" + strconv.FormatUint(userID, 10) + ")" +} +func WithEmoji(text, emojiID string) string { + return "[" + text + "](tg://emoji?id=" + emojiID + ")" +} + +func WithTime(text string, unix uint64) string { + return "![" + text + "](tg://time?unix=" + strconv.FormatUint(unix, 10) + ")" +} +func WithTimeFormat(text string, unix uint64, format string) string { + return "![" + text + "](tg://time?unix=" + + strconv.FormatUint(unix, 10) + + "&format=" + format + ")" +} + +// WithInlineCode returns s wrapped as inline code Telegram Markdown text. +func WithInlineCode(s string) string { + return "`" + s + "`" +} +func WithBlockCode(s string) string { + return "```\n" + s + "\n```" +} +func WithBlockCodeLanguage(s, lang string) string { + return "```" + lang + "\n" + s + "\n```" +} +func WithQuote(s string) string { + return ">" + strings.ReplaceAll(laniakea.EscapeMarkdownV2(s), "\n", "\n>") +} +func WithQuoteExpandable(s string) string { + return "**>" + s +} diff --git a/tgmd/utils_test.go b/tgmd/utils_test.go new file mode 100644 index 0000000..8b6ec1b --- /dev/null +++ b/tgmd/utils_test.go @@ -0,0 +1,24 @@ +package tgmd + +import "testing" + +func TestFormattingHelpers(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + {name: "bold", got: WithBold("text"), want: "*text*"}, + {name: "italic", got: WithItalic("text"), want: "_text_"}, + {name: "inline code", got: WithInlineCode("text"), want: "`text`"}, + {name: "link", got: WithLink("Laniakea", "https://example.test"), want: "[Laniakea](https://example.test)"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Fatalf("unexpected formatted text: got %q want %q", tc.got, tc.want) + } + }) + } +} -- 2.54.0 From 6595265cb3eca02ac08220cb1bf2edf2716627aa Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 30 Apr 2026 14:02:15 +0300 Subject: [PATCH 89/99] (ci/cd): added go test and gofmt --- .gitea/workflows/go-lint.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/go-lint.yaml b/.gitea/workflows/go-lint.yaml index 5dc1a35..3ebe461 100644 --- a/.gitea/workflows/go-lint.yaml +++ b/.gitea/workflows/go-lint.yaml @@ -8,5 +8,12 @@ jobs: steps: - name: Checkout repository code uses: actions/checkout@v6 + + - name: Verify formatting + run: test -z "$(gofmt -l .)" + + - name: Run go test + run: go test ./... + - name: Run golangci-lint run: golangci-lint run -- 2.54.0 From 7205b21fa2de14af6ac609e7f22165ce3c2ac8d5 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 4 May 2026 11:20:09 +0300 Subject: [PATCH 90/99] (new): tgfmt package (fix): formatting helpers (ci/cd): go checks (tests): tgfmt coverage --- .gitea/workflows/go-lint.yaml | 13 ++- .golangci.yml | 1 - CHANGELOG.md | 4 +- msg_context.go | 22 ++--- tgfmt/doc.go | 2 + tgfmt/html.go | 99 +++++++++++++++++++++++ tgfmt/html_test.go | 78 ++++++++++++++++++ tgfmt/md.go | 56 +++++++++++++ tgfmt/md_test.go | 36 +++++++++ tgfmt/mdv2.go | 103 ++++++++++++++++++++++++ tgfmt/mdv2_test.go | 78 ++++++++++++++++++ {tgmd => tgfmt}/message_builder.go | 46 ++++++++++- {tgmd => tgfmt}/message_builder_test.go | 2 +- tgfmt/utils.go | 12 +++ tgmd/doc.go | 2 - tgmd/utils.go | 71 ---------------- tgmd/utils_test.go | 24 ------ utils.go | 38 --------- 18 files changed, 532 insertions(+), 155 deletions(-) create mode 100644 tgfmt/doc.go create mode 100644 tgfmt/html.go create mode 100644 tgfmt/html_test.go create mode 100644 tgfmt/md.go create mode 100644 tgfmt/md_test.go create mode 100644 tgfmt/mdv2.go create mode 100644 tgfmt/mdv2_test.go rename {tgmd => tgfmt}/message_builder.go (83%) rename {tgmd => tgfmt}/message_builder_test.go (99%) create mode 100644 tgfmt/utils.go delete mode 100644 tgmd/doc.go delete mode 100644 tgmd/utils.go delete mode 100644 tgmd/utils_test.go diff --git a/.gitea/workflows/go-lint.yaml b/.gitea/workflows/go-lint.yaml index 3ebe461..fafc64e 100644 --- a/.gitea/workflows/go-lint.yaml +++ b/.gitea/workflows/go-lint.yaml @@ -1,6 +1,6 @@ name: Golang lint run-name: Linting code -on: [push] +on: [push, pull_request] jobs: lint: @@ -10,10 +10,19 @@ jobs: uses: actions/checkout@v6 - name: Verify formatting - run: test -z "$(gofmt -l .)" + run: | + files="$(gofmt -l .)" + if [ -n "$files" ]; then + echo "These files are not gofmt-formatted:" + echo "$files" + exit 1 + fi - name: Run go test run: go test ./... + - name: Run go vet + run: go vet ./... + - name: Run golangci-lint run: golangci-lint run diff --git a/.golangci.yml b/.golangci.yml index a7dcbc3..7ea3993 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,7 +5,6 @@ linters: disable-all: true enable: - errcheck - - govet - ineffassign - staticcheck - unused diff --git a/CHANGELOG.md b/CHANGELOG.md index 82879cf..4a49e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,13 @@ - Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code. - Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. - Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware. -- Added the `tgmd` package with Telegram Markdown formatting helpers and a message entity builder. +- Added the `tgfmt` package with typed MarkdownV2, HTML, legacy Markdown formatting helpers, and a message entity builder. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. - Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced. - `BotOpts`, `tgapi.APIOpts`, logger utilities, README, and wiki pages now document the final stable API names and configuration options consistently. +- CI now checks formatting, tests, vet, and lint on both pushes and pull requests. ### Fixed - Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized. @@ -29,6 +30,7 @@ - Added webhook runtime regression coverage for request enqueue through worker execution of a command handler. - Added regression coverage for inline callback keyboard upserts and callback target detection. - 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. ## v1.0.0-rc.16 diff --git a/msg_context.go b/msg_context.go index 44862fe..cdbf42a 100644 --- a/msg_context.go +++ b/msg_context.go @@ -139,7 +139,7 @@ func (m *AnswerMessage) Edit(text string) *AnswerMessage { // EditMarkdown replaces the text of the message using MarkdownV2 formatting. // -// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +// ⚠️ WARNING: User input must be escaped with tgfmt.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.ParseMarkdownV2) @@ -161,7 +161,7 @@ func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *Answ // EditCallbackMarkdown edits the callback message using MarkdownV2. // -// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.editCallback(text, keyboard, tgapi.ParseMarkdownV2) } @@ -173,7 +173,7 @@ func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, ar // 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. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2) } @@ -223,7 +223,7 @@ func (m *AnswerMessage) EditCaption(text string) *AnswerMessage { // EditCaptionMarkdown edits the caption of a media message using MarkdownV2. // -// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (m *AnswerMessage) EditCaptionMarkdown(text string) *AnswerMessage { return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMarkdownV2) } @@ -235,7 +235,7 @@ func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *An // 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. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeyboard) *AnswerMessage { return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2) } @@ -290,7 +290,7 @@ func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage { // AnswerMarkdown sends a message using MarkdownV2 formatting. // -// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage { return ctx.answer(text, nil, tgapi.ParseMarkdownV2) } @@ -307,7 +307,7 @@ func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessag // AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2. // -// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } @@ -326,7 +326,7 @@ func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMe // KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2. // -// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.answer(text, keyboard, tgapi.ParseMarkdownV2) } @@ -413,7 +413,7 @@ func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage { // AnswerPhotoMarkdown sends a photo with MarkdownV2 caption. // -// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage { return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMarkdownV2) } @@ -425,7 +425,7 @@ func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeybo // AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2. // -// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage { return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMarkdownV2) } @@ -437,7 +437,7 @@ func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *Answ // 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. +// ⚠️ WARNING: User input must be escaped with tgfmt.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage { return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } diff --git a/tgfmt/doc.go b/tgfmt/doc.go new file mode 100644 index 0000000..e2a44c0 --- /dev/null +++ b/tgfmt/doc.go @@ -0,0 +1,2 @@ +// Package tgfmt provides small helpers for Telegram text formatting. +package tgfmt diff --git a/tgfmt/html.go b/tgfmt/html.go new file mode 100644 index 0000000..3460195 --- /dev/null +++ b/tgfmt/html.go @@ -0,0 +1,99 @@ +package tgfmt + +import ( + "strconv" + "strings" +) + +// HTML is an escaped Telegram HTML fragment. +// +// Methods on HTML compose formatting without escaping the fragment again. +type HTML string + +// EscapeHTML escapes special characters for Telegram HTML parse mode. +func EscapeHTML(s string) HTML { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + return HTML(s) +} + +// Bold returns h wrapped as bold Telegram HTML text. +func (h HTML) Bold() HTML { + return "" + h + "" +} + +// Italic returns h wrapped as italic Telegram HTML text. +func (h HTML) Italic() HTML { + return "" + h + "" +} + +// Underline returns h wrapped as underlined Telegram HTML text. +func (h HTML) Underline() HTML { + return "" + h + "" +} + +// Strikethrough returns h wrapped as strikethrough Telegram HTML text. +func (h HTML) Strikethrough() HTML { + return "" + h + "" +} + +// Spoiler returns h wrapped as spoiler Telegram HTML text. +func (h HTML) Spoiler() HTML { + return "" + h + "" +} + +// Link returns h as a Telegram HTML text link. +func (h HTML) Link(url string) HTML { + return `` + h + "" +} + +// Mention returns h as a Telegram HTML user mention. +func (h HTML) Mention(userID int64) HTML { + return `` + h + "" +} + +// Emoji returns h as a Telegram HTML custom emoji. +func (h HTML) Emoji(emojiID string) HTML { + return `` + h + "" +} + +// Time returns h as a Telegram HTML localized timestamp. +func (h HTML) Time(unix int64) HTML { + return `` + h + "" +} + +// TimeFormat returns h as a Telegram HTML localized timestamp with format. +func (h HTML) TimeFormat(unix int64, format string) HTML { + return `` + h + "" +} + +// InlineCode returns h wrapped as inline code Telegram HTML text. +func (h HTML) InlineCode() HTML { + return "" + h + "" +} + +// BlockCode returns h wrapped as a Telegram HTML code block. +func (h HTML) BlockCode() HTML { + return "

" + h + "
" +} + +// BlockCodeLanguage returns h wrapped as a Telegram HTML code block with language. +func (h HTML) BlockCodeLanguage(lang string) HTML { + return `
` + h + "
" +} + +// Quote returns h as a Telegram HTML blockquote. +func (h HTML) Quote() HTML { + return "
" + h + "
" +} + +// QuoteExpandable returns h as a Telegram HTML expandable blockquote. +func (h HTML) QuoteExpandable() HTML { + return "
" + h + "
" +} + +func escapeHTMLAttr(s string) HTML { + return EscapeHTML(s) +} diff --git a/tgfmt/html_test.go b/tgfmt/html_test.go new file mode 100644 index 0000000..338bc37 --- /dev/null +++ b/tgfmt/html_test.go @@ -0,0 +1,78 @@ +package tgfmt + +import "testing" + +func TestEscapeHTML(t *testing.T) { + got := EscapeHTML(``) + want := HTML(`<tag attr="a&b">`) + + if got != want { + t.Fatalf("EscapeHTML() = %q, want %q", got, want) + } +} + +func TestHTMLComposesWithoutDoubleEscaping(t *testing.T) { + got := EscapeHTML("").Bold().Italic() + want := HTML("<b>") + + if got != want { + t.Fatalf("formatted HTML = %q, want %q", got, want) + } +} + +func TestHTMLFormattingMethods(t *testing.T) { + tests := []struct { + name string + got HTML + want HTML + }{ + {name: "bold", got: EscapeHTML("text").Bold(), want: "text"}, + {name: "italic", got: EscapeHTML("text").Italic(), want: "text"}, + {name: "underline", got: EscapeHTML("text").Underline(), want: "text"}, + {name: "strikethrough", got: EscapeHTML("text").Strikethrough(), want: "text"}, + {name: "spoiler", got: EscapeHTML("text").Spoiler(), want: "text"}, + {name: "inline code", got: EscapeHTML("text").InlineCode(), want: "text"}, + {name: "block code", got: EscapeHTML("text").BlockCode(), want: "
text
"}, + {name: "block code language", got: EscapeHTML("text").BlockCodeLanguage(`go"`), want: `
text
`}, + {name: "quote", got: EscapeHTML("text").Quote(), want: "
text
"}, + {name: "expandable quote", got: EscapeHTML("text").QuoteExpandable(), want: "
text
"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("formatted HTML = %q, want %q", tt.got, tt.want) + } + }) + } +} + +func TestHTMLLinkEscapesAttributes(t *testing.T) { + got := EscapeHTML("Laniakea").Link(`https://example.test/?q="a&b"`) + want := HTML(`Laniakea`) + + if got != want { + t.Fatalf("Link() = %q, want %q", got, want) + } +} + +func TestHTMLSpecialLinks(t *testing.T) { + tests := []struct { + name string + got HTML + want HTML + }{ + {name: "mention", got: EscapeHTML("User").Mention(123), want: `User`}, + {name: "emoji", got: EscapeHTML("emoji").Emoji(`12"3`), want: `emoji`}, + {name: "time", got: EscapeHTML("date").Time(1772323200), want: `date`}, + {name: "time format", got: EscapeHTML("date").TimeFormat(1772323200, `MMM " yyyy`), want: `date`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("formatted HTML link = %q, want %q", tt.got, tt.want) + } + }) + } +} diff --git a/tgfmt/md.go b/tgfmt/md.go new file mode 100644 index 0000000..3269d49 --- /dev/null +++ b/tgfmt/md.go @@ -0,0 +1,56 @@ +package tgfmt + +import ( + "strconv" + "strings" +) + +// Markdown is an escaped legacy Telegram Markdown fragment. +// +// Deprecated: Use MarkdownV2 instead. +type Markdown string + +// EscapeMarkdown escapes special characters for legacy Telegram Markdown. +// +// Deprecated: Use EscapeMarkdownV2 instead. +func EscapeMarkdown(s string) Markdown { + s = strings.ReplaceAll(s, "_", `\_`) + s = strings.ReplaceAll(s, "*", `\*`) + s = strings.ReplaceAll(s, "[", `\[`) + return Markdown(strings.ReplaceAll(s, "`", "\\`")) +} + +// Bold returns s wrapped as bold legacy Telegram Markdown text. +func (s Markdown) Bold() Markdown { + return "*" + s + "*" +} + +// Italic returns s wrapped as italic legacy Telegram Markdown text. +func (s Markdown) Italic() Markdown { + return "_" + s + "_" +} + +// Link returns s as a legacy Telegram Markdown text link. +func (s Markdown) Link(url string) Markdown { + return "[" + s + "](" + Markdown(url) + ")" +} + +// Mention returns s as a legacy Telegram Markdown user mention. +func (s Markdown) Mention(userID int64) Markdown { + return "[" + s + "](tg://user?id=" + Markdown(strconv.FormatInt(userID, 10)) + ")" +} + +// InlineCode returns s wrapped as inline code legacy Telegram Markdown text. +func (s Markdown) InlineCode() Markdown { + return "`" + s + "`" +} + +// BlockCode returns s wrapped as a legacy Telegram Markdown code block. +func (s Markdown) BlockCode() Markdown { + return "```\n" + s + "\n```" +} + +// BlockCodeLanguage returns s wrapped as a legacy Telegram Markdown code block. +func (s Markdown) BlockCodeLanguage(lang string) Markdown { + return "```" + Markdown(lang) + "\n" + s + "\n```" +} diff --git a/tgfmt/md_test.go b/tgfmt/md_test.go new file mode 100644 index 0000000..beea1da --- /dev/null +++ b/tgfmt/md_test.go @@ -0,0 +1,36 @@ +package tgfmt + +import "testing" + +func TestEscapeMarkdown(t *testing.T) { + got := EscapeMarkdown("a_b*c[1]`x`") + want := Markdown("a\\_b\\*c\\[1]\\`x\\`") + + if got != want { + t.Fatalf("EscapeMarkdown() = %q, want %q", got, want) + } +} + +func TestMarkdownFormattingMethods(t *testing.T) { + tests := []struct { + name string + got Markdown + want Markdown + }{ + {name: "bold", got: EscapeMarkdown("text").Bold(), want: "*text*"}, + {name: "italic", got: EscapeMarkdown("text").Italic(), want: "_text_"}, + {name: "link", got: EscapeMarkdown("Laniakea").Link("https://example.test"), want: "[Laniakea](https://example.test)"}, + {name: "mention", got: EscapeMarkdown("User").Mention(123), want: "[User](tg://user?id=123)"}, + {name: "inline code", got: EscapeMarkdown("text").InlineCode(), want: "`text`"}, + {name: "block code", got: EscapeMarkdown("text").BlockCode(), want: "```\ntext\n```"}, + {name: "block code language", got: EscapeMarkdown("text").BlockCodeLanguage("go"), want: "```go\ntext\n```"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("formatted Markdown = %q, want %q", tt.got, tt.want) + } + }) + } +} diff --git a/tgfmt/mdv2.go b/tgfmt/mdv2.go new file mode 100644 index 0000000..2274a89 --- /dev/null +++ b/tgfmt/mdv2.go @@ -0,0 +1,103 @@ +package tgfmt + +import ( + "strconv" + "strings" +) + +// MarkdownV2 is an escaped Telegram MarkdownV2 fragment. +// +// Methods on MarkdownV2 compose formatting without escaping the fragment again. +type MarkdownV2 string + +// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2. +// https://core.telegram.org/bots/api#markdownv2-style +func EscapeMarkdownV2(s string) MarkdownV2 { + symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"} + for _, symbol := range symbols { + s = strings.ReplaceAll(s, symbol, "\\"+symbol) + } + return MarkdownV2(s) +} + +// Bold returns s wrapped as bold Telegram MarkdownV2 text. +func (s MarkdownV2) Bold() MarkdownV2 { + return "*" + s + "*" +} + +// Italic returns s wrapped as italic Telegram MarkdownV2 text. +func (s MarkdownV2) Italic() MarkdownV2 { + return "_" + s + "_" +} + +// Underline returns s wrapped as underlined Telegram MarkdownV2 text. +func (s MarkdownV2) Underline() MarkdownV2 { + return "__" + s + "__" +} + +// Strikethrough returns s wrapped as strikethrough Telegram MarkdownV2 text. +func (s MarkdownV2) Strikethrough() MarkdownV2 { + return "~" + s + "~" +} + +// Spoiler returns s wrapped as spoiler Telegram MarkdownV2 text. +func (s MarkdownV2) Spoiler() MarkdownV2 { + return "||" + s + "||" +} + +// Link returns s as a Telegram MarkdownV2 text link. +func (s MarkdownV2) Link(url string) MarkdownV2 { + return "[" + s + "](" + escapeMarkdownV2LinkDestination(url) + ")" +} + +// Mention returns s as a Telegram MarkdownV2 user mention. +func (s MarkdownV2) Mention(userID uint64) MarkdownV2 { + return "[" + s + "](tg://user?id=" + MarkdownV2(strconv.FormatUint(userID, 10)) + ")" +} + +// Emoji returns s as a Telegram MarkdownV2 custom emoji. +func (s MarkdownV2) Emoji(emojiID string) MarkdownV2 { + return "[" + s + "](tg://emoji?id=" + escapeMarkdownV2LinkDestination(emojiID) + ")" +} + +// Time returns s as a Telegram MarkdownV2 localized timestamp. +func (s MarkdownV2) Time(unix uint64) MarkdownV2 { + return "![" + s + "](tg://time?unix=" + MarkdownV2(strconv.FormatUint(unix, 10)) + ")" +} + +// TimeFormat returns s as a Telegram MarkdownV2 localized timestamp with format. +func (s MarkdownV2) TimeFormat(unix uint64, format string) MarkdownV2 { + dest := "tg://time?unix=" + strconv.FormatUint(unix, 10) + "&format=" + format + return "![" + s + "](" + escapeMarkdownV2LinkDestination(dest) + ")" +} + +// InlineCode returns s wrapped as inline code Telegram MarkdownV2 text. +func (s MarkdownV2) InlineCode() MarkdownV2 { + return "`" + s + "`" +} + +// BlockCode returns s wrapped as a Telegram MarkdownV2 code block. +func (s MarkdownV2) BlockCode() MarkdownV2 { + return "```\n" + s + "\n```" +} + +// BlockCodeLanguage returns s wrapped as a Telegram MarkdownV2 code block with language. +func (s MarkdownV2) BlockCodeLanguage(lang string) MarkdownV2 { + return "```" + MarkdownV2(lang) + "\n" + s + "\n```" +} + +// Quote returns s as a Telegram MarkdownV2 blockquote. +func (s MarkdownV2) Quote() MarkdownV2 { + return MarkdownV2(">" + strings.ReplaceAll(string(s), "\n", "\n>")) +} + +// QuoteExpandable returns s as a Telegram MarkdownV2 expandable blockquote. +func (s MarkdownV2) QuoteExpandable() MarkdownV2 { + return "**>" + s +} + +func escapeMarkdownV2LinkDestination(s string) MarkdownV2 { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, ")", "\\)") + return MarkdownV2(s) +} diff --git a/tgfmt/mdv2_test.go b/tgfmt/mdv2_test.go new file mode 100644 index 0000000..ef3de7d --- /dev/null +++ b/tgfmt/mdv2_test.go @@ -0,0 +1,78 @@ +package tgfmt + +import "testing" + +func TestEscapeMarkdownV2(t *testing.T) { + got := EscapeMarkdownV2(`a_b*c[1](x)!`) + want := MarkdownV2(`a\_b\*c\[1\]\(x\)\!`) + + if got != want { + t.Fatalf("EscapeMarkdownV2() = %q, want %q", got, want) + } +} + +func TestMarkdownV2ComposesWithoutDoubleEscaping(t *testing.T) { + got := EscapeMarkdownV2("a*b").Bold().Italic() + want := MarkdownV2(`_*a\*b*_`) + + if got != want { + t.Fatalf("formatted text = %q, want %q", got, want) + } +} + +func TestMarkdownV2FormattingMethods(t *testing.T) { + tests := []struct { + name string + got MarkdownV2 + want MarkdownV2 + }{ + {name: "bold", got: EscapeMarkdownV2("text").Bold(), want: "*text*"}, + {name: "italic", got: EscapeMarkdownV2("text").Italic(), want: "_text_"}, + {name: "underline", got: EscapeMarkdownV2("text").Underline(), want: "__text__"}, + {name: "strikethrough", got: EscapeMarkdownV2("text").Strikethrough(), want: "~text~"}, + {name: "spoiler", got: EscapeMarkdownV2("text").Spoiler(), want: "||text||"}, + {name: "inline code", got: EscapeMarkdownV2("text").InlineCode(), want: "`text`"}, + {name: "block code", got: EscapeMarkdownV2("text").BlockCode(), want: "```\ntext\n```"}, + {name: "block code language", got: EscapeMarkdownV2("text").BlockCodeLanguage("go"), want: "```go\ntext\n```"}, + {name: "quote", got: EscapeMarkdownV2("a\nb").Quote(), want: ">a\n>b"}, + {name: "expandable quote", got: EscapeMarkdownV2("text").QuoteExpandable(), want: "**>text"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("formatted text = %q, want %q", tt.got, tt.want) + } + }) + } +} + +func TestMarkdownV2LinkEscapesDestination(t *testing.T) { + got := EscapeMarkdownV2("Laniakea").Link(`https://example.test/a)b\c`) + want := MarkdownV2(`[Laniakea](https://example.test/a\)b\\c)`) + + if got != want { + t.Fatalf("Link() = %q, want %q", got, want) + } +} + +func TestMarkdownV2SpecialLinks(t *testing.T) { + tests := []struct { + name string + got MarkdownV2 + want MarkdownV2 + }{ + {name: "mention", got: EscapeMarkdownV2("User").Mention(123), want: "[User](tg://user?id=123)"}, + {name: "emoji", got: EscapeMarkdownV2("emoji").Emoji(`12)3`), want: `[emoji](tg://emoji?id=12\)3)`}, + {name: "time", got: EscapeMarkdownV2("date").Time(1772323200), want: "![date](tg://time?unix=1772323200)"}, + {name: "time format", got: EscapeMarkdownV2("date").TimeFormat(1772323200, `MMM ) yyyy`), want: `![date](tg://time?unix=1772323200&format=MMM \) yyyy)`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("formatted link = %q, want %q", tt.got, tt.want) + } + }) + } +} diff --git a/tgmd/message_builder.go b/tgfmt/message_builder.go similarity index 83% rename from tgmd/message_builder.go rename to tgfmt/message_builder.go index 3e86bac..03b771d 100644 --- a/tgmd/message_builder.go +++ b/tgfmt/message_builder.go @@ -1,4 +1,4 @@ -package tgmd +package tgfmt import ( "strings" @@ -8,8 +8,6 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -//TODO GoDoc, tests. Maybe escape Markdown v2 - // MessageBuilder builds Telegram message text with explicit message entities. // MessageBuilder is not safe for concurrent use. type MessageBuilder struct { @@ -46,6 +44,7 @@ func (b *MessageBuilder) Entities() []tgapi.MessageEntity { return append([]tgapi.MessageEntity(nil), b.entities...) } +// Build returns the built message text and a copy of its entities. func (b *MessageBuilder) Build() (string, []tgapi.MessageEntity) { if b.isDirty { b.update() @@ -53,6 +52,7 @@ func (b *MessageBuilder) Build() (string, []tgapi.MessageEntity) { return b.str, append([]tgapi.MessageEntity(nil), b.entities...) } +// Reset clears the builder and keeps it ready for reuse. func (b *MessageBuilder) Reset() { b.str = "" b.offset = 0 @@ -67,7 +67,7 @@ func (b *MessageBuilder) update() *MessageBuilder { var textLen int var entitiesLen int for _, e := range b.entries { - textLen += len(e.text) // bytes, для Grow нормально + textLen += len(e.text) entitiesLen += len(e.entities) } @@ -91,10 +91,12 @@ func (b *MessageBuilder) update() *MessageBuilder { b.isDirty = false return b } + func (b *MessageBuilder) markDirty() { b.isDirty = true } +// MessageBuilderEntry represents text appended to a MessageBuilder. type MessageBuilderEntry struct { text string length int @@ -118,6 +120,7 @@ func (b *MessageBuilder) Add(text string) *MessageBuilderEntry { return e } +// Mention marks the entry as a Telegram mention. func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityMention, @@ -126,6 +129,7 @@ func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry { return e } +// Hashtag marks the entry as a Telegram hashtag. func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityHashtag, @@ -134,6 +138,7 @@ func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry { return e } +// Cashtag marks the entry as a Telegram cashtag. func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityCashtag, @@ -142,6 +147,7 @@ func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry { return e } +// BotCommand marks the entry as a Telegram bot command. func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityBotCommand, @@ -150,6 +156,7 @@ func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry { return e } +// Email marks the entry as an email address. func (e *MessageBuilderEntry) Email() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityEmail, @@ -158,6 +165,7 @@ func (e *MessageBuilderEntry) Email() *MessageBuilderEntry { return e } +// Phone marks the entry as a phone number. func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityPhoneNumber, @@ -165,6 +173,8 @@ func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry { }) return e } + +// Bold marks the entry as bold text. func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityBold, @@ -172,6 +182,8 @@ func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry { }) return e } + +// Italic marks the entry as italic text. func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityItalic, @@ -179,6 +191,8 @@ func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry { }) return e } + +// Underline marks the entry as underlined text. func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityUnderline, @@ -186,6 +200,8 @@ func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry { }) return e } + +// Strikethrough marks the entry as strikethrough text. func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityStrike, @@ -193,6 +209,8 @@ func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry { }) return e } + +// Spoiler marks the entry as spoiler text. func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntitySpoiler, @@ -200,6 +218,8 @@ func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry { }) return e } + +// Quote marks the entry as a blockquote. func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityBlockquote, @@ -207,6 +227,8 @@ func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry { }) return e } + +// ExpandableQuote marks the entry as an expandable blockquote. func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityExpandableBlockquote, @@ -214,6 +236,8 @@ func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry { }) return e } + +// InlineCode marks the entry as inline code. func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityCode, @@ -221,6 +245,8 @@ func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry { }) return e } + +// CodeBlock marks the entry as a preformatted code block. func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityPre, @@ -228,6 +254,8 @@ func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry { }) return e } + +// CodeBlockWithLanguage marks the entry as a preformatted code block with language. func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityPre, @@ -235,6 +263,8 @@ func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilder }) return e } + +// Link marks the entry as a text link. func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityTextLink, @@ -242,6 +272,8 @@ func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry { }) return e } + +// TextMention marks the entry as a mention of user. func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityTextMention, @@ -249,6 +281,8 @@ func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry }) return e } + +// CustomEmoji marks the entry as a custom emoji. func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityCustomEmoji, @@ -256,6 +290,8 @@ func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry { }) return e } + +// DateTime marks the entry as a localized timestamp. func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityDateTime, @@ -263,6 +299,8 @@ func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry { }) return e } + +// DateTimeFormat marks the entry as a localized timestamp with format. func (e *MessageBuilderEntry) DateTimeFormat(time time.Time, format string) *MessageBuilderEntry { e.addEntity(tgapi.MessageEntity{ Type: tgapi.MessageEntityDateTime, diff --git a/tgmd/message_builder_test.go b/tgfmt/message_builder_test.go similarity index 99% rename from tgmd/message_builder_test.go rename to tgfmt/message_builder_test.go index d4f0d69..2dcad85 100644 --- a/tgmd/message_builder_test.go +++ b/tgfmt/message_builder_test.go @@ -1,4 +1,4 @@ -package tgmd +package tgfmt import ( "reflect" diff --git a/tgfmt/utils.go b/tgfmt/utils.go new file mode 100644 index 0000000..47d47eb --- /dev/null +++ b/tgfmt/utils.go @@ -0,0 +1,12 @@ +package tgfmt + +import "strings" + +// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments. +func EscapePunctuation(s string) string { + symbols := []string{".", "!", "-"} + for _, symbol := range symbols { + s = strings.ReplaceAll(s, symbol, "\\"+symbol) + } + return s +} diff --git a/tgmd/doc.go b/tgmd/doc.go deleted file mode 100644 index f480af2..0000000 --- a/tgmd/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package tgmd provides small helpers for Telegram Markdown text. -package tgmd diff --git a/tgmd/utils.go b/tgmd/utils.go deleted file mode 100644 index 4de1911..0000000 --- a/tgmd/utils.go +++ /dev/null @@ -1,71 +0,0 @@ -package tgmd - -import ( - "strconv" - "strings" - - "git.scuroneko.dev/scuroneko/laniakea" -) - -// Helpers in this file generate Telegram MarkdownV2. -// All user-provided text is escaped. - -// TODO Markdown v2 escaping. GoDoc and tests - -// WithBold returns s wrapped as bold Telegram Markdown text. -func WithBold(s string) string { - return "*" + s + "*" -} - -// WithItalic returns s wrapped as italic Telegram Markdown text. -func WithItalic(s string) string { - return "_" + s + "_" -} - -func WithUnderline(s string) string { - return "__" + s + "__" -} -func WithStrikethrough(s string) string { - return "~" + s + "~" -} -func WithSpoiler(s string) string { - return "||" + s + "||" -} - -// WithLink returns a Telegram Markdown link for text and URL. -func WithLink(text, url string) string { - return "[" + text + "](" + url + ")" -} - -func WithMention(text string, userID uint64) string { - return "[" + text + "](tg://user?id=" + strconv.FormatUint(userID, 10) + ")" -} -func WithEmoji(text, emojiID string) string { - return "[" + text + "](tg://emoji?id=" + emojiID + ")" -} - -func WithTime(text string, unix uint64) string { - return "![" + text + "](tg://time?unix=" + strconv.FormatUint(unix, 10) + ")" -} -func WithTimeFormat(text string, unix uint64, format string) string { - return "![" + text + "](tg://time?unix=" + - strconv.FormatUint(unix, 10) + - "&format=" + format + ")" -} - -// WithInlineCode returns s wrapped as inline code Telegram Markdown text. -func WithInlineCode(s string) string { - return "`" + s + "`" -} -func WithBlockCode(s string) string { - return "```\n" + s + "\n```" -} -func WithBlockCodeLanguage(s, lang string) string { - return "```" + lang + "\n" + s + "\n```" -} -func WithQuote(s string) string { - return ">" + strings.ReplaceAll(laniakea.EscapeMarkdownV2(s), "\n", "\n>") -} -func WithQuoteExpandable(s string) string { - return "**>" + s -} diff --git a/tgmd/utils_test.go b/tgmd/utils_test.go deleted file mode 100644 index 8b6ec1b..0000000 --- a/tgmd/utils_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package tgmd - -import "testing" - -func TestFormattingHelpers(t *testing.T) { - tests := []struct { - name string - got string - want string - }{ - {name: "bold", got: WithBold("text"), want: "*text*"}, - {name: "italic", got: WithItalic("text"), want: "_text_"}, - {name: "inline code", got: WithInlineCode("text"), want: "`text`"}, - {name: "link", got: WithLink("Laniakea", "https://example.test"), want: "[Laniakea](https://example.test)"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if tc.got != tc.want { - t.Fatalf("unexpected formatted text: got %q want %q", tc.got, tc.want) - } - }) - } -} diff --git a/utils.go b/utils.go index 0cb42ac..3de6014 100644 --- a/utils.go +++ b/utils.go @@ -1,8 +1,6 @@ package laniakea import ( - "strings" - "git.scuroneko.dev/scuroneko/laniakea/utils" ) @@ -17,42 +15,6 @@ func Val[T any](p *T, def T) T { return def } -// EscapeMarkdown escapes special characters for legacy Telegram Markdown. -// Deprecated: Use EscapeMarkdownV2. -func EscapeMarkdown(s string) string { - s = strings.ReplaceAll(s, "_", `\_`) - s = strings.ReplaceAll(s, "*", `\*`) - s = strings.ReplaceAll(s, "[", `\[`) - return strings.ReplaceAll(s, "`", "\\`") -} - -// EscapeHTML escapes special characters for Telegram HTML parse mode. -func EscapeHTML(s string) string { - s = strings.ReplaceAll(s, "&", "&") - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - return s -} - -// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2. -// https://core.telegram.org/bots/api#markdownv2-style -func EscapeMarkdownV2(s string) string { - symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"} - for _, symbol := range symbols { - s = strings.ReplaceAll(s, symbol, "\\"+symbol) - } - return s -} - -// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments. -func EscapePunctuation(s string) string { - symbols := []string{".", "!", "-"} - for _, symbol := range symbols { - s = strings.ReplaceAll(s, symbol, "\\"+symbol) - } - return s -} - const ( // VersionString re-exports the module version string. VersionString = utils.VersionString -- 2.54.0 From daa1b862ed9ddb3a8bf0999e8c6f2ddb3de58f57 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 4 May 2026 11:35:02 +0300 Subject: [PATCH 91/99] (new): keyboard helpers (tests): payload encoding (doc): changelog --- CHANGELOG.md | 2 ++ keyboard.go | 42 +++++++++++++++++++++++++++++++----------- keyboard_test.go | 28 ++++++++++++++++++++++++++++ msg_context.go | 5 +++++ msg_context_test.go | 17 +++++++++++++++++ 5 files changed, 83 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a49e67..ed2f98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. - 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. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. @@ -31,6 +32,7 @@ - Added regression coverage for inline callback keyboard upserts and callback target detection. - 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. ## v1.0.0-rc.16 diff --git a/keyboard.go b/keyboard.go index b874afa..85958c8 100644 --- a/keyboard.go +++ b/keyboard.go @@ -27,11 +27,14 @@ const ( // Call build() to produce the final tgapi.InlineKeyboardButton. // Builder methods are immutable — each returns a copy. type InlineKeyboardButtonBuilder struct { - text string - iconCustomEmojiID string - style tgapi.KeyboardButtonStyle - url string - callbackData string + text string + emojiID string + style tgapi.KeyboardButtonStyle + + url string + data string + + payloadType BotPayloadType } // NewInlineKeyboardButton creates a new button builder with the given display text. @@ -43,7 +46,7 @@ func NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder { // SetIconCustomEmojiID sets a custom emoji ID to display as the button's icon. // This is a Telegram Bot API feature for custom emoji icons. func (b InlineKeyboardButtonBuilder) SetIconCustomEmojiID(id string) InlineKeyboardButtonBuilder { - b.iconCustomEmojiID = id + b.emojiID = id return b } @@ -62,6 +65,12 @@ func (b InlineKeyboardButtonBuilder) SetURL(url string) InlineKeyboardButtonBuil return b } +// SetPayloadType sets the encoding used by SetCallbackData. +func (b InlineKeyboardButtonBuilder) SetPayloadType(t BotPayloadType) InlineKeyboardButtonBuilder { + b.payloadType = t + return b +} + // SetCallbackDataJSON sets a structured callback payload that will be sent to the bot // when the button is pressed. The command and arguments are serialized as JSON. // @@ -70,7 +79,7 @@ func (b InlineKeyboardButtonBuilder) SetURL(url string) InlineKeyboardButtonBuil // // Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKeyboardButtonBuilder { - b.callbackData = NewCallbackData(cmd, args...).ToJSON() + b.data = NewCallbackData(cmd, args...).ToJSON() return b } @@ -78,7 +87,18 @@ func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any // This can be useful when the JSON payload exceeds Telegram's callback data length limit. // Args are converted to strings using fmt.Sprint. func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKeyboardButtonBuilder { - b.callbackData = NewCallbackData(cmd, args...).ToBase64() + b.data = NewCallbackData(cmd, args...).ToBase64() + 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 { + b.data = NewCallbackData(cmd, args...).ToBase64() + } else { + b.data = NewCallbackData(cmd, args...).ToJSON() + } return b } @@ -88,8 +108,8 @@ func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton { Text: b.text, URL: b.url, Style: b.style, - IconCustomEmojiID: b.iconCustomEmojiID, - CallbackData: b.callbackData, + IconCustomEmojiID: b.emojiID, + CallbackData: b.data, } } @@ -177,7 +197,7 @@ func (in *InlineKeyboard) AddURLButtonStyle(text string, style tgapi.KeyboardBut // AddCallbackButton adds a button that sends a structured callback payload to the bot. // The command and args are serialized according to the current payloadType. -func (in *InlineKeyboard) AddCallbackButton(text string, cmd string, args ...any) *InlineKeyboard { +func (in *InlineKeyboard) AddCallbackButton(text, cmd string, args ...any) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{ Text: text, CallbackData: NewCallbackData(cmd, args...).Encode(in.payloadType), diff --git a/keyboard_test.go b/keyboard_test.go index ac1f5ac..03d495f 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -45,6 +45,34 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { } } +func TestInlineKeyboardButtonBuilderSetCallbackDataDefaultsToJSON(t *testing.T) { + kb := NewInlineKeyboardBase64(1). + AddButton(NewInlineKeyboardButton("A").SetCallbackData("cmd", 1, "two")) + + button := kb.Get().InlineKeyboard[0][0] + if !strings.Contains(button.CallbackData, `"cmd":"cmd"`) { + t.Fatalf("expected JSON callback payload, got %q", button.CallbackData) + } +} + +func TestInlineKeyboardButtonBuilderSetCallbackDataUsesConfiguredPayloadType(t *testing.T) { + kb := NewInlineKeyboardJSON(1). + AddButton(NewInlineKeyboardButton("A"). + SetPayloadType(BotPayloadBase64). + SetCallbackData("cmd", 1, "two"), + ) + + got, _, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, false) + if err != nil { + t.Fatalf("decodePayload returned error: %v", err) + } + + want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected payload: got %#v want %#v", got, want) + } +} + func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) { kb := NewInlineKeyboardJSON(2) if got := kb.GetPayloadType(); got != BotPayloadJSON { diff --git a/msg_context.go b/msg_context.go index cdbf42a..f5841f9 100644 --- a/msg_context.go +++ b/msg_context.go @@ -594,6 +594,11 @@ func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard { return NewInlineKeyboard(ctx.payloadType, maxRow) } +// NewInlineKeyboardButton creates a button builder using the context payload encoding. +func (ctx *MsgContext) NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder { + return NewInlineKeyboardButton(text).SetPayloadType(ctx.payloadType) +} + func bindPositional(args []string, dst any) error { v := reflect.ValueOf(dst) if v.Kind() != reflect.Pointer || v.IsNil() { diff --git a/msg_context_test.go b/msg_context_test.go index f9f71bb..3b9f2a6 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -91,6 +91,23 @@ func TestBindArgsBindsScalarFields(t *testing.T) { } } +func TestNewInlineKeyboardButtonUsesContextPayloadType(t *testing.T) { + ctx := &MsgContext{payloadType: BotPayloadBase64} + + kb := NewInlineKeyboardJSON(1). + AddButton(ctx.NewInlineKeyboardButton("A").SetCallbackData("cmd", 1, "two")) + + got, _, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, false) + if err != nil { + t.Fatalf("decodePayload returned error: %v", err) + } + + want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected payload: got %#v want %#v", got, want) + } +} + func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) { type input struct { ID int -- 2.54.0 From 5959d6994540e66dee87fd5cacd8bb2fc032eadf Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Fri, 8 May 2026 14:11:58 +0300 Subject: [PATCH 92/99] (fix): polling retry-after (tests): polling rate-limit (doc): changelog update --- CHANGELOG.md | 3 +++ bot.go | 15 ++++++++++---- bot_test.go | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ bot_utils.go | 13 ++++++++++++ tgapi/api.go | 12 ++++++++++- tgapi/errors.go | 20 ++++++++++++++++++- 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2f98e..4098853 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 `tgapi.ResponseError` so Telegram API error codes, descriptions, and response parameters remain inspectable through returned errors. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. @@ -25,6 +26,7 @@ ### Fixed - Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized. - Fixed webhook startup so a logger configured through `SetWebhookLogger(...)` is preserved. +- Fixed long-polling 429 handling so `getUpdates` retries use Telegram `retry_after` directly and do not inflate later transient-error backoff. ### Tests - Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. @@ -33,6 +35,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 long-polling `retry_after` handling on Telegram 429 responses. ## v1.0.0-rc.16 diff --git a/bot.go b/bot.go index 782403b..f5dcbde 100644 --- a/bot.go +++ b/bot.go @@ -437,7 +437,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { } close(bot.updateQueue) }() - retryDelay := time.Duration(0) + backoffDelay := time.Duration(0) retryCount := 0 for { select { @@ -449,8 +449,15 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } - bot.logger.Errorln("failed to fetch updates:", err) - retryDelay = nextPollRetryDelay(retryDelay) + retryDelay, ok := pollRetryAfterDelay(err) + if ok { + bot.logger.Warnln("getUpdates rate limited; retrying after", retryDelay) + backoffDelay = 0 + } else { + bot.logger.Errorln("failed to fetch updates:", err) + backoffDelay = nextPollRetryDelay(backoffDelay) + retryDelay = backoffDelay + } retryCount++ bot.safeEmitEvent(ctx, PollingRetryEvent{ Attempt: retryCount, @@ -475,7 +482,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { } continue } - retryDelay = 0 + backoffDelay = 0 retryCount = 0 for _, update := range updates { diff --git a/bot_test.go b/bot_test.go index 9ca08a9..7456fe4 100644 --- a/bot_test.go +++ b/bot_test.go @@ -560,6 +560,59 @@ func TestRunWithContextPreservesPollingRetryBackoff(t *testing.T) { } } +func TestRunWithContextUsesTelegramRetryAfterForPollingRateLimit(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observer := &pollingRetryObserver{cancel: cancel} + + client := &http.Client{ + Transport: pollingRoundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 5","parameters":{"retry_after":5}}`)), + }, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("http://example.invalid"). + SetHTTPClient(client), + ) + defer func() { + _ = api.Close() + }() + + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + api: api, + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{{name: "demo"}}, + updateQueue: make(chan *tgapi.Update, 1), + maxWorkers: 1, + observer: observer, + } + + if err := bot.RunWithContext(ctx); err != nil { + t.Fatalf("RunWithContext returned error: %v", err) + } + + if len(observer.retries) != 1 { + t.Fatalf("expected one polling retry event, got %d", len(observer.retries)) + } + if got := observer.retries[0]; got.Attempt != 1 || got.Delay != 5*time.Second { + t.Fatalf("unexpected polling retry event: %#v", got) + } + var responseErr *tgapi.ResponseError + if !errors.As(observer.retries[0].Err, &responseErr) { + t.Fatalf("expected ResponseError, got %T", observer.retries[0].Err) + } + if responseErr.Code != 429 || responseErr.Parameters == nil || responseErr.Parameters.RetryAfter == nil || *responseErr.Parameters.RetryAfter != 5 { + t.Fatalf("unexpected response error: %#v", responseErr) + } +} + func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { type testDB struct{ Name string } diff --git a/bot_utils.go b/bot_utils.go index 521bacc..02c51ba 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -2,6 +2,7 @@ package laniakea import ( "context" + "errors" "fmt" "maps" "reflect" @@ -135,6 +136,18 @@ func nextPollRetryDelay(prev time.Duration) time.Duration { return next } +func pollRetryAfterDelay(err error) (time.Duration, bool) { + var responseErr *tgapi.ResponseError + if !errors.As(err, &responseErr) || responseErr.Code != 429 || responseErr.Parameters == nil || responseErr.Parameters.RetryAfter == nil { + return 0, false + } + after := *responseErr.Parameters.RetryAfter + if after <= 0 { + return 0, false + } + return time.Duration(after) * time.Second, true +} + func isNilValue[T any](v T) bool { rv := reflect.ValueOf(v) if !rv.IsValid() { diff --git a/tgapi/api.go b/tgapi/api.go index c6aea8a..73db648 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -255,6 +255,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro } if !response.Ok { + responseErr := &ResponseError{ + Code: response.ErrorCode, + Description: response.Description, + Parameters: response.Parameters, + } + // Handle rate limiting (429) if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { after := *response.Parameters.RetryAfter @@ -269,6 +275,10 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro } } + if r.method == "getUpdates" { + return zero, responseErr + } + // Wait and retry select { case <-ctx.Done(): @@ -279,7 +289,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro } // Other API errors - return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) + return zero, responseErr } return response.Result, nil diff --git a/tgapi/errors.go b/tgapi/errors.go index bbf1f04..8eb7cda 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -1,6 +1,9 @@ package tgapi -import "errors" +import ( + "errors" + "fmt" +) // ErrPoolUnexpected reports an unexpected result type returned from the worker pool. var ErrPoolUnexpected = errors.New("unexpected response from pool") @@ -10,3 +13,18 @@ var ErrPoolQueueFull = errors.New("worker pool queue full") // ErrPoolStopped reports that a request was submitted after the worker pool stopped. var ErrPoolStopped = errors.New("worker pool stopped") + +// ResponseError reports an unsuccessful Telegram API response. +type ResponseError struct { + Code int + Description string + Parameters *ResponseParameters +} + +// Error returns the Telegram API error code and description. +func (e *ResponseError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("[%d] %s", e.Code, e.Description) +} -- 2.54.0 From 09fb9261df0ae618f6e8e8159e8c67a8095516cf Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 12 May 2026 16:23:30 +0300 Subject: [PATCH 93/99] (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) -- 2.54.0 From affb802a7bd8a00495883c75d617e14344bffe26 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Mon, 18 May 2026 17:27:14 +0300 Subject: [PATCH 94/99] (new): PollTimeout config, RateLimiter.Cleanup (fix): compact payload escape, Draft.push validation, plugin logger ownership, worker StopAndWait, getChatLimiter deadlock, runner ctx-after-tick (refactor): remove NewPayload, buildSceneKey from sceneRuntime, unify ToJSON fallback (tests): compact round-trip, Draft.push state, RateLimiter.Cleanup eviction (doc): changelog v1.0.0 rewrite, NewCommand dual-use godoc --- .gitignore | 2 + AGENTS.md | 2 +- CHANGELOG.md | 31 +++++++++-- bot.go | 11 +++- bot_config.go | 4 +- bot_opts.go | 23 +++++++- bot_opts_loader.go | 2 +- bot_register.go | 1 + bot_scene.go | 6 +- bot_test.go | 12 ++-- bot_utils.go | 3 +- bot_webhook.go | 49 +++++------------ bot_webhook_test.go | 2 +- cmd_generator_test.go | 4 +- commands.go | 22 +++++--- doc.go | 2 +- drafts.go | 20 +++++-- drafts_test.go | 20 ++++++- handler.go | 95 ++++++++++++++++++++++++++++---- handler_test.go | 52 +++++++++--------- keyboard.go | 30 ++++++---- keyboard_test.go | 54 ++++++++++++++++++ methods.go | 3 +- msg_context.go | 125 +++++++++++++++++++++--------------------- msg_context_test.go | 75 +++++++++++++++++-------- msg_handler.go | 10 ++-- plugins.go | 43 +++++++++++---- plugins_test.go | 20 +++---- policy.go | 33 +++++------ policy_test.go | 52 +++++++++--------- runners.go | 39 +++++++------ scene.go | 7 +-- scene_context.go | 4 +- scene_handler.go | 14 ++--- scene_test.go | 62 ++++++++++----------- update_context.go | 4 +- utils/limiter.go | 55 +++++++++++++++++-- utils/limiter_test.go | 49 +++++++++++++++++ 38 files changed, 697 insertions(+), 345 deletions(-) diff --git a/.gitignore b/.gitignore index 848b64a..89ab47d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ test/ .codex/ .codex +.agents/ +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 2d4ac66..e57a7cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Purpose -This repository uses Codex for full-project Go code review, not diff-only review. +This repository uses AI coding agents for full-project Go code review, not diff-only review. When asked to review code, inspect the entire repository and use repository-wide context. Do not limit analysis to the latest commit, pull request diff, or recently changed files. diff --git a/CHANGELOG.md b/CHANGELOG.md index cc94fc7..ede2df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,28 +3,47 @@ ## v1.0.0 ### Breaking Changes +- Renamed `MsgContext` to `MessageContext` across the public API, including handler signatures (`CommandExecutor`, `MiddlewareExecutor`, scene handler types), all reply/edit/scene helpers, embedded fields on `SceneContext`, and documentation. +- Removed the `NewPayload(...)` constructor. `NewCommand(...)` builds the underlying `Command[T]` for both `/-`commands and callback payloads; registration via `Plugin.AddPayload`/`Plugin.Payload` decides routing. +- `MessageContext.Error(...)` no longer sends unclassified errors to the user. Only errors marked with `AsUserError(...)` are surfaced through the centralized reply path; everything else stays internal-only and is logged. +- `Plugin.Close()` no longer closes a logger supplied through `Plugin.SetLogger(...)`. Only loggers created by the bot during `AddPlugins` registration are owned and closed; caller-supplied loggers remain the caller's responsibility. - Renamed final public APIs to idiomatic names before the stable release: `RunWebhookWithContext(...)`, `RunWebhook(...)`, `CloseWebhook()`, `BotWebhookOpts`, `NewBotWebhookOpts()`, `SetWebhookLogger(...)`, and `GetWebhookLogger()`. -- Renamed plugin builder helpers from `NewCommand(...)`, `NewPayload(...)`, and `NewScene(...)` to `Command(...)`, `Payload(...)`, and `Scene(...)`; `NewCommand(...)` and `NewPayload(...)` now take the command string before the executor. +- Renamed plugin builder helpers from `NewCommand(...)` and `NewScene(...)` to `Command(...)` and `Scene(...)`; the surviving `NewCommand(...)` takes the command string before the executor. - Renamed command argument value constants to `CommandValueString`, `CommandValueInt`, `CommandValueBool`, and `CommandValueAny`; `NewCommandArg(...)` now defaults to unvalidated `CommandValueAny`. - Renamed runner builders from `Onetime(...)` and `Timeout(...)` to `Once(...)` and `Every(...)`. - Renamed remaining public acronym/casing outliers including `AnswerCallback...`, `ParseMarkdownV2`, `ParseMarkdown`, `GetChatMemberCount`, `DropRateLimitOverflow`, `SetDropRateLimitOverflow`, and inline keyboard builder APIs. ### Added -- Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code. -- Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. +- Added `MessageContext.IsCallback()` and `MessageContext.HasPhoto()` helpers for callback-aware handler code. +- Added `MessageContext.UpsertKeyboard(...)` and `MessageContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow. - 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 `InlineKeyboardButtonBuilder.SetPayloadType(...)`, `InlineKeyboardButtonBuilder.SetCallbackData(...)`, and `MessageContext.NewInlineKeyboardButton(...)` helpers for payload-aware button building. - Added compact callback payload encoding through `BotPayloadCompact`, `BotPayloadCompactBase64`, compact inline keyboard builders, and matching `CallbackData` helpers. +- Added `BotOpts.PollTimeout`, `BotOpts.SetPollTimeout(...)`, and the `POLL_TIMEOUT` environment variable to configure the long-polling `getUpdates` timeout (default 30 seconds). +- Added `RateLimiter.Cleanup(idleThreshold)` to evict per-chat limiter state and expired chat cooldowns; the limiter now tracks per-chat last-seen time so long-running bots can bound memory through a periodic runner. +- Added cached bot identity (`Bot.userID`) populated at `NewBot` so chat-admin policies and similar lookups reuse it instead of issuing a fresh `GetMe` request. - Added `tgapi.ResponseError` so Telegram API error codes, descriptions, and response parameters remain inspectable through returned errors. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. +- Compact callback payload encoding now escapes `,`, `|`, and `\` in command and arg bytes so payloads containing those bytes round-trip without ambiguity. Note: the format coalesces "no args" with "single empty arg" — both encode as `cmd|` and decode to nil args. +- `CallbackData.ToJSON()`, `ToBase64()`, `ToCompact()`, and `ToCompactBase64()` now all return an empty string on serialization failure; the previous `ToJSON()` fallback `{"cmd":""}` has been removed so encoder bugs surface visibly instead of routing to no handler. - Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced. +- Plugin registration now warns when `AddCommand`, `AddPayload`, or `AddScene` overwrites an existing entry with the same name instead of silently replacing it. - `BotOpts`, `tgapi.APIOpts`, logger utilities, README, and wiki pages now document the final stable API names and configuration options consistently. - CI now checks formatting, tests, vet, and lint on both pushes and pull requests. ### Fixed +- Fixed the update worker pool returning before in-flight handlers completed. `startUpdateWorkers` now calls `pool.StopAndWait()` so the bot waits for already-submitted tasks before runtime exit. +- Fixed `RateLimiter.getChatLimiter` upgrading a held read lock to a write lock, which could deadlock under contention. The lookup now releases the read lock before acquiring the write lock and re-checks the map. +- Fixed `RateLimiter` per-chat limiter and lock maps growing unbounded for the lifetime of long-running bots that serve many distinct chats. +- Fixed `Draft.Push` mutating `Message` before validating the candidate length, leaving the draft in a half-mutated state when the candidate would exceed Telegram's limit. The candidate is now validated first; on failure the draft remains unchanged. +- Fixed background runners running one extra iteration after context cancellation when both `ctx.Done()` and the ticker were ready in the same `select`. +- Fixed `Plugin.Close()` double-closing a logger supplied by the caller through `SetLogger(...)`. +- Fixed compact callback payload corruption for arguments containing `,` or `|` bytes. +- Fixed `LoadOptsFromEnv` calling `os.Getenv("MAX_WORKERS")` twice when parsing the worker count. +- Fixed `sceneRuntime` interface carrying a delegating `buildSceneKey` method that just forwarded to a package-level helper; `MessageContext` scene helpers now call the helper directly. - Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized. - Fixed webhook startup so a logger configured through `SetWebhookLogger(...)` is preserved. - Fixed long-polling 429 handling so `getUpdates` retries use Telegram `retry_after` directly and do not inflate later transient-error backoff. @@ -38,6 +57,10 @@ - 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. +- Added regression coverage for compact callback payload round-tripping through `,`, `|`, and `\` separator bytes and a missing-separator decode error. +- Added regression coverage for `Draft.Push` preserving the existing message when validation rejects the candidate. +- Added regression coverage for `RateLimiter.Cleanup` evicting idle chat limiters and expired chat locks while leaving active state in place. +- Updated `MessageContext.Error` tests so unclassified errors stay internal-only and only `AsUserError` reaches the user. ## v1.0.0-rc.16 diff --git a/bot.go b/bot.go index 87d00d1..6ee6e16 100644 --- a/bot.go +++ b/bot.go @@ -36,7 +36,7 @@ type AppData any // data. // // Use Bot[NoData] to indicate no shared dependency injection is required. -type NoData struct{ AppData } +type NoData struct{} // AppDataLogger builds a sneklog.LoggerWriter from injected application data. // @@ -89,10 +89,12 @@ type Bot[T AppData] struct { token string debug bool errorTemplate string + userID int64 username string payloadType BotPayloadType strictPayloadType bool maxWorkers int + pollTimeout int // Long-polling timeout in seconds for getUpdates logFormat utils.LogFormat logFormatter *sneklog.Formatter @@ -184,12 +186,18 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { workers = opts.MaxWorkers } + pollTimeout := 30 + if opts.PollTimeout > 0 { + pollTimeout = opts.PollTimeout + } + bot := &Bot[T]{ updateOffset: 0, errorTemplate: "%s", payloadType: BotPayloadBase64, strictPayloadType: opts.StrictPayloadType, maxWorkers: workers, + pollTimeout: pollTimeout, updateQueue: updateQueue, api: api, uploader: uploader, @@ -239,6 +247,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { return nil, err } bot.username = Val(u.Username, "") + bot.userID = u.ID if bot.username == "" { bot.logger.Warn("Can't get bot username. Named command handlers won't work!") } diff --git a/bot_config.go b/bot_config.go index e7752ad..e5c329e 100644 --- a/bot_config.go +++ b/bot_config.go @@ -60,7 +60,7 @@ func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] { return bot } if store == nil { - bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore") + bot.logger.Warn("SetSessionStore called with nil store; nothing changed") return bot } bot.sessionStore = store @@ -216,7 +216,7 @@ func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] { return bot } if l == nil { - bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled") + bot.logger.Warn("SetL10n called with nil L10n; localization will not change") return bot } bot.l10n = l diff --git a/bot_opts.go b/bot_opts.go index 7e0c793..57676c8 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -65,6 +65,11 @@ type BotOpts struct { // MaxWorkers is the maximum number of update handlers that may run concurrently. MaxWorkers int + // PollTimeout is the long-polling timeout in seconds for getUpdates. + // Defaults to 30. Telegram allows 0..50; values outside that range are accepted + // by the bot but rejected by Telegram at runtime. + PollTimeout int + // FileConfigVersion stores the version declared by the config file used to // load these options. // @@ -94,6 +99,7 @@ type BotOpts struct { // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow // - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format // - MAX_WORKERS: maximum number of concurrent update handlers (default: 32) +// - POLL_TIMEOUT: long-polling timeout in seconds for getUpdates (default: 30) // - LOG_FORMAT: logger output format, "text" or "json" (default: "text") // // Returns a populated BotOpts. @@ -101,6 +107,7 @@ type BotOpts struct { func LoadOptsFromEnv() *BotOpts { rateLimit := 30 maxWorkers := 32 + pollTimeout := 30 stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES")) updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes)) @@ -115,11 +122,17 @@ func LoadOptsFromEnv() *BotOpts { } if mw := os.Getenv("MAX_WORKERS"); mw != "" { - if n, err := strconv.Atoi(os.Getenv("MAX_WORKERS")); err == nil { + if n, err := strconv.Atoi(mw); err == nil { maxWorkers = n } } + if pt := os.Getenv("POLL_TIMEOUT"); pt != "" { + if n, err := strconv.Atoi(pt); err == nil { + pollTimeout = n + } + } + return &BotOpts{ Token: os.Getenv("TG_TOKEN"), UpdateTypes: updateTypes, @@ -140,6 +153,7 @@ func LoadOptsFromEnv() *BotOpts { StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", MaxWorkers: maxWorkers, + PollTimeout: pollTimeout, FileConfigVersion: 0, LogFormat: utils.LogFormat(os.Getenv("LOG_FORMAT")), } @@ -256,6 +270,13 @@ func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts { return opts } +// SetPollTimeout sets the long-polling timeout in seconds for getUpdates. +// Defaults to 30. Telegram accepts 0..50. +func (opts *BotOpts) SetPollTimeout(seconds int) *BotOpts { + opts.PollTimeout = seconds + return opts +} + // SetLogFormat sets the output format used by bot-managed loggers. func (opts *BotOpts) SetLogFormat(format utils.LogFormat) *BotOpts { opts.LogFormat = format diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 4e86993..608a5a3 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -155,7 +155,7 @@ func SaveBotOptsFile(codec BotOptsFileCodec, filename string, opts *BotOpts) err if err != nil { return err } - err = os.WriteFile(filename, data, 0644) + err = os.WriteFile(filename, data, 0600) if err != nil { return err } diff --git a/bot_register.go b/bot_register.go index 2791d3f..6f22aa8 100644 --- a/bot_register.go +++ b/bot_register.go @@ -29,6 +29,7 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { cloned := clonePlugin(p) if cloned.logger == nil { cloned.logger = utils.CreateLogger(cloned.name, level, bot.logFormat, bot.logFormatter) + cloned.loggerOwned = true } bot.addTokenReplacer(cloned.logger) bot.plugins = append(bot.plugins, cloned) diff --git a/bot_scene.go b/bot_scene.go index 2fff340..db71fad 100644 --- a/bot_scene.go +++ b/bot_scene.go @@ -33,7 +33,7 @@ func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) { return nil, false } -func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, error) { +func (bot *Bot[T]) findSceneSession(ctx *MessageContext) (string, SceneSession, error) { var zero SceneSession for _, scope := range bot.sceneScopePriority { @@ -53,7 +53,3 @@ func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, erro return "", zero, ErrCantFindSession } - -func (bot *Bot[T]) buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { - return buildSceneKey(scope, ctx) -} diff --git a/bot_test.go b/bot_test.go index 7456fe4..ade1071 100644 --- a/bot_test.go +++ b/bot_test.go @@ -63,14 +63,14 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) { bot := &Bot[NoData]{logger: sneklog.NewLogger()} plugin := NewPlugin[NoData]("demo") - cmd := plugin.Command("start", func(ctx *MsgContext, db NoData) error { return nil }) - plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })) + cmd := plugin.Command("start", func(ctx *MessageContext, db NoData) error { return nil }) + plugin.AddMiddleware(NewMiddleware("base", func(ctx *MessageContext, db NoData) bool { return true })) bot.AddPlugins(plugin) cmd.SetDescription("mutated after registration") - plugin.Command("late", func(ctx *MsgContext, db NoData) error { return nil }) - plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true })) + plugin.Command("late", func(ctx *MessageContext, db NoData) error { return nil }) + plugin.AddMiddleware(NewMiddleware("late", func(ctx *MessageContext, db NoData) bool { return true })) registered := bot.plugins[0] if _, exists := registered.commands["late"]; exists { @@ -812,7 +812,7 @@ func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { bot := &Bot[NoData]{ logger: sneklog.NewLogger(), prefixes: []string{"/"}, - middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })}, + middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MessageContext, db NoData) bool { return true })}, runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })}, } plugin := NewPlugin[NoData]("late") @@ -823,7 +823,7 @@ func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { defer bot.finishRun() bot.AddPlugins(plugin) - bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true })) + bot.AddMiddleware(NewMiddleware("late", func(ctx *MessageContext, db NoData) bool { return true })) bot.AddRunner(NewRunner("late", func(bot *Bot[NoData]) error { return nil })) if len(bot.plugins) != 0 { diff --git a/bot_utils.go b/bot_utils.go index 02c51ba..793f55f 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -68,7 +68,7 @@ func (bot *Bot[T]) startUpdateWorkers(ctx context.Context) { bot.handle(ctx, u) }) } - pool.Stop() // Wait for all tasks to complete and stop the pool + pool.StopAndWait() // Wait for all tasks to complete and stop the pool } func (bot *Bot[T]) initLoggers(opts *BotOpts) { @@ -183,6 +183,7 @@ func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] { middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...), skipAutoCmd: p.skipAutoCmd, logger: p.logger, + loggerOwned: false, // user-supplied loggers stay caller-owned; bot may take ownership during registration messageFallback: p.messageFallback, handlers: make(map[tgapi.UpdateType]CommandExecutor[T]), onClose: p.onClose, diff --git a/bot_webhook.go b/bot_webhook.go index 6d26778..1106686 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -370,21 +370,14 @@ func (bot *Bot[T]) newWebhookMux(ctx context.Context, opts *BotWebhookOpts) *htt r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken)) return r } -func (bot *Bot[T]) runWebhook(ctx context.Context, opts *BotWebhookOpts) error { +func (bot *Bot[T]) baseRunWebhook(ctx context.Context, opts *BotWebhookOpts, runFunc func(*http.Server, chan error)) error { srv := &http.Server{ Addr: fmt.Sprintf(":%d", opts.LocalPort), Handler: bot.newWebhookMux(ctx, opts), } errCh := make(chan error, 1) - go func() { - err := srv.ListenAndServe() - if err != nil && !errors.Is(err, http.ErrServerClosed) { - errCh <- err - return - } - errCh <- nil - }() + go runFunc(srv, errCh) bot.webhookLogger.Infoln(fmt.Sprintf("Bot Webhook started at %s; waiting for updates at %s", srv.Addr, opts.URL)) @@ -403,38 +396,26 @@ func (bot *Bot[T]) runWebhook(ctx context.Context, opts *BotWebhookOpts) error { return err } } -func (bot *Bot[T]) runWebhookTLS(ctx context.Context, opts *BotWebhookOpts, key, cert string) error { - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", opts.LocalPort), - Handler: bot.newWebhookMux(ctx, opts), - } - errCh := make(chan error, 1) +func (bot *Bot[T]) runWebhook(ctx context.Context, opts *BotWebhookOpts) error { + return bot.baseRunWebhook(ctx, opts, func(srv *http.Server, errCh chan error) { + err := srv.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }) - go func() { +} +func (bot *Bot[T]) runWebhookTLS(ctx context.Context, opts *BotWebhookOpts, key, cert string) error { + return bot.baseRunWebhook(ctx, opts, func(srv *http.Server, errCh chan error) { err := srv.ListenAndServeTLS(cert, key) if err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err return } errCh <- nil - }() - - bot.webhookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL)) - - select { - case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := srv.Shutdown(shutdownCtx); err != nil { - return err - } - - return <-errCh - - case err := <-errCh: - return err - } + }) } func validateWebhookPath(path string, useStatusPath bool) error { if path == "" { diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 6a00f17..49d2019 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -135,7 +135,7 @@ func TestRunWebhookRuntimePreservesConfiguredWebhookLogger(t *testing.T) { func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { var calls atomic.Int32 plugin := NewPlugin[NoData]("demo") - plugin.Command("start", func(ctx *MsgContext, db NoData) error { + plugin.Command("start", func(ctx *MessageContext, db NoData) error { calls.Add(1) return nil }) diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 72a1221..8a4b6d6 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -44,7 +44,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { }() plugin := NewPlugin[NoData]("overflow") - exec := func(ctx *MsgContext, db NoData) error { return nil } + exec := func(ctx *MessageContext, db NoData) error { return nil } for i := 0; i < 101; i++ { plugin.Command("cmd"+strconv.Itoa(i), exec) } @@ -66,7 +66,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { plugin := NewPlugin[NoData]("sorted") - exec := func(ctx *MsgContext, db NoData) error { return nil } + exec := func(ctx *MessageContext, db NoData) error { return nil } plugin.Command("zeta", exec) plugin.Command("alpha", exec) diff --git a/commands.go b/commands.go index fd801b6..7e86ee2 100644 --- a/commands.go +++ b/commands.go @@ -85,7 +85,7 @@ func (c CommandArg) SetRequired() CommandArg { // CommandExecutor is the function type that executes a command. // It receives the message context and injected application data. // Returning a non-nil error routes it through the bot's error handler. -type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error +type CommandExecutor[T AppData] func(ctx *MessageContext, dbContext T) error // Command represents a bot command with arguments, description, and executor. // Can be registered in a Plugin and optionally skipped from auto-generation. @@ -98,18 +98,22 @@ type Command[T AppData] struct { skipAutoCmd bool // If true, this command won't be auto-added to help menus } -// NewCommand creates a new Command with the given command string, executor, and arguments. -// The command string should not include the leading slash (e.g., "start", not "/start"). +// NewCommand creates a new Command with the given identifier, executor, and arguments. +// +// The identifier is used as the routing key for both /-prefixed commands and +// callback payloads — the difference is registration: pass the result to +// Plugin.AddCommand/Plugin.Command for message routing, or to +// Plugin.AddPayload/Plugin.Payload for callback_data routing. +// +// For /-commands the identifier must not include the leading slash +// (e.g. "start", not "/start") and should match [_a-z0-9]{1,32} to satisfy +// Telegram's BotCommand validation. Payload identifiers may use any bytes +// that fit Telegram's callback_data limit, though the configured payload +// encoding may impose its own restrictions. func NewCommand[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} } -// NewPayload creates a new callback payload handler command. -// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. -func NewPayload[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} -} - // Use adds a middleware to the command's execution chain. // Middlewares are executed in the order they are added. func (c *Command[T]) Use(m Middleware[T]) *Command[T] { diff --git a/doc.go b/doc.go index f30fb46..ae3738b 100644 --- a/doc.go +++ b/doc.go @@ -5,7 +5,7 @@ Core concepts: - Bot manages Telegram API access, update processing, logging, rate limiting, and dependency injection. - Plugins group commands, payloads, and non-command update handlers behind shared middleware. - - MsgContext provides access to the current update and reply/edit/delete helpers. + - MessageContext provides access to the current update and reply/edit/delete helpers. - InlineKeyboard builds callback-driven keyboards and structured payloads. - DraftProvider accumulates multi-step replies before sending them. - L10n stores key-based translations with fallback behavior. diff --git a/drafts.go b/drafts.go index c8968c4..7c43826 100644 --- a/drafts.go +++ b/drafts.go @@ -14,8 +14,11 @@ type draftIDGenerator interface { Next() uint64 } -// RandomDraftIDGenerator generates draft IDs using cryptographically secure random numbers. -// Suitable for distributed systems or when ID predictability is undesirable. +// RandomDraftIDGenerator generates draft IDs using math/rand/v2. +// +// Suitable for general use thanks to the wide 64-bit value space. Not suitable +// for security-sensitive purposes — use crypto/rand if unpredictability against +// an adversary matters. type RandomDraftIDGenerator struct{} // Next returns a random 64-bit unsigned integer. @@ -29,7 +32,7 @@ type LinearDraftIDGenerator struct { lastID atomic.Uint64 } -// Next returns the next linear ID, atomically incremented.о +// Next returns the next linear ID, atomically incremented. func (g *LinearDraftIDGenerator) Next() uint64 { return g.lastID.Add(1) } @@ -239,14 +242,21 @@ func (d *Draft) Flush() error { } // Internal helper for Push that updates the server-side draft. +// +// The candidate Message (current content + new text) is validated before any +// mutation, so a validation failure leaves the draft unchanged. After the +// validation passes, Message is committed locally regardless of whether the +// API call succeeds (per the Push docs: local state reflects the user's +// intent, network failures can be retried). func (d *Draft) push(text string) error { if d.chatID == 0 { return ErrDraftChatIDZero } - d.Message += text - if err := validateMessageText(d.Message); err != nil { + candidate := d.Message + text + if err := validateMessageText(candidate); err != nil { return err } + d.Message = candidate params := tgapi.SendMessageDraft{ ChatID: d.chatID, DraftID: d.ID, diff --git a/drafts_test.go b/drafts_test.go index 1ad9de0..6c4d59a 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -19,7 +19,7 @@ func TestDraftFlushRequiresChatID(t *testing.T) { } func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { - ctx := &MsgContext{ + ctx := &MessageContext{ API: &tgapi.API{}, Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, @@ -31,6 +31,7 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { draft := ctx.NewDraft() if draft == nil { t.Fatal("expected draft") + return } if draft.chatID != 42 { t.Fatalf("unexpected chat id: %d", draft.chatID) @@ -53,3 +54,20 @@ func TestDraftPushRejectsLongMessage(t *testing.T) { t.Fatalf("expected ErrMessageTooLong, got %v", err) } } + +// TestDraftPushLeavesMessageUnchangedOnValidationFailure covers the validation +// order fix: when the candidate Message (current + new text) overflows the +// Telegram limit, the existing Message must remain intact so callers can +// recover and retry with a shorter payload instead of finding the draft in +// a half-mutated state. +func TestDraftPushLeavesMessageUnchangedOnValidationFailure(t *testing.T) { + draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0) + draft.Message = "hello" + + if err := draft.Push(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) { + t.Fatalf("expected ErrMessageTooLong, got %v", err) + } + if draft.Message != "hello" { + t.Fatalf("expected draft Message to stay %q, got %q", "hello", draft.Message) + } +} diff --git a/handler.go b/handler.go index b1116ff..7d37a77 100644 --- a/handler.go +++ b/handler.go @@ -26,7 +26,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { ctx, cancel := context.WithCancel(parentCtx) defer cancel() - msgCtx := &MsgContext{ + msgCtx := &MessageContext{ Update: *u, API: bot.api, Logger: bot.logger, errorTemplate: bot.errorTemplate, @@ -35,6 +35,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { sceneRuntime: bot, observer: bot.observer, payloadType: bot.payloadType, + botID: bot.userID, ctx: ctx, } bot.prepareUpdateCtx(u, msgCtx) @@ -114,7 +115,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { }) } -func cloneMsgContext(src *MsgContext) *MsgContext { +func cloneMsgContext(src *MessageContext) *MessageContext { cloned := *src if src.Args != nil { cloned.Args = append([]string(nil), src.Args...) @@ -154,20 +155,92 @@ func decodeBase64Payload(s string) (CallbackData, error) { return decodeJSONPayload(string(b)) } -func encodeCompactPayload(d CallbackData) (string, error) { - args := strings.Join(d.Args, ",") - return d.Command + "|" + args, nil +// Compact payload format: cmd|arg1,arg2,... +// Bytes \, |, and , inside a part are escaped with a leading backslash so the +// payload round-trips without ambiguity. Encoding/decoding operate byte-wise +// because all separators are single-byte ASCII; multi-byte UTF-8 code points +// pass through unchanged. + +func encodeCompactPart(s string) string { + if !strings.ContainsAny(s, `\|,`) { + return s + } + var b strings.Builder + b.Grow(len(s) + 2) + for i := 0; i < len(s); i++ { + switch s[i] { + case '\\', '|', ',': + b.WriteByte('\\') + } + b.WriteByte(s[i]) + } + return b.String() } + +func decodeCompactPart(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + b.WriteByte(s[i+1]) + i++ + continue + } + b.WriteByte(s[i]) + } + return b.String() +} + +func encodeCompactPayload(d CallbackData) (string, error) { + var b strings.Builder + b.WriteString(encodeCompactPart(d.Command)) + b.WriteByte('|') + for i, a := range d.Args { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(encodeCompactPart(a)) + } + return b.String(), nil +} + func decodeCompactPayload(s string) (CallbackData, error) { - values := strings.SplitN(s, "|", 2) - if len(values) != 2 { + sepIdx := -1 + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + i++ + continue + } + if s[i] == '|' { + sepIdx = i + break + } + } + if sepIdx == -1 { return CallbackData{}, errors.New("invalid payload") } - cmd, argsRaw := values[0], values[1] - var args []string - if argsRaw != "" { - args = strings.Split(argsRaw, ",") + cmd := decodeCompactPart(s[:sepIdx]) + argsRaw := s[sepIdx+1:] + if argsRaw == "" { + return CallbackData{Command: cmd}, nil } + + var args []string + start := 0 + for i := 0; i < len(argsRaw); i++ { + if argsRaw[i] == '\\' && i+1 < len(argsRaw) { + i++ + continue + } + if argsRaw[i] == ',' { + args = append(args, decodeCompactPart(argsRaw[start:i])) + start = i + 1 + } + } + args = append(args, decodeCompactPart(argsRaw[start:])) return CallbackData{Command: cmd, Args: args}, nil } func encodeCompactBase64Payload(d CallbackData) (string, error) { diff --git a/handler_test.go b/handler_test.go index 6441097..ae4b963 100644 --- a/handler_test.go +++ b/handler_test.go @@ -64,7 +64,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) { bot := &Bot[NoData]{ logger: logger, middlewares: []Middleware[NoData]{ - NewMiddleware("logger-check", func(ctx *MsgContext, db NoData) bool { + NewMiddleware("logger-check", func(ctx *MessageContext, db NoData) bool { called = true if ctx.Logger != logger { t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger) @@ -90,7 +90,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) { func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) { plugin := NewPlugin[NoData]("test") - handler := func(ctx *MsgContext, db NoData) error { return nil } + handler := func(ctx *MessageContext, db NoData) error { return nil } for _, updateType := range []tgapi.UpdateType{ tgapi.UpdateTypeMessage, @@ -376,7 +376,7 @@ func TestPrepareUpdateCtxContract(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { bot := &Bot[NoData]{} - ctx := &MsgContext{} + ctx := &MessageContext{} bot.prepareUpdateCtx(tt.update, ctx) if got := ctx.Msg != nil; got != tt.wantMsg { @@ -450,7 +450,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { called := false - plugin := NewPlugin[NoData]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoData) error { + plugin := NewPlugin[NoData]("test").AddUpdateHandler(tt.update.Type, func(ctx *MessageContext, db NoData) error { called = true if ctx.Update.UpdateID != tt.update.UpdateID { t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID) @@ -488,7 +488,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { firstCalled := false secondCalled := false - first := NewPlugin[NoData]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error { + first := NewPlugin[NoData]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MessageContext, db NoData) error { firstCalled = true if ctx.FromID != 41 { t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID) @@ -499,7 +499,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { ctx.Args = []string{"mutated"} return nil }) - second := NewPlugin[NoData]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error { + second := NewPlugin[NoData]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MessageContext, db NoData) error { secondCalled = true if ctx.From == nil { t.Fatal("expected ctx.From to remain populated for second handler") @@ -541,7 +541,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { observer := &recordingObserver{} - plugin := NewPlugin[NoData]("test").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoData) error { + plugin := NewPlugin[NoData]("test").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MessageContext, db NoData) error { return AsUserError(errors.New("update failed")) }) @@ -596,7 +596,7 @@ func TestHandleObserverCompletesUpdateWhenBotMiddlewareBlocks(t *testing.T) { logger: sneklog.NewLogger(), observer: observer, middlewares: []Middleware[NoData]{ - NewMiddleware("block", func(ctx *MsgContext, db NoData) bool { + NewMiddleware("block", func(ctx *MessageContext, db NoData) bool { return false }), }, @@ -632,7 +632,7 @@ func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { observer := &recordingObserver{} called := false plugin := NewPlugin[NoData]("test") - plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + plugin.SetMessageFallback(func(ctx *MessageContext, db NoData) error { called = true if ctx.Text != "/missing hello world" { t.Fatalf("unexpected fallback text: got %q", ctx.Text) @@ -687,7 +687,7 @@ func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { func TestHandleMessageFallbackRunsForPlainText(t *testing.T) { called := false - plugin := NewPlugin[NoData]("test").SetMessageFallback(func(ctx *MsgContext, db NoData) error { + plugin := NewPlugin[NoData]("test").SetMessageFallback(func(ctx *MessageContext, db NoData) error { called = true if ctx.Text != "hello fallback" { t.Fatalf("unexpected fallback text: got %q", ctx.Text) @@ -723,10 +723,10 @@ func TestHandleMessageFallbackRunsForPlainText(t *testing.T) { func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.AddMiddleware(NewMiddleware("block", func(ctx *MsgContext, db NoData) bool { + plugin.AddMiddleware(NewMiddleware("block", func(ctx *MessageContext, db NoData) bool { return false })) - plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + plugin.SetMessageFallback(func(ctx *MessageContext, db NoData) error { called = true return nil }) @@ -757,11 +757,11 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { commandCalled := false fallbackCalled := false plugin := NewPlugin[NoData]("test") - plugin.Command("start", func(ctx *MsgContext, db NoData) error { + plugin.Command("start", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) - plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error { + plugin.SetMessageFallback(func(ctx *MessageContext, db NoData) error { fallbackCalled = true return nil }) @@ -794,7 +794,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { called = true if ctx.Msg == nil { t.Fatal("expected message context") @@ -841,7 +841,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { var got banInput plugin := NewPlugin[NoData]("test") - plugin.Command("ban", func(ctx *MsgContext, db NoData) error { + plugin.Command("ban", func(ctx *MessageContext, db NoData) error { return ctx.BindArgs(&got) }, NewCommandArg("user_id").SetValueType(CommandValueInt).SetRequired(), @@ -878,7 +878,7 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { var got payloadInput plugin := NewPlugin[NoData]("test") - plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MessageContext, db NoData) error { return ctx.BindArgs(&got) }, NewCommandArg("id").SetValueType(CommandValueInt).SetRequired(), @@ -920,11 +920,11 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { updateCalled := false plugin := NewPlugin[NoData]("test") - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) - plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error { + plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MessageContext, db NoData) error { updateCalled = true if ctx.Msg == nil { t.Fatal("expected ctx.Msg in edited message handler") @@ -968,11 +968,11 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { updateCalled := false plugin := NewPlugin[NoData]("test") - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) - plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error { + plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MessageContext, db NoData) error { updateCalled = true if ctx.Msg == nil { t.Fatal("expected ctx.Msg in edited channel post handler") @@ -1007,7 +1007,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MessageContext, db NoData) error { called = true if ctx.CallbackQueryID != "cb-msg" { t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) @@ -1066,7 +1066,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { called := false plugin := NewPlugin[NoData]("test") - plugin.Payload("inline.approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("inline.approve", func(ctx *MessageContext, db NoData) error { called = true if ctx.CallbackQueryID != "cb-inline" { t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) @@ -1122,7 +1122,7 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("test") - plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MessageContext, db NoData) error { return nil }) @@ -1173,7 +1173,7 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { observer := &recordingObserver{} plugin := NewPlugin[NoData]("test") wantErr := AsInternalError(errors.New("boom")) - plugin.Payload("approve", func(ctx *MsgContext, db NoData) error { + plugin.Payload("approve", func(ctx *MessageContext, db NoData) error { return wantErr }) @@ -1236,7 +1236,7 @@ func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { Data: "{not-json", From: tgapi.User{ID: 7}, }, - }, &MsgContext{ + }, &MessageContext{ Update: tgapi.Update{ UpdateID: 34, Type: tgapi.UpdateTypeCallbackQuery, diff --git a/keyboard.go b/keyboard.go index 0027adb..52a03a7 100644 --- a/keyboard.go +++ b/keyboard.go @@ -203,6 +203,8 @@ func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard { return in } +func (in *InlineKeyboard) GetMaxRow() int { return in.maxRow } + // Internal helper that appends a button and auto-flushes a full row. func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { if in.CurrentLine.Len() == in.maxRow { @@ -302,18 +304,18 @@ func NewCallbackData(command string, args ...any) CallbackData { } } +// All To* encoders return an empty string when serialization fails. Telegram +// rejects empty callback_data, so an empty result surfaces a real bug rather +// than masking it with a stub payload that silently routes to no handler. +// Build CallbackData from primitives (string, []string) only — the encoders +// have no failure modes for that input. + // ToJSON serializes the CallbackData to a JSON string. -// -// If serialization fails (e.g., due to unmarshalable fields), returns a fallback -// JSON object: {"cmd":""} to prevent breaking Telegram's API. -// -// This fallback ensures the bot receives a valid JSON payload even if internal -// errors occur — avoiding "invalid callback_data" errors from Telegram. +// Returns an empty string if serialization fails. func (d CallbackData) ToJSON() string { data, err := encodeJSONPayload(d) if err != nil { - // Fallback: return minimal valid JSON to avoid Telegram API rejection - return `{"cmd":""}` + return "" } return data } @@ -323,25 +325,31 @@ func (d CallbackData) ToJSON() string { func (d CallbackData) ToBase64() string { data, err := encodeBase64Payload(d) if err != nil { - return `` + return "" } return data } // ToCompact serializes the CallbackData to a compact delimited string. +// Returns an empty string if serialization fails. +// +// The compact format coalesces "no args" with "single empty arg" — both +// produce "cmd|" and decode back to nil args. Use ToJSON or ToBase64 when +// that distinction must be preserved. func (d CallbackData) ToCompact() string { data, err := encodeCompactPayload(d) if err != nil { - return `` + return "" } return data } // ToCompactBase64 serializes the CallbackData to compact text and then encodes it as Base64. +// Returns an empty string if serialization or encoding fails. func (d CallbackData) ToCompactBase64() string { data, err := encodeCompactBase64Payload(d) if err != nil { - return `` + return "" } return data } diff --git a/keyboard_test.go b/keyboard_test.go index 267642f..ecb1034 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -150,6 +150,60 @@ func TestDecodePayloadAcceptsCompactBase64KeyboardPayloadWhenBotPrefersJSON(t *t } } +// TestCompactPayloadRoundTripsWithSeparatorChars guards the compact-encoding +// escape fix. Args containing the , | or \ separator bytes previously corrupted +// on decode; now they must round-trip exactly. +// +// Note: the compact format coalesces "no args" with "single empty arg" — both +// emit "cmd|" and decode to nil args. Use other encodings if that distinction +// matters. +func TestCompactPayloadRoundTripsWithSeparatorChars(t *testing.T) { + tests := []struct { + name string + data CallbackData + }{ + {name: "plain", data: CallbackData{Command: "cmd", Args: []string{"one", "two"}}}, + {name: "no args", data: CallbackData{Command: "cmd"}}, + {name: "comma in arg", data: CallbackData{Command: "cmd", Args: []string{"a,b", "c"}}}, + {name: "pipe in arg", data: CallbackData{Command: "cmd", Args: []string{"a|b", "c"}}}, + {name: "backslash in arg", data: CallbackData{Command: "cmd", Args: []string{`a\b`, "c"}}}, + {name: "all specials in arg", data: CallbackData{Command: "cmd", Args: []string{`a,b|c\d`}}}, + {name: "specials in command", data: CallbackData{Command: "a|b,c", Args: []string{"x"}}}, + {name: "two empty args", data: CallbackData{Command: "cmd", Args: []string{"", ""}}}, + {name: "utf8 args", data: CallbackData{Command: "cmd", Args: []string{"привет", "мир"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encoded, err := encodeCompactPayload(tt.data) + if err != nil { + t.Fatalf("encodeCompactPayload returned error: %v", err) + } + got, err := decodeCompactPayload(encoded) + if err != nil { + t.Fatalf("decodeCompactPayload returned error: %v", err) + } + if got.Command != tt.data.Command { + t.Fatalf("command mismatch: got %q want %q (encoded=%q)", got.Command, tt.data.Command, encoded) + } + if len(got.Args) != len(tt.data.Args) { + t.Fatalf("args length mismatch: got %v want %v (encoded=%q)", got.Args, tt.data.Args, encoded) + } + for i := range tt.data.Args { + if got.Args[i] != tt.data.Args[i] { + t.Fatalf("arg %d mismatch: got %q want %q (encoded=%q)", i, got.Args[i], tt.data.Args[i], encoded) + } + } + }) + } +} + +func TestCompactPayloadDecodeRejectsMissingSeparator(t *testing.T) { + if _, err := decodeCompactPayload("noseparator"); err == nil { + t.Fatal("expected error decoding payload without separator") + } +} + func TestDecodePayloadStrictRejectsCompactMismatchedType(t *testing.T) { kb := NewInlineKeyboardCompact(1). AddCallbackButton("A", "cmd", 1) diff --git a/methods.go b/methods.go index 50e24e6..e970d8b 100644 --- a/methods.go +++ b/methods.go @@ -43,9 +43,10 @@ import ( // } func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { offset := bot.GetUpdateOffset() + timeout := bot.pollTimeout params := tgapi.UpdateParams{ Offset: new(offset), - Timeout: new(30), + Timeout: new(timeout), AllowedUpdates: bot.GetUpdateTypes(), } diff --git a/msg_context.go b/msg_context.go index f5841f9..6a25968 100644 --- a/msg_context.go +++ b/msg_context.go @@ -14,10 +14,10 @@ import ( "git.scuroneko.dev/scuroneko/sneklog/v2" ) -// MsgContext holds the normalized per-update context passed to command, payload, +// MessageContext 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. +// MessageContext 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. @@ -27,10 +27,10 @@ import ( // - 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, +// Helper methods on MessageContext 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 { +type MessageContext struct { API *tgapi.API Update tgapi.Update @@ -81,21 +81,22 @@ type MsgContext struct { payloadType BotPayloadType sceneRuntime sceneRuntime observer Observer + botID int64 ctx context.Context } -// AnswerMessage represents a message sent or edited via MsgContext. +// AnswerMessage represents a message sent or edited via MessageContext. // It holds metadata to allow further editing or deletion. type AnswerMessage struct { MessageID int Text string IsMedia bool - ctx *MsgContext // internal back-reference + ctx *MessageContext // 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 { +func (ctx *MessageContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateMessageText(text); err != nil { ctx.Logger.Errorln(err) return nil @@ -146,7 +147,7 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { } // Internal helper for editing callback-linked messages. -func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" { ctx.Logger.Errorln(ErrCallbackMessageMissing) return nil @@ -155,31 +156,31 @@ func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parse } // EditCallback edits the callback message using plain text (ParseNone). -func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) 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 tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.editCallback(text, keyboard, tgapi.ParseMarkdownV2) } // 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 { +func (ctx *MessageContext) 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 tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { +func (ctx *MessageContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2) } // Internal helper for media-caption edits. -func (ctx *MsgContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateCaptionText(text); err != nil { ctx.Logger.Errorln(err) return nil @@ -241,7 +242,7 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo } // Internal helper for message replies with optional keyboard and parse mode. -func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) return nil @@ -276,7 +277,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t } // Answer sends a plain text message (ParseNone). -func (ctx *MsgContext) Answer(text string) *AnswerMessage { +func (ctx *MessageContext) Answer(text string) *AnswerMessage { return ctx.answer(text, nil, tgapi.ParseNone) } @@ -284,54 +285,54 @@ func (ctx *MsgContext) Answer(text string) *AnswerMessage { // // 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 { +func (ctx *MessageContext) 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 tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage { +func (ctx *MessageContext) AnswerMarkdown(text string) *AnswerMessage { return ctx.answer(text, nil, tgapi.ParseMarkdownV2) } // Answerf formats a string using fmt.Sprintf and sends it as a plain text message. -func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage { +func (ctx *MessageContext) 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 { +func (ctx *MessageContext) 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 tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { +func (ctx *MessageContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } // Keyboard sends a message with an inline keyboard (plain text). -func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) 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 { +func (ctx *MessageContext) 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 tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.answer(text, keyboard, tgapi.ParseMarkdownV2) } -func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage { +func (ctx *MessageContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage { if parseMode != tgapi.ParseNone { ctx.Logger.Errorln(ErrMessageSplitImpossible) return nil @@ -371,7 +372,7 @@ func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMo } // Internal helper for photo replies with optional caption and keyboard. -func (ctx *MsgContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) return nil @@ -407,43 +408,43 @@ func (ctx *MsgContext) answerPhoto(photoID, text string, kb *InlineKeyboard, par } // AnswerPhoto sends a photo with plain text caption. -func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage { +func (ctx *MessageContext) 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 tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage { +func (ctx *MessageContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage { return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMarkdownV2) } // AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text). -func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) 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 tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage { return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMarkdownV2) } // AnswerPhotof formats a string and sends it as a photo caption (plain text). -func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *AnswerMessage { +func (ctx *MessageContext) 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 tgfmt.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage { +func (ctx *MessageContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage { return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } // Internal helper that deletes a message by ID. -func (ctx *MsgContext) delete(messageID int) { +func (ctx *MessageContext) delete(messageID int) { if messageID == 0 { ctx.Logger.Errorln(ErrMessageIDZero) return @@ -465,7 +466,7 @@ func (ctx *MsgContext) delete(messageID int) { func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) } // CallbackDelete deletes the message that triggered the callback query. -func (ctx *MsgContext) CallbackDelete() { +func (ctx *MessageContext) CallbackDelete() { if ctx.CallbackMsgID == 0 { ctx.Logger.Errorln(ErrCallbackMessageMissing) return @@ -474,7 +475,7 @@ func (ctx *MsgContext) CallbackDelete() { } // Internal helper that answers a callback query with optional text, alert, or URL. -func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { +func (ctx *MessageContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryID) == 0 { return } @@ -488,19 +489,19 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { } // AnswerCallback answers the callback query with no text or alert. -func (ctx *MsgContext) AnswerCallback() { ctx.answerCallbackQuery("", "", false) } +func (ctx *MessageContext) AnswerCallback() { ctx.answerCallbackQuery("", "", false) } // AnswerCallbackText answers the callback query with a text notification. -func (ctx *MsgContext) AnswerCallbackText(text string) { ctx.answerCallbackQuery("", text, false) } +func (ctx *MessageContext) AnswerCallbackText(text string) { ctx.answerCallbackQuery("", text, false) } // AnswerCallbackAlert answers the callback query with a user-visible alert. -func (ctx *MsgContext) AnswerCallbackAlert(text string) { ctx.answerCallbackQuery("", text, true) } +func (ctx *MessageContext) AnswerCallbackAlert(text string) { ctx.answerCallbackQuery("", text, true) } // AnswerCallbackURL answers the callback query with a URL redirect. -func (ctx *MsgContext) AnswerCallbackURL(u string) { ctx.answerCallbackQuery(u, "", false) } +func (ctx *MessageContext) AnswerCallbackURL(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) { +func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) { if ctx.Msg == nil { ctx.Logger.Errorln("Can't send action without chat message context") return @@ -518,12 +519,12 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { } // Internal helper that formats, sends, and logs an error. -func (ctx *MsgContext) error(err error) { +func (ctx *MessageContext) error(err error) { if err == nil { return } ctx.Logger.Errorln(err) - if IsInternalError(err) { + if !IsUserError(err) { return } text := fmt.Sprintf(ctx.errorTemplate, err.Error()) @@ -536,9 +537,9 @@ func (ctx *MsgContext) error(err error) { } // Error is an alias for error(). -func (ctx *MsgContext) Error(err error) { ctx.error(err) } +func (ctx *MessageContext) Error(err error) { ctx.error(err) } -func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { +func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) return nil @@ -567,20 +568,20 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *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 { +func (ctx *MessageContext) 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 { +func (ctx *MessageContext) NewDraftMarkdown() *Draft { return ctx.newDraft(tgapi.ParseMarkdownV2) } // 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 { +func (ctx *MessageContext) Translate(key string) string { if ctx.From == nil { return key } @@ -590,12 +591,12 @@ func (ctx *MsgContext) Translate(key string) string { // 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 { +func (ctx *MessageContext) NewInlineKeyboard(maxRow int) *InlineKeyboard { return NewInlineKeyboard(ctx.payloadType, maxRow) } // NewInlineKeyboardButton creates a button builder using the context payload encoding. -func (ctx *MsgContext) NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder { +func (ctx *MessageContext) NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder { return NewInlineKeyboardButton(text).SetPayloadType(ctx.payloadType) } @@ -684,19 +685,19 @@ func bindPositional(args []string, dst any) error { // 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 { +func (ctx *MessageContext) 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 { +func (ctx *MessageContext) Context() context.Context { if ctx.ctx == nil { return context.Background() } return ctx.ctx } -func (ctx *MsgContext) emitPolicyChecked(event PolicyCheckedEvent) { +func (ctx *MessageContext) emitPolicyChecked(event PolicyCheckedEvent) { if ctx == nil || ctx.observer == nil { return } @@ -713,7 +714,7 @@ func (ctx *MsgContext) emitPolicyChecked(event PolicyCheckedEvent) { } // EnterScene enters the named scene at its configured entry step. -func (ctx *MsgContext) EnterScene(name string) error { +func (ctx *MessageContext) EnterScene(name string) error { if ctx.sceneRuntime == nil { return ErrSceneRuntimeNil } @@ -723,7 +724,7 @@ func (ctx *MsgContext) EnterScene(name string) error { return ErrSceneNotFound } - key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) + key, ok := buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } @@ -743,7 +744,7 @@ func (ctx *MsgContext) EnterScene(name string) error { } // EnterSceneStep enters the named scene at a specific step. -func (ctx *MsgContext) EnterSceneStep(name, step string) error { +func (ctx *MessageContext) EnterSceneStep(name, step string) error { if ctx.sceneRuntime == nil { return ErrSceneRuntimeNil } @@ -756,7 +757,7 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { return ErrSceneStepNotFound } - key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) + key, ok := buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } @@ -767,7 +768,7 @@ func (ctx *MsgContext) EnterSceneStep(name, step string) error { } // ExitScene leaves the currently active scene for this context. -func (ctx *MsgContext) ExitScene() error { +func (ctx *MessageContext) ExitScene() error { if ctx.sceneRuntime == nil { return ErrSceneRuntimeNil } @@ -785,7 +786,7 @@ func (ctx *MsgContext) ExitScene() error { return ErrSceneNotFound } - key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx) + key, ok := buildSceneKey(scene.Scope, ctx) if !ok { return ErrCantFindSession } @@ -794,16 +795,16 @@ func (ctx *MsgContext) ExitScene() error { } // IsCallback reports whether the context belongs to a callback query. -func (ctx *MsgContext) IsCallback() bool { +func (ctx *MessageContext) IsCallback() bool { return ctx.CallbackQueryID != "" || ctx.CallbackMsgID > 0 || ctx.InlineMsgID != "" } // HasPhoto reports whether the current message contains a photo payload. -func (ctx *MsgContext) HasPhoto() bool { +func (ctx *MessageContext) HasPhoto() bool { return ctx.Msg != nil && ctx.Msg.Photo.Len() > 0 } -func (ctx *MsgContext) upsertKeyboard(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MessageContext) upsertKeyboard(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.IsCallback() { if ctx.HasPhoto() { ctx.CallbackDelete() @@ -815,11 +816,11 @@ func (ctx *MsgContext) upsertKeyboard(text string, keyboard *InlineKeyboard, par } // UpsertKeyboard edits a callback message or sends a new plain-text message with a keyboard. -func (ctx *MsgContext) UpsertKeyboard(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) UpsertKeyboard(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.upsertKeyboard(text, keyboard, tgapi.ParseNone) } // UpsertKeyboardMarkdown edits a callback message or sends a new MarkdownV2 message with a keyboard. -func (ctx *MsgContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { +func (ctx *MessageContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2) } diff --git a/msg_context_test.go b/msg_context_test.go index 3b9f2a6..6da8a93 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -44,7 +44,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, @@ -56,6 +56,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { answer := ctx.AnswerPhoto("photo-id", "caption") if answer == nil { t.Fatal("expected answer message") + return } if answer.MessageID != 9 { t.Fatalf("unexpected message id: %d", answer.MessageID) @@ -73,7 +74,7 @@ func TestBindArgsBindsScalarFields(t *testing.T) { Name string } - ctx := &MsgContext{Args: []string{"42", "true", "3.5", "Ada", "Lovelace"}} + ctx := &MessageContext{Args: []string{"42", "true", "3.5", "Ada", "Lovelace"}} var got input if err := ctx.BindArgs(&got); err != nil { @@ -92,7 +93,7 @@ func TestBindArgsBindsScalarFields(t *testing.T) { } func TestNewInlineKeyboardButtonUsesContextPayloadType(t *testing.T) { - ctx := &MsgContext{payloadType: BotPayloadBase64} + ctx := &MessageContext{payloadType: BotPayloadBase64} kb := NewInlineKeyboardJSON(1). AddButton(ctx.NewInlineKeyboardButton("A").SetCallbackData("cmd", 1, "two")) @@ -115,7 +116,7 @@ func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) { Admin bool } - ctx := &MsgContext{Args: []string{"7"}} + ctx := &MessageContext{Args: []string{"7"}} var got input if err := ctx.BindArgs(&got); err != nil { @@ -134,7 +135,7 @@ func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) { } func TestBindArgsRejectsInvalidTargets(t *testing.T) { - ctx := &MsgContext{Args: []string{"1"}} + ctx := &MessageContext{Args: []string{"1"}} if err := ctx.BindArgs(nil); !errors.Is(err, ErrBindArgsTargetNotPointer) { t.Fatalf("expected ErrBindArgsTargetNotPointer for nil target, got %v", err) @@ -151,7 +152,7 @@ func TestBindArgsReportsConversionFailures(t *testing.T) { ID int } - ctx := &MsgContext{Args: []string{"oops"}} + ctx := &MessageContext{Args: []string{"oops"}} var got input err := ctx.BindArgs(&got) @@ -171,7 +172,7 @@ func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) { Tags []string } - ctx := &MsgContext{Args: []string{"tag"}} + ctx := &MessageContext{Args: []string{"tag"}} var got input err := ctx.BindArgs(&got) @@ -183,7 +184,37 @@ func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) { } } -func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { +func TestErrorDefaultStaysInternalForMessageFlow(t *testing.T) { + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatal("unexpected HTTP request for unclassified error") + return nil, nil + }), + } + + api := tgapi.NewAPI( + tgapi.NewAPIOpts("token"). + SetAPIURL("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + ctx := &MessageContext{ + API: api, + Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, + Logger: sneklog.NewLogger(), + errorTemplate: "Error: %s", + } + + // Unclassified errors must not leak to the user. Only AsUserError replies. + ctx.error(errors.New("boom")) +} + +func TestErrorUserVisibleAnswersForMessageFlow(t *testing.T) { var requests int var gotBody map[string]any @@ -216,14 +247,14 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } - ctx.error(errors.New("boom")) + ctx.error(AsUserError(errors.New("boom"))) if requests != 1 { t.Fatalf("expected one user-facing error reply, got %d requests", requests) @@ -252,7 +283,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), @@ -281,7 +312,7 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Logger: sneklog.NewLogger(), errorTemplate: "%s", @@ -324,7 +355,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Logger: sneklog.NewLogger(), errorTemplate: "Oops: %s", @@ -344,13 +375,13 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { func TestIsCallbackIncludesInlineCallbackTargets(t *testing.T) { tests := []struct { name string - ctx MsgContext + ctx MessageContext want bool }{ - {name: "callback query id", ctx: MsgContext{CallbackQueryID: "cb-1"}, want: true}, - {name: "callback message id", ctx: MsgContext{CallbackMsgID: 12}, want: true}, - {name: "inline message id", ctx: MsgContext{InlineMsgID: "inline-1"}, want: true}, - {name: "not callback", ctx: MsgContext{}, want: false}, + {name: "callback query id", ctx: MessageContext{CallbackQueryID: "cb-1"}, want: true}, + {name: "callback message id", ctx: MessageContext{CallbackMsgID: 12}, want: true}, + {name: "inline message id", ctx: MessageContext{InlineMsgID: "inline-1"}, want: true}, + {name: "not callback", ctx: MessageContext{}, want: false}, } for _, tt := range tests { @@ -397,7 +428,7 @@ func TestUpsertKeyboardEditsInlineCallback(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, InlineMsgID: "inline-1", Logger: sneklog.NewLogger(), @@ -426,7 +457,7 @@ func TestUpsertKeyboardEditsInlineCallback(t *testing.T) { } func TestAnswerRejectsEmptyMessage(t *testing.T) { - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), } @@ -455,7 +486,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), @@ -539,7 +570,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), diff --git a/msg_handler.go b/msg_handler.go index 5053ddd..9baad5e 100644 --- a/msg_handler.go +++ b/msg_handler.go @@ -7,7 +7,7 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { +func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MessageContext) bool { text, ok := messageText(update) if !ok { return false @@ -22,7 +22,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { if strings.Contains(cmd, "@") { botUsername := bot.username if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) { - cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname + cmd = cmd[:len(cmd)-len("@"+botUsername)] // remove @botname } } // Ищем команду по точному совпадению @@ -30,7 +30,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { if _, exists := plugin.commands[cmd]; exists { ctx.Text = args - ctx.Args = strings.Fields(args) // Убирает лишние пробелы + ctx.Args = strings.Fields(args) if plugin.logger != nil { ctx.Logger = plugin.logger @@ -90,7 +90,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool { return bot.handleFallback(update, ctx) } -func (bot *Bot[T]) handleFallback(update *tgapi.Update, ctx *MsgContext) bool { +func (bot *Bot[T]) handleFallback(update *tgapi.Update, ctx *MessageContext) bool { text, ok := messageText(update) if !ok { return false @@ -180,7 +180,7 @@ func messageText(update *tgapi.Update) (string, bool) { return text, true } -func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool { +func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MessageContext) bool { data, err := bot.decodePayload(update.CallbackQuery.Data) if err != nil { bot.logger.Errorln(err) diff --git a/plugins.go b/plugins.go index a2ac49a..a7074f6 100644 --- a/plugins.go +++ b/plugins.go @@ -23,6 +23,7 @@ type Plugin[T AppData] struct { middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help logger *sneklog.Logger + loggerOwned bool // true when the logger was created by the bot during registration; only owned loggers are closed by Close messageFallback CommandExecutor[T] handlers map[tgapi.UpdateType]CommandExecutor[T] @@ -53,6 +54,9 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { } return p } + if _, exists := p.commands[command.command]; exists && p.logger != nil { + p.logger.Warnf("command '%s' is already registered in plugin '%s'; overwriting", command.command, p.name) + } p.commands[command.command] = command return p } @@ -74,6 +78,9 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { } return p } + if _, exists := p.payloads[command.command]; exists && p.logger != nil { + p.logger.Warnf("payload '%s' is already registered in plugin '%s'; overwriting", command.command, p.name) + } p.payloads[command.command] = command return p } @@ -81,7 +88,7 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { // Payload creates and immediately adds a new payload command to the plugin. // Returns the created payload command for further configuration. func (p *Plugin[T]) Payload(command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] { - cmd := NewPayload(command, exec, args...) + cmd := NewCommand(command, exec, args...) p.AddPayload(cmd) return cmd } @@ -101,6 +108,9 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { } scene.PluginName = p.name scene.setPluginName(p.name) + if _, exists := p.scenes[scene.Name]; exists && p.logger != nil { + p.logger.Warnf("scene '%s' is already registered in plugin '%s'; overwriting", scene.Name, p.name) + } p.scenes[scene.Name] = scene return p } @@ -209,9 +219,13 @@ func (p *Plugin[T]) SetMessageFallback(handler CommandExecutor[T]) *Plugin[T] { // Close releases plugin-owned resources such as its logger and optional // OnClose callback. +// +// Only loggers created by the bot during registration are closed. A logger +// supplied via SetLogger remains the caller's responsibility — the framework +// never closes a logger it does not own. func (p *Plugin[T]) Close() error { var e []error - if p.logger != nil { + if p.logger != nil && p.loggerOwned { if err := p.logger.Close(); err != nil { e = append(e, err) } @@ -225,7 +239,7 @@ func (p *Plugin[T]) Close() error { } // Internal helper that validates and executes a command handler. -func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) error { +func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error { command, exists := p.commands[cmd] if !exists { return AsInternalError(errCommandNotFound) @@ -247,7 +261,7 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) error { } // Internal helper that validates and executes a payload handler. -func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) error { +func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) error { command, exists := p.payloads[payload] if !exists { return AsInternalError(errPayloadNotFound) @@ -269,7 +283,7 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) error } // Internal helper that runs plugin middlewares in order. -func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool { +func (p *Plugin[T]) executeMiddlewares(ctx *MessageContext, db T) bool { for _, m := range p.middlewares { if !m.Execute(ctx, db) { return false @@ -281,14 +295,14 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool { // MiddlewareExecutor is the function type for middleware logic. // Returns true to continue execution, false to block it. // If async, return value is ignored. -type MiddlewareExecutor[T AppData] func(ctx *MsgContext, db T) bool +type MiddlewareExecutor[T AppData] func(ctx *MessageContext, db T) bool // Middleware represents a reusable execution interceptor. // Can be synchronous (blocking) or asynchronous (non-blocking). type Middleware[T AppData] struct { name string // Human-readable name for logging/debugging executor MiddlewareExecutor[T] // Function to execute - order int // Optional sort order (not used yet) + order int // Sort order for bot-level middleware ordering async bool // If true, runs in goroutine and doesn't block } @@ -313,12 +327,19 @@ func (m Middleware[T]) SetAsync(async bool) Middleware[T] { // Execute runs the middleware. // If async, runs in a goroutine and returns true immediately. // Otherwise, returns the result of the executor. -func (m Middleware[T]) Execute(ctx *MsgContext, db T) bool { +// +// Async note: the goroutine receives a shallow copy of MessageContext, so +// scalar fields (FromID, ChatID, CallbackQueryID, ...) remain a stable +// snapshot. Pointer and slice fields (Msg, From, Chat, API, Logger, Args) +// continue to share storage with the synchronous flow. Async middleware +// must treat those fields as read-only — mutating them races the sync chain +// that mutates the same context concurrently. +func (m Middleware[T]) Execute(ctx *MessageContext, db T) bool { if m.async { - ctx := *ctx // copy context to avoid race condition - go func(ctx MsgContext) { + ctxCopy := *ctx + go func(ctx MessageContext) { m.executor(&ctx, db) - }(ctx) + }(ctxCopy) return true } return m.executor(ctx, db) diff --git a/plugins_test.go b/plugins_test.go index ac12fd8..037b4a3 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -6,7 +6,7 @@ import ( ) func TestValidateArgsRequiresFullMatch(t *testing.T) { - intCmd := NewCommand("int", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).SetRequired()) + intCmd := NewCommand("int", func(ctx *MessageContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).SetRequired()) if err := intCmd.validateArgs([]string{"123"}); err != nil { t.Fatalf("expected valid integer argument, got %v", err) } @@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) } - boolCmd := NewCommand("bool", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("flag").SetValueType(CommandValueBool).SetRequired()) + boolCmd := NewCommand("bool", func(ctx *MessageContext, db NoData) error { return nil }, NewCommandArg("flag").SetValueType(CommandValueBool).SetRequired()) if err := boolCmd.validateArgs([]string{"false"}); err != nil { t.Fatalf("expected valid bool argument, got %v", err) } @@ -26,7 +26,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { cmd := NewCommand( "mixed", - func(ctx *MsgContext, db NoData) error { return nil }, + func(ctx *MessageContext, db NoData) error { return nil }, NewCommandArg("optional"), NewCommandArg("required").SetRequired(), ) @@ -40,9 +40,9 @@ func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) { } func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) { - groupMiddleware := NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true }) - commandMiddleware := NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }) - cmd := NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }). + groupMiddleware := NewMiddleware("group", func(ctx *MessageContext, db NoData) bool { return true }) + commandMiddleware := NewMiddleware("command", func(ctx *MessageContext, db NoData) bool { return true }) + cmd := NewCommand("ban", func(ctx *MessageContext, db NoData) error { return nil }). SetDescription("Ban user"). Use(commandMiddleware) @@ -78,9 +78,9 @@ func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) func TestCommandGroupBuildIsRepeatable(t *testing.T) { group := NewCommandGroup[NoData]("admin"). - Use(NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })). - AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }). - Use(NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true }))) + Use(NewMiddleware("group", func(ctx *MessageContext, db NoData) bool { return true })). + AddCommand(NewCommand("ban", func(ctx *MessageContext, db NoData) error { return nil }). + Use(NewMiddleware("command", func(ctx *MessageContext, db NoData) bool { return true }))) first := group.Build() second := group.Build() @@ -103,7 +103,7 @@ func TestPluginCommandGroupRegistersBuiltCommands(t *testing.T) { plugin := NewPlugin[NoData]("admin") plugin.CommandGroup("admin_", func(group *CommandGroup[NoData]) { - group.AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil })) + group.AddCommand(NewCommand("ban", func(ctx *MessageContext, db NoData) error { return nil })) }) if _, ok := plugin.commands["admin_ban"]; !ok { diff --git a/policy.go b/policy.go index 9ea88a7..722c179 100644 --- a/policy.go +++ b/policy.go @@ -8,11 +8,11 @@ import ( ) // Policy defines a reusable authorization rule for the current update context. -type Policy[T AppData] func(ctx *MsgContext, data T) error +type Policy[T AppData] func(ctx *MessageContext, data T) error // RequirePolicy adapts a Policy into a blocking middleware. func RequirePolicy[T AppData](name string, p Policy[T]) Middleware[T] { - return NewMiddleware(name, func(ctx *MsgContext, data T) bool { + return NewMiddleware(name, func(ctx *MessageContext, data T) bool { if err := p(ctx, data); err != nil { ctx.emitPolicyChecked(PolicyCheckedEvent{ Name: name, @@ -37,7 +37,7 @@ func RequirePolicy[T AppData](name string, p Policy[T]) Middleware[T] { // AllPolicies composes policies that all must succeed. func AllPolicies[T AppData](policies ...Policy[T]) Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { for _, p := range policies { if err := p(ctx, data); err != nil { return err @@ -49,7 +49,7 @@ func AllPolicies[T AppData](policies ...Policy[T]) Policy[T] { // AnyPolicy composes policies where at least one must succeed. func AnyPolicy[T AppData](policies ...Policy[T]) Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { var firstDeny error var internalErr error for _, p := range policies { @@ -79,7 +79,7 @@ func AnyPolicy[T AppData](policies ...Policy[T]) Policy[T] { // NotPolicy inverts a policy deny result while preserving internal failures. func NotPolicy[T AppData](policy Policy[T]) Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { var err error if err = policy(ctx, data); err == nil { return AsUserError(errors.New("the action is not allowed due to policy violation")) @@ -93,7 +93,7 @@ func NotPolicy[T AppData](policy Policy[T]) Policy[T] { // RequirePrivateChat allows execution only in private chats. func RequirePrivateChat[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.Msg == nil || ctx.Msg.Chat == nil { return AsInternalError(errors.New("private-chat policy requires message chat context")) } @@ -108,7 +108,7 @@ func RequirePrivateChat[T AppData]() Policy[T] { // RequireGroupChat allows execution only in group or supergroup chats. func RequireGroupChat[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.Msg == nil || ctx.Msg.Chat == nil { return AsInternalError(errors.New("group-chat policy requires message chat context")) } @@ -123,7 +123,7 @@ func RequireGroupChat[T AppData]() Policy[T] { // RequireSupergroupChat allows execution only in supergroup chats. func RequireSupergroupChat[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.Msg == nil || ctx.Msg.Chat == nil { return AsInternalError(errors.New("supergroup-chat policy requires message chat context")) } @@ -138,7 +138,7 @@ func RequireSupergroupChat[T AppData]() Policy[T] { // RequireChatAdmin allows execution only for chat administrators or owners. func RequireChatAdmin[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.FromID == 0 || ctx.ChatID == 0 { return AsInternalError(errors.New("chat-admin policy requires message chat context")) } @@ -161,7 +161,7 @@ func RequireChatAdmin[T AppData]() Policy[T] { // RequireChatCreator allows execution only for the chat owner. func RequireChatCreator[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.FromID == 0 || ctx.ChatID == 0 { return AsInternalError(errors.New("chat-creator policy requires message chat context")) } @@ -184,19 +184,16 @@ func RequireChatCreator[T AppData]() Policy[T] { // RequireBotAdmin allows execution only when the bot is an admin in the chat. func RequireBotAdmin[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.ChatID == 0 { return AsInternalError(errors.New("bot-admin policy requires message chat context")) } - - bot, err := ctx.API.GetMe() - if err != nil { - return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err)) + if ctx.botID == 0 { + return AsInternalError(errors.New("bot ID is not set in context")) } member, err := ctx.API.GetChatMember(tgapi.GetChatMember{ - ChatID: ctx.ChatID, - UserID: bot.ID, + ChatID: ctx.ChatID, UserID: ctx.botID, }) if err != nil { return AsInternalError(fmt.Errorf("failed to fetch bot member status: %w", err)) @@ -212,7 +209,7 @@ func RequireBotAdmin[T AppData]() Policy[T] { // RequireCallbackFromUser allows execution only for callback queries sent by non-bot users. func RequireCallbackFromUser[T AppData]() Policy[T] { - return func(ctx *MsgContext, data T) error { + return func(ctx *MessageContext, data T) error { if ctx.Update.CallbackQuery == nil { return AsInternalError(errors.New("callback-user policy requires callback query context")) } diff --git a/policy_test.go b/policy_test.go index d48d508..9338884 100644 --- a/policy_test.go +++ b/policy_test.go @@ -46,14 +46,14 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } - mw := RequirePolicy("deny", func(ctx *MsgContext, data NoData) error { + mw := RequirePolicy("deny", func(ctx *MessageContext, data NoData) error { return AsUserError(errors.New("blocked")) }) @@ -69,7 +69,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { } func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) { - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, @@ -82,7 +82,7 @@ func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) { } func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) { - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup}, }, @@ -136,7 +136,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { } }() - ctx := &MsgContext{ + ctx := &MessageContext{ API: api, ChatID: -2001, FromID: 55, @@ -160,15 +160,15 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { func TestAllPoliciesReturnsFirstError(t *testing.T) { want := AsUserError(errors.New("blocked")) policy := AllPolicies( - func(ctx *MsgContext, data NoData) error { return nil }, - func(ctx *MsgContext, data NoData) error { return want }, - func(ctx *MsgContext, data NoData) error { + func(ctx *MessageContext, data NoData) error { return nil }, + func(ctx *MessageContext, data NoData) error { return want }, + func(ctx *MessageContext, data NoData) error { t.Fatal("unexpected evaluation after first failure") return nil }, ) - err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) + err := policy(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, want) { t.Fatalf("expected first policy error, got %v", err) } @@ -176,11 +176,11 @@ func TestAllPoliciesReturnsFirstError(t *testing.T) { func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { policy := AnyPolicy( - func(ctx *MsgContext, data NoData) error { return AsInternalError(errors.New("temporary")) }, - func(ctx *MsgContext, data NoData) error { return nil }, + func(ctx *MessageContext, data NoData) error { return AsInternalError(errors.New("temporary")) }, + func(ctx *MessageContext, data NoData) error { return nil }, ) - if err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { + if err := policy(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { t.Fatalf("expected later success to allow access, got %v", err) } } @@ -188,11 +188,11 @@ func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) { func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { internal := AsInternalError(errors.New("temporary")) policy := AnyPolicy( - func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) }, - func(ctx *MsgContext, data NoData) error { return internal }, + func(ctx *MessageContext, data NoData) error { return AsUserError(errors.New("denied")) }, + func(ctx *MessageContext, data NoData) error { return internal }, ) - err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) + err := policy(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, internal) { t.Fatalf("expected internal error, got %v", err) } @@ -201,29 +201,29 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) { first := AsUserError(errors.New("first deny")) policy := AnyPolicy( - func(ctx *MsgContext, data NoData) error { return first }, - func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) }, + func(ctx *MessageContext, data NoData) error { return first }, + func(ctx *MessageContext, data NoData) error { return AsUserError(errors.New("second deny")) }, ) - err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) + err := policy(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, first) { t.Fatalf("expected first deny error, got %v", err) } } func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { - inverted := NotPolicy(func(ctx *MsgContext, data NoData) error { + inverted := NotPolicy(func(ctx *MessageContext, data NoData) error { return AsUserError(errors.New("denied")) }) - if err := inverted(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { + if err := inverted(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil { t.Fatalf("expected inverted deny to succeed, got %v", err) } internal := AsInternalError(errors.New("temporary")) - preserve := NotPolicy(func(ctx *MsgContext, data NoData) error { + preserve := NotPolicy(func(ctx *MessageContext, data NoData) error { return internal }) - err := preserve(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) + err := preserve(&MessageContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, internal) { t.Fatalf("expected internal error to be preserved, got %v", err) } @@ -232,7 +232,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { func TestRequirePolicyEmitsObserverEvents(t *testing.T) { t.Run("allow", func(t *testing.T) { observer := &recordingObserver{} - ctx := &MsgContext{ + ctx := &MessageContext{ Logger: sneklog.NewLogger(), ctx: context.Background(), observer: observer, @@ -240,7 +240,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { ChatID: 20, } - mw := RequirePolicy("allow", func(ctx *MsgContext, data NoData) error { + mw := RequirePolicy("allow", func(ctx *MessageContext, data NoData) error { return nil }) @@ -257,14 +257,14 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { t.Run("deny", func(t *testing.T) { observer := &recordingObserver{} - ctx := &MsgContext{ + ctx := &MessageContext{ Logger: sneklog.NewLogger(), ctx: context.Background(), observer: observer, errorTemplate: "%s", } - mw := RequirePolicy("deny", func(ctx *MsgContext, data NoData) error { + mw := RequirePolicy("deny", func(ctx *MessageContext, data NoData) error { return AsInternalError(errors.New("blocked")) }) diff --git a/runners.go b/runners.go index 72d075f..883280b 100644 --- a/runners.go +++ b/runners.go @@ -161,23 +161,30 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - startedAt := time.Now() - err := r.fn(bot) - bot.safeEmitEvent(ctx, RunnerFinishedEvent{ - Name: r.name, - Duration: time.Since(startedAt), - Err: err, + } + // When both ctx.Done() and ticker.C are ready at the same + // time, Go's select picks one at random. Re-check ctx so a + // late tick after cancellation does not fire one extra + // invocation past shutdown. + if ctx.Err() != nil { + return + } + startedAt := time.Now() + err := r.fn(bot) + bot.safeEmitEvent(ctx, RunnerFinishedEvent{ + Name: r.name, + Duration: time.Since(startedAt), + Err: err, + }) + if err != nil { + bot.safeEmitEvent(ctx, ErrorEvent{ + Plugin: "bot", + HandlerKind: HandlerRunnerKind, + HandlerName: r.name, + Err: err, + UserFacing: false, }) - if err != nil { - bot.safeEmitEvent(ctx, ErrorEvent{ - Plugin: "bot", - HandlerKind: HandlerRunnerKind, - HandlerName: r.name, - Err: err, - UserFacing: false, - }) - bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) - } + bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } } }(runner) diff --git a/scene.go b/scene.go index a868d31..c083253 100644 --- a/scene.go +++ b/scene.go @@ -15,7 +15,7 @@ type Scene[T any] struct { Name string // Scope controls how active scene sessions are keyed and shared. Scope SceneScope - // Entry names the first step used by MsgContext.EnterScene. + // Entry names the first step used by MessageContext.EnterScene. Entry string // PluginName stores the owning plugin name for scene resolution. PluginName string @@ -45,7 +45,7 @@ func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { return s } -// SetEntry sets the initial step entered by MsgContext.EnterScene. +// SetEntry sets the initial step entered by MessageContext.EnterScene. func (s *Scene[T]) SetEntry(step string) *Scene[T] { s.Entry = step return s @@ -260,8 +260,7 @@ type sceneRuntime interface { getSession(key string) (SceneSession, error) setSession(key string, session SceneSession) error deleteSession(key string) error - buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) - findSceneSession(ctx *MsgContext) (string, SceneSession, error) + findSceneSession(ctx *MessageContext) (string, SceneSession, error) } type sceneMeta struct { diff --git a/scene_context.go b/scene_context.go index a3ef20b..dc4da74 100644 --- a/scene_context.go +++ b/scene_context.go @@ -1,8 +1,8 @@ package laniakea -// SceneContext wraps MsgContext with scene session state for scene handlers. +// SceneContext wraps MessageContext with scene session state for scene handlers. type SceneContext struct { - *MsgContext + *MessageContext sess SceneSession key string } diff --git a/scene_handler.go b/scene_handler.go index b4c0c22..fb22545 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -7,10 +7,10 @@ import ( "time" ) -func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { +func (bot *Bot[T]) tryHandleScene(ctx *MessageContext) (bool, error) { key, session, err := bot.findSceneSession(ctx) if err != nil { - if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) { + if errors.Is(err, ErrCantFindSession) { return false, nil } return false, err @@ -31,9 +31,9 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { return false, nil } sceneCtx := &SceneContext{ - MsgContext: ctx, - sess: session, - key: key, + MessageContext: ctx, + sess: session, + key: key, } return bot.executeScene(sceneCtx, scene) @@ -42,7 +42,7 @@ func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) { } func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error) { - if ctx.MsgContext == nil || ctx.sess.Scene == "" { + if ctx.MessageContext == nil || ctx.sess.Scene == "" { return false, nil } @@ -269,7 +269,7 @@ func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result S return false, nil } } -func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) { +func buildSceneKey(scope SceneScope, ctx *MessageContext) (string, bool) { if ctx == nil { return "", false } diff --git a/scene_test.go b/scene_test.go index bde5f10..a641f03 100644 --- a/scene_test.go +++ b/scene_test.go @@ -71,7 +71,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { t.Fatalf("unexpected scene entry: got %q want %q", sceneMeta.Entry, "start") } - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -95,7 +95,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { t.Fatal("expected scene step handler to be called") } - lookupCtx := &MsgContext{ + lookupCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, } @@ -108,7 +108,7 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { tests := []struct { name string scope SceneScope - ctx *MsgContext + ctx *MessageContext }{ { name: "nil context", @@ -118,17 +118,17 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) { { name: "missing message for chat scope", scope: SceneScopeChat, - ctx: &MsgContext{}, + ctx: &MessageContext{}, }, { name: "missing from id for user scope", scope: SceneScopeUser, - ctx: &MsgContext{}, + ctx: &MessageContext{}, }, { name: "missing from id for user chat scope", scope: SceneScopeUserChat, - ctx: &MsgContext{ + ctx: &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, }, }, @@ -155,7 +155,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { } bot.AddPlugins(plugin) - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -178,7 +178,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { } bot.AddPlugins(plugin) - ctx := &MsgContext{ + ctx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -192,7 +192,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { } func TestSceneContextMethodsRequireRuntime(t *testing.T) { - ctx := &MsgContext{} + ctx := &MessageContext{} if err := ctx.EnterScene("signup"); !errors.Is(err, ErrSceneRuntimeNil) { t.Fatalf("expected ErrSceneRuntimeNil from EnterScene, got %v", err) @@ -238,7 +238,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -287,7 +287,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -339,7 +339,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -394,7 +394,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { } bot.AddPlugins(plugin) - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -460,7 +460,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -517,7 +517,7 @@ func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -563,7 +563,7 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { stepCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.Payload("ping", func(ctx *MsgContext, db NoData) error { return nil }) + plugin.Payload("ping", func(ctx *MessageContext, db NoData) error { return nil }) plugin.Scene("signup"). SetEntry("start"). OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) { @@ -579,7 +579,7 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -588,7 +588,7 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -632,7 +632,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { commandCalled := false plugin := NewPlugin[NoData]("wizard") - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) @@ -655,7 +655,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -664,7 +664,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -718,7 +718,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { stepCalled = true return ctx.Stay(), nil }) - plugin.Command("ping", func(ctx *MsgContext, db NoData) error { + plugin.Command("ping", func(ctx *MessageContext, db NoData) error { commandCalled = true return nil }) @@ -731,7 +731,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -740,7 +740,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -800,7 +800,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { } bot.AddPlugins(plugin) - enterCtx := &MsgContext{ + enterCtx := &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, sceneRuntime: bot, @@ -809,7 +809,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{ + key, ok := buildSceneKey(SceneScopeUserChat, &MessageContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}}, FromID: 42, }) @@ -847,7 +847,7 @@ func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { t.Fatalf("Set returned error: %v", err) } - key, session, err := bot.findSceneSession(&MsgContext{FromID: 42}) + key, session, err := bot.findSceneSession(&MessageContext{FromID: 42}) if err != nil { t.Fatalf("findSceneSession returned error: %v", err) } @@ -870,7 +870,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { sceneScopePriority: []SceneScope{SceneScopeUser}, } - _, _, err := bot.findSceneSession(&MsgContext{FromID: 42}) + _, _, err := bot.findSceneSession(&MessageContext{FromID: 42}) if !errors.Is(err, getErr) { t.Fatalf("expected getErr, got %v", err) } @@ -887,9 +887,9 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { } _, err := bot.applySceneResult(scene, &SceneContext{ - MsgContext: &MsgContext{}, - sess: SceneSession{Scene: "signup", Step: "start"}, - key: "user_id:42:chat_id:100", + MessageContext: &MessageContext{}, + sess: SceneSession{Scene: "signup", Step: "start"}, + key: "user_id:42:chat_id:100", }, SceneResult{Action: SceneActionStay}) if !errors.Is(err, setErr) { t.Fatalf("expected setErr, got %v", err) diff --git a/update_context.go b/update_context.go index cb2167b..39c21fe 100644 --- a/update_context.go +++ b/update_context.go @@ -6,7 +6,7 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) bool { +func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MessageContext) bool { handled := false for _, plugin := range bot.plugins { handler, ok := plugin.handlers[u.Type] @@ -66,7 +66,7 @@ func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) bool { return handled } -func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { +func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MessageContext) { var from *tgapi.User var chat *tgapi.Chat switch u.Type { diff --git a/utils/limiter.go b/utils/limiter.go index ed72897..698d692 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -16,6 +16,10 @@ var ErrDropOverflow = errors.New("drop overflow limit") // It supports two modes: // - "drop" mode: immediately reject if limits are exceeded. // - "wait" mode: block until capacity is available. +// +// Per-chat limiters are created lazily and accumulate indefinitely. Call Cleanup +// periodically (e.g. from a background runner) to evict idle entries and prevent +// unbounded memory growth in bots that serve many distinct chats. type RateLimiter struct { globalLockUntil time.Time // global cooldown timestamp (set by API errors) globalLimiter *rate.Limiter // global token bucket (30 req/sec) @@ -23,7 +27,8 @@ type RateLimiter struct { chatLocks map[int64]time.Time // per-chat cooldown timestamps chatLimiters map[int64]*rate.Limiter // per-chat token buckets (1 req/sec) - chatMu sync.RWMutex // protects chatLocks and chatLimiters + chatLastSeen map[int64]time.Time // last access timestamp per chat, for Cleanup eviction + chatMu sync.RWMutex // protects chatLocks, chatLimiters, and chatLastSeen } // NewRateLimiter creates a new RateLimiter with default limits. @@ -34,6 +39,32 @@ func NewRateLimiter() *RateLimiter { globalLimiter: rate.NewLimiter(30, 30), chatLimiters: make(map[int64]*rate.Limiter), chatLocks: make(map[int64]time.Time), + chatLastSeen: make(map[int64]time.Time), + } +} + +// Cleanup removes per-chat limiter state that has not been touched within +// idleThreshold and chat cooldowns whose expiry has already passed. +// +// Safe to call concurrently with Wait/Allow. Intended for periodic invocation +// from a background runner (e.g. once a minute) to bound memory in long-running +// bots that serve many distinct chats. +func (rl *RateLimiter) Cleanup(idleThreshold time.Duration) { + now := time.Now() + rl.chatMu.Lock() + defer rl.chatMu.Unlock() + + for chatID, lastSeen := range rl.chatLastSeen { + if now.Sub(lastSeen) <= idleThreshold { + continue + } + delete(rl.chatLimiters, chatID) + delete(rl.chatLastSeen, chatID) + } + for chatID, until := range rl.chatLocks { + if !until.After(now) { + delete(rl.chatLocks, chatID) + } } } @@ -228,14 +259,28 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro } // Internal helper that returns or creates a per-chat limiter. +// Updates chatLastSeen so Cleanup can evict idle entries. func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { - rl.chatMu.Lock() - defer rl.chatMu.Unlock() + now := time.Now() - if lim, ok := rl.chatLimiters[chatID]; ok { + rl.chatMu.RLock() + lim, ok := rl.chatLimiters[chatID] + rl.chatMu.RUnlock() + if ok { + rl.chatMu.Lock() + rl.chatLastSeen[chatID] = now + rl.chatMu.Unlock() return lim } - lim := rate.NewLimiter(1, 1) + + rl.chatMu.Lock() + defer rl.chatMu.Unlock() + if lim, ok := rl.chatLimiters[chatID]; ok { + rl.chatLastSeen[chatID] = now + return lim + } + lim = rate.NewLimiter(1, 1) rl.chatLimiters[chatID] = lim + rl.chatLastSeen[chatID] = now return lim } diff --git a/utils/limiter_test.go b/utils/limiter_test.go index ab69db9..adef0c5 100644 --- a/utils/limiter_test.go +++ b/utils/limiter_test.go @@ -39,3 +39,52 @@ func TestRateLimiterGlobalWaitRespectsContextCancellation(t *testing.T) { t.Fatalf("expected DeadlineExceeded, got %v", err) } } + +// TestRateLimiterCleanupEvictsIdleChats guards the memory-leak fix: per-chat +// limiter and lastSeen state must be reclaimed by Cleanup once the entry has +// been idle for longer than the threshold, while still-active chats and +// unexpired cooldowns must survive. +func TestRateLimiterCleanupEvictsIdleChats(t *testing.T) { + rl := NewRateLimiter() + + // Touch chat 1 to make it tracked, then backdate its last-seen marker + // so it looks idle from Cleanup's perspective. + if !rl.Allow(1) { + t.Fatal("expected initial Allow for chat 1 to succeed") + } + rl.chatMu.Lock() + rl.chatLastSeen[1] = time.Now().Add(-time.Hour) + rl.chatMu.Unlock() + + // Touch chat 2 so it stays "active". + if !rl.Allow(2) { + t.Fatal("expected initial Allow for chat 2 to succeed") + } + + // Expired cooldown should be evicted; future cooldown should survive. + rl.SetChatLock(10, 1) + rl.chatMu.Lock() + rl.chatLocks[10] = time.Now().Add(-time.Second) + rl.chatLocks[11] = time.Now().Add(time.Hour) + rl.chatMu.Unlock() + + rl.Cleanup(time.Minute) + + rl.chatMu.RLock() + defer rl.chatMu.RUnlock() + if _, ok := rl.chatLimiters[1]; ok { + t.Fatal("expected idle chat 1 limiter to be evicted") + } + if _, ok := rl.chatLastSeen[1]; ok { + t.Fatal("expected idle chat 1 lastSeen to be evicted") + } + if _, ok := rl.chatLimiters[2]; !ok { + t.Fatal("expected active chat 2 limiter to remain") + } + if _, ok := rl.chatLocks[10]; ok { + t.Fatal("expected expired chat 10 lock to be evicted") + } + if _, ok := rl.chatLocks[11]; !ok { + t.Fatal("expected future chat 11 lock to remain") + } +} -- 2.54.0 From 1e26d871b5ca121b869ae28a1883a7bcc3238b1f Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 19 May 2026 13:42:10 +0300 Subject: [PATCH 95/99] (new): Bot API 10.0 (doc): Since: Bot API X.Y annotations across all tgapi types and methods --- tgapi/attachments_methods.go | 70 ++++++++++ tgapi/attachments_types.go | 187 ++++++++++++++++++++----- tgapi/bot_methods.go | 102 ++++++++++++++ tgapi/bot_types.go | 14 ++ tgapi/business_methods.go | 71 +++++++++- tgapi/business_types.go | 12 +- tgapi/chat_methods.go | 127 +++++++++++++++-- tgapi/chat_types.go | 78 +++++++---- tgapi/forum_methods.go | 31 ++++ tgapi/forum_types.go | 22 ++- tgapi/games_methods.go | 9 ++ tgapi/games_types.go | 6 + tgapi/inline_methods.go | 12 ++ tgapi/inline_types.go | 5 + tgapi/messages_methods.go | 166 ++++++++++++++++++++++ tgapi/messages_types.go | 264 +++++++++++++++++++++++------------ tgapi/passport_methods.go | 3 + tgapi/passport_types.go | 13 ++ tgapi/payments_methods.go | 12 ++ tgapi/payments_types.go | 19 ++- tgapi/stars_methods.go | 11 ++ tgapi/stars_types.go | 2 + tgapi/stickers_methods.go | 48 +++++++ tgapi/stickers_types.go | 28 ++-- tgapi/types.go | 110 ++++++++++++--- tgapi/uploader_api.go | 2 + tgapi/uploader_methods.go | 88 +++++++++--- tgapi/users_methods.go | 38 +++++ tgapi/users_types.go | 39 +++--- 29 files changed, 1347 insertions(+), 242 deletions(-) diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index c4d3a5b..dfe0ee2 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // SendPhoto holds parameters for the sendPhoto method. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendphoto type SendPhoto struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -28,6 +29,7 @@ type SendPhoto struct { } // SendPhoto sends a photo. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendphoto func (api *API) SendPhoto(params SendPhoto) (Message, error) { req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID) @@ -35,6 +37,7 @@ func (api *API) SendPhoto(params SendPhoto) (Message, error) { } // SendPhotoWithContext is the context-aware variant of SendPhoto. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendphoto func (api *API) SendPhotoWithContext(ctx context.Context, params SendPhoto) (Message, error) { @@ -43,6 +46,7 @@ func (api *API) SendPhotoWithContext(ctx context.Context, params SendPhoto) (Mes } // SendAudio holds parameters for the sendAudio method. +// Since: Bot API 1.2 // See https://core.telegram.org/bots/api#sendaudio type SendAudio struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -70,6 +74,7 @@ type SendAudio struct { } // SendAudio sends an audio file. +// Since: Bot API 1.2 // See https://core.telegram.org/bots/api#sendaudio func (api *API) SendAudio(params SendAudio) (Message, error) { req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID) @@ -77,6 +82,7 @@ func (api *API) SendAudio(params SendAudio) (Message, error) { } // SendAudioWithContext is the context-aware variant of SendAudio. +// Since: Bot API 1.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendaudio func (api *API) SendAudioWithContext(ctx context.Context, params SendAudio) (Message, error) { @@ -85,6 +91,7 @@ func (api *API) SendAudioWithContext(ctx context.Context, params SendAudio) (Mes } // SendDocument holds parameters for the sendDocument method. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#senddocument type SendDocument struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -110,6 +117,7 @@ type SendDocument struct { } // SendDocument sends a document. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#senddocument func (api *API) SendDocument(params SendDocument) (Message, error) { req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID) @@ -117,6 +125,7 @@ func (api *API) SendDocument(params SendDocument) (Message, error) { } // SendDocumentWithContext is the context-aware variant of SendDocument. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#senddocument func (api *API) SendDocumentWithContext(ctx context.Context, params SendDocument) (Message, error) { @@ -125,6 +134,7 @@ func (api *API) SendDocumentWithContext(ctx context.Context, params SendDocument } // SendVideo holds parameters for the sendVideo method. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendvideo type SendVideo struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -158,6 +168,7 @@ type SendVideo struct { } // SendVideo sends a video. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendvideo func (api *API) SendVideo(params SendVideo) (Message, error) { req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID) @@ -165,6 +176,7 @@ func (api *API) SendVideo(params SendVideo) (Message, error) { } // SendVideoWithContext is the context-aware variant of SendVideo. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvideo func (api *API) SendVideoWithContext(ctx context.Context, params SendVideo) (Message, error) { @@ -173,6 +185,7 @@ func (api *API) SendVideoWithContext(ctx context.Context, params SendVideo) (Mes } // SendAnimation holds parameters for the sendAnimation method. +// Since: Bot API 4.0 // See https://core.telegram.org/bots/api#sendanimation type SendAnimation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -202,6 +215,7 @@ type SendAnimation struct { } // SendAnimation sends an animation file (GIF or H.264/MPEG-4 AVC video without sound). +// Since: Bot API 4.0 // See https://core.telegram.org/bots/api#sendanimation func (api *API) SendAnimation(params SendAnimation) (Message, error) { req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID) @@ -209,6 +223,7 @@ func (api *API) SendAnimation(params SendAnimation) (Message, error) { } // SendAnimationWithContext is the context-aware variant of SendAnimation. +// Since: Bot API 4.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendanimation func (api *API) SendAnimationWithContext(ctx context.Context, params SendAnimation) (Message, error) { @@ -217,6 +232,7 @@ func (api *API) SendAnimationWithContext(ctx context.Context, params SendAnimati } // SendVoice holds parameters for the sendVoice method. +// Since: Bot API 1.2 // See https://core.telegram.org/bots/api#sendvoice type SendVoice struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -240,6 +256,7 @@ type SendVoice struct { } // SendVoice sends a voice note. +// Since: Bot API 1.2 // See https://core.telegram.org/bots/api#sendvoice func (api *API) SendVoice(params SendVoice) (Message, error) { req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) @@ -247,6 +264,7 @@ func (api *API) SendVoice(params SendVoice) (Message, error) { } // SendVoiceWithContext is the context-aware variant of SendVoice. +// Since: Bot API 1.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvoice func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoice) (Message, error) { @@ -255,6 +273,7 @@ func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoice) (Mes } // SendVideoNote holds parameters for the sendVideoNote method. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#sendvideonote type SendVideoNote struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -277,6 +296,7 @@ type SendVideoNote struct { } // SendVideoNote sends a video note (rounded video message). +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#sendvideonote func (api *API) SendVideoNote(params SendVideoNote) (Message, error) { req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID) @@ -284,6 +304,7 @@ func (api *API) SendVideoNote(params SendVideoNote) (Message, error) { } // SendVideoNoteWithContext is the context-aware variant of SendVideoNote. +// Since: Bot API 3.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvideonote func (api *API) SendVideoNoteWithContext(ctx context.Context, params SendVideoNote) (Message, error) { @@ -292,6 +313,7 @@ func (api *API) SendVideoNoteWithContext(ctx context.Context, params SendVideoNo } // SendPaidMedia holds parameters for the sendPaidMedia method. +// Since: Bot API 7.6 // See https://core.telegram.org/bots/api#sendpaidmedia type SendPaidMedia struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -316,6 +338,7 @@ type SendPaidMedia struct { } // SendPaidMedia sends paid media. +// Since: Bot API 7.6 // See https://core.telegram.org/bots/api#sendpaidmedia func (api *API) SendPaidMedia(params SendPaidMedia) (Message, error) { req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID) @@ -323,6 +346,7 @@ func (api *API) SendPaidMedia(params SendPaidMedia) (Message, error) { } // SendPaidMediaWithContext is the context-aware variant of SendPaidMedia. +// Since: Bot API 7.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendpaidmedia func (api *API) SendPaidMediaWithContext(ctx context.Context, params SendPaidMedia) (Message, error) { @@ -331,6 +355,7 @@ func (api *API) SendPaidMediaWithContext(ctx context.Context, params SendPaidMed } // SendMediaGroup holds parameters for the sendMediaGroup method. +// Since: Bot API 3.5 // See https://core.telegram.org/bots/api#sendmediagroup type SendMediaGroup struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -347,6 +372,7 @@ type SendMediaGroup struct { } // SendMediaGroup sends a group of photos, videos, documents or audios as an album. +// Since: Bot API 3.5 // See https://core.telegram.org/bots/api#sendmediagroup func (api *API) SendMediaGroup(params SendMediaGroup) ([]Message, error) { req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID) @@ -354,9 +380,53 @@ func (api *API) SendMediaGroup(params SendMediaGroup) ([]Message, error) { } // SendMediaGroupWithContext is the context-aware variant of SendMediaGroup. +// Since: Bot API 3.5 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendmediagroup func (api *API) SendMediaGroupWithContext(ctx context.Context, params SendMediaGroup) ([]Message, error) { req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID) return req.DoWithContext(ctx, api) } + +// SendLivePhoto holds parameters for the sendLivePhoto method. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#sendlivephoto +type SendLivePhoto struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + + LivePhoto string `json:"live_photo"` + Caption string `json:"caption,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` + + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` + HasSpoiler bool `json:"has_spoiler,omitempty"` + DisableNotification bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + + SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` + ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` + ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` +} + +// SendLivePhoto sends a live photo. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#sendlivephoto +func (api *API) SendLivePhoto(params SendLivePhoto) (Message, error) { + req := NewRequestWithChatID[Message]("sendLivePhoto", params, params.ChatID) + return req.Do(api) +} + +// SendLivePhotoWithContext is the context-aware variant of SendLivePhoto. +// Since: Bot API 10.0 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendlivephoto +func (api *API) SendLivePhotoWithContext(ctx context.Context, params SendLivePhoto) (Message, error) { + req := NewRequestWithChatID[Message]("sendLivePhoto", params, params.ChatID) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/attachments_types.go b/tgapi/attachments_types.go index 02944b5..b1a6c45 100644 --- a/tgapi/attachments_types.go +++ b/tgapi/attachments_types.go @@ -1,5 +1,7 @@ package tgapi +// Animation represents an animation file (GIF or H.264/MPEG-4 AVC without sound). +// Since: Bot API 4.0 type Animation struct { FileID string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` @@ -14,6 +16,7 @@ type Animation struct { } // Audio represents an audio file to be treated as music by the Telegram clients. +// Since: Bot API 1.2 // See https://core.telegram.org/bots/api#audio type Audio struct { FileID string `json:"file_id"` @@ -22,12 +25,14 @@ type Audio struct { Performer string `json:"performer,omitempty"` Title string `json:"title,omitempty"` - FileName string `json:"file_name,omitempty"` + FileName string `json:"file_name,omitempty"` // Since: Bot API 5.0 MimeType string `json:"mime_type,omitempty"` FileSize int64 `json:"file_size,omitempty"` Thumbnail *PhotoSize `json:"thumbnail,omitempty"` } +// Document represents a general file (as opposed to photos, voice messages and audio files). +// Since: Bot API 1.0 type Document struct { FileID string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` @@ -38,11 +43,14 @@ type Document struct { } // Story represents a story. +// Since: Bot API 6.8 type Story struct { Chat Chat `json:"chat"` ID int `json:"id"` } +// Video represents a video file. +// Since: Bot API 1.0 type Video struct { FileID string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` @@ -51,15 +59,16 @@ type Video struct { Duration int `json:"duration"` Thumbnail *PhotoSize `json:"thumbnail,omitempty"` - Cover []PhotoSize `json:"cover,omitempty"` - StartTimestamp int64 `json:"start_timestamp"` - Qualities []VideoQuality `json:"qualities,omitempty"` + Cover []PhotoSize `json:"cover,omitempty"` // Since: Bot API 8.3 + StartTimestamp int64 `json:"start_timestamp"` // Since: Bot API 8.3 + Qualities []VideoQuality `json:"qualities,omitempty"` // Since: Bot API 9.4 FileName string `json:"file_name,omitempty"` MimeType string `json:"mime_type,omitempty"` FileSize int64 `json:"file_size,omitempty"` } // VideoQuality describes an alternative quality for a video. +// Since: Bot API 9.4 // See https://core.telegram.org/bots/api#videoquality type VideoQuality struct { FileID string `json:"file_id"` @@ -70,6 +79,8 @@ type VideoQuality struct { FileSize int64 `json:"file_size,omitempty"` } +// VideoNote represents a video message. +// Since: Bot API 3.0 type VideoNote struct { FileID string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` @@ -79,6 +90,8 @@ type VideoNote struct { FileSize int64 `json:"file_size,omitempty"` } +// Voice represents a voice note. +// Since: Bot API 1.2 type Voice struct { FileID string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` @@ -87,18 +100,26 @@ type Voice struct { FileSize int `json:"file_size,omitempty"` } +// PaidMediaInfo describes paid media. +// Since: Bot API 7.6 type PaidMediaInfo struct { StarCount int `json:"star_count"` PaidMedia []PaidMedia `json:"paid_media"` } + +// PaidMediaType represents the type of paid media. +// Since: Bot API 7.6 type PaidMediaType string const ( - PaidMediaPreviewType PaidMediaType = "preview" - PaidMediaPhotoType PaidMediaType = "photo" - PaidMediaVideoType PaidMediaType = "video" + PaidMediaPreviewType PaidMediaType = "preview" + PaidMediaPhotoType PaidMediaType = "photo" + PaidMediaVideoType PaidMediaType = "video" + PaidMediaLivePhotoType PaidMediaType = "live_photo" // Since: Bot API 10.0 ) +// PaidMedia describes paid media content. +// Since: Bot API 7.6 type PaidMedia struct { Type PaidMediaType `json:"type,omitempty"` @@ -108,9 +129,12 @@ type PaidMedia struct { Photo []PhotoSize `json:"photo,omitempty"` - Video *Video `json:"video,omitempty"` + Video *Video `json:"video,omitempty"` + LivePhoto *LivePhoto `json:"live_photo,omitempty"` // Since: Bot API 10.0 } +// Contact represents a phone contact. +// Since: Bot API 1.0 type Contact struct { PhoneNumber string `json:"phone_number"` FirstName string `json:"first_name"` @@ -119,32 +143,56 @@ type Contact struct { Vcard string `json:"vcard,omitempty"` } +// Dice represents an animated emoji with a random value. +// Since: Bot API 4.7 type Dice struct { Emoji string `json:"emoji"` Value int `json:"value"` } // PollOption contains information about one answer option in a poll. +// Since: Bot API 4.2 // See https://core.telegram.org/bots/api#polloption type PollOption struct { - PersistentID string `json:"persistent_id"` + PersistentID string `json:"persistent_id"` // Since: Bot API 9.6 Text string `json:"text"` TextEntities []MessageEntity `json:"text_entities"` + Media *PollMedia `json:"media,omitempty"` // Since: Bot API 10.0 VoterCount int `json:"voter_count"` - AddedByUser *User `json:"added_by_user,omitempty"` - AddedByChat *Chat `json:"added_by_chat,omitempty"` - AdditionDate int `json:"addition_date,omitempty"` + AddedByUser *User `json:"added_by_user,omitempty"` // Since: Bot API 9.6 + AddedByChat *Chat `json:"added_by_chat,omitempty"` // Since: Bot API 9.6 + AdditionDate int `json:"addition_date,omitempty"` // Since: Bot API 9.6 +} + +// InputPollOptionMedia describes the media to attach to a poll option. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#inputpolloptionmedia +type InputPollOptionMedia struct { + Type string `json:"type"` + Media string `json:"media"` } // InputPollOption contains information about one answer option in a poll to be sent. +// Since: Bot API 7.3 // See https://core.telegram.org/bots/api#inputpolloption type InputPollOption struct { - Text string `json:"text"` - TextParseMode ParseMode `json:"text_parse_mode,omitempty"` - TextEntities []MessageEntity `json:"text_entities,omitempty"` + Text string `json:"text"` + TextParseMode ParseMode `json:"text_parse_mode,omitempty"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` + Media *InputPollOptionMedia `json:"media,omitempty"` // Since: Bot API 10.0 } +// InputPollMedia describes the media to attach to a poll or its explanation. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#inputpollmedia +type InputPollMedia struct { + Type string `json:"type"` + Media string `json:"media"` +} + +// PollOptionAdded describes a service message about a poll option being added. +// Since: Bot API 9.6 type PollOptionAdded struct { PollMessage *InaccessibleMessage `json:"poll_message,omitempty"` OptionPersistentID string `json:"option_persistent_id"` @@ -152,6 +200,8 @@ type PollOptionAdded struct { OptionTextEntities []MessageEntity `json:"option_text_entities,omitempty"` } +// PollOptionDeleted describes a service message about a poll option being deleted. +// Since: Bot API 9.6 type PollOptionDeleted struct { PollMessage *InaccessibleMessage `json:"poll_message,omitempty"` OptionPersistentID string `json:"option_persistent_id"` @@ -170,38 +220,60 @@ const ( ) // PollAnswer represents an answer of a user in a poll. +// Since: Bot API 4.6 // See https://core.telegram.org/bots/api#pollanswer type PollAnswer struct { PollID string `json:"poll_id"` - VoterChat Chat `json:"voter_chat"` + VoterChat Chat `json:"voter_chat"` // Since: Bot API 6.8 User User `json:"user"` OptionIDs []int `json:"option_ids"` - OptionPersistentIDs []string `json:"option_persistent_ids"` + OptionPersistentIDs []string `json:"option_persistent_ids"` // Since: Bot API 9.6 } // Poll contains information about a poll. +// Since: Bot API 4.2 // See https://core.telegram.org/bots/api#poll type Poll struct { ID string `json:"id"` Question string `json:"question"` - QuestionEntities []MessageEntity `json:"question_entities"` + QuestionEntities []MessageEntity `json:"question_entities"` // Since: Bot API 7.3 Options []PollOption `json:"options"` TotalVoterCount int `json:"total_voter_count"` - IsClosed bool `json:"is_closed"` - IsAnonymous bool `json:"is_anonymous"` + IsClosed bool `json:"is_closed,omitempty"` + IsAnonymous bool `json:"is_anonymous,omitempty"` Type PollType `json:"type"` - AllowsMultipleAnswers bool `json:"allows_multiple_answers"` - AllowsRevoting bool `json:"allows_revoting"` - CorrectOptionIDs []int `json:"correct_option_ids,omitempty"` - Explanation string `json:"explanation,omitempty"` - ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"` - OpenPeriod int `json:"open_period,omitempty"` - CloseDate int `json:"close_date,omitempty"` - Description string `json:"description,omitempty"` - DescriptionEntities []MessageEntity `json:"description_entities,omitempty"` + AllowsMultipleAnswers bool `json:"allows_multiple_answers,omitempty"` // Since: Bot API 4.6 + AllowsRevoting bool `json:"allows_revoting,omitempty"` // Since: Bot API 9.6 + MembersOnly bool `json:"members_only,omitempty"` // Since: Bot API 10.0 + CountryCodes []string `json:"country_codes,omitempty"` // Since: Bot API 10.0 + CorrectOptionIDs []int `json:"correct_option_ids,omitempty"` // Since: Bot API 9.6 + Explanation string `json:"explanation,omitempty"` // Since: Bot API 4.8 + ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"` // Since: Bot API 4.8 + ExplanationMedia *PollMedia `json:"explanation_media,omitempty"` // Since: Bot API 10.0 + OpenPeriod int `json:"open_period,omitempty"` // Since: Bot API 4.8 + CloseDate int `json:"close_date,omitempty"` // Since: Bot API 4.8 + Description string `json:"description,omitempty"` // Since: Bot API 9.6 + DescriptionEntities []MessageEntity `json:"description_entities,omitempty"` // Since: Bot API 9.6 + Media *PollMedia `json:"media,omitempty"` // Since: Bot API 10.0 } +// PollMedia represents media attached to a poll. +// Since: Bot API 10.0 +type PollMedia struct { + Animation *Animation `json:"animation,omitempty"` + Audio *Audio `json:"audio,omitempty"` + Document *Document `json:"document,omitempty"` + LivePhoto *LivePhoto `json:"live_photo,omitempty"` + Location *Location `json:"location,omitempty"` + Photo []PhotoSize `json:"photo,omitempty"` + Sticker *Sticker `json:"sticker,omitempty"` + Venue *Venue `json:"venue,omitempty"` + Video *Video `json:"video,omitempty"` +} + +// ChecklistTask represents a single task in a checklist. +// Since: Bot API 9.1 type ChecklistTask struct { ID int `json:"id"` Text string `json:"text"` @@ -211,6 +283,8 @@ type ChecklistTask struct { CompletionDate int `json:"completion_date,omitempty"` } +// Checklist represents a checklist. +// Since: Bot API 9.1 type Checklist struct { Title string `json:"title"` TitleEntities []MessageEntity `json:"title_entities,omitempty"` @@ -220,6 +294,7 @@ type Checklist struct { } // InputChecklistTask describes a task in a checklist. +// Since: Bot API 9.1 type InputChecklistTask struct { ID int `json:"id"` Text string `json:"text"` @@ -228,6 +303,7 @@ type InputChecklistTask struct { } // InputChecklist represents a checklist to be sent. +// Since: Bot API 9.1 type InputChecklist struct { Title string `json:"title"` ParseMode ParseMode `json:"parse_mode,omitempty"` @@ -237,12 +313,16 @@ type InputChecklist struct { OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"` } +// ChecklistTaskDone describes a service message about checklist tasks being marked as done. +// Since: Bot API 9.1 type ChecklistTaskDone struct { ChecklistMessage *Message `json:"checklist_message,omitempty"` MarkedAsDoneTaskIDs []int `json:"marked_as_done_task_ids,omitempty"` MarkedAsNotDoneTaskIDs []int `json:"marked_as_not_done_task_ids,omitempty"` } +// ChecklistTasksAdded describes a service message about new checklist tasks being added. +// Since: Bot API 9.1 type ChecklistTasksAdded struct { ChecklistMessage *Message `json:"checklist_message,omitempty"` Tasks []ChecklistTask `json:"tasks"` @@ -262,10 +342,16 @@ const ( InputMediaTypeVideo InputMediaType = "video" // InputMediaTypeAudio is an audio file. InputMediaTypeAudio InputMediaType = "audio" + + InputMediaTypeSticker InputMediaType = "sticker" + InputMediaTypeLocation InputMediaType = "location" + InputMediaTypeVenue InputMediaType = "venue" + InputMediaTypeLivePhoto InputMediaType = "live_photo" // Since: Bot API 10.0 ) // InputMedia represents the content of a media message to be sent. -// It is a union type described in https://core.telegram.org/bots/api#inputmedia. +// Since: Bot API 4.0 +// See https://core.telegram.org/bots/api#inputmedia type InputMedia struct { Type InputMediaType `json:"type"` Media string `json:"media"` @@ -273,11 +359,11 @@ type InputMedia struct { Caption *string `json:"caption,omitempty"` ParseMode *ParseMode `json:"parse_mode,omitempty"` CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` - ShowCaptionAboveMedia *bool `json:"show_caption_above_media,omitempty"` - HasSpoiler *bool `json:"has_spoiler,omitempty"` + ShowCaptionAboveMedia *bool `json:"show_caption_above_media,omitempty"` // Since: Bot API 7.4 + HasSpoiler *bool `json:"has_spoiler,omitempty"` // Since: Bot API 6.4 - Cover *string `json:"cover"` - StartTimestamp *int `json:"start_timestamp"` + Cover *string `json:"cover"` // Since: Bot API 8.3 + StartTimestamp *int `json:"start_timestamp"` // Since: Bot API 8.3 Width *int `json:"width,omitempty"` Height *int `json:"height,omitempty"` Duration *int `json:"duration,omitempty"` @@ -285,6 +371,18 @@ type InputMedia struct { Performer *string `json:"performer,omitempty"` Title *string `json:"title,omitempty"` + + Emoji *string `json:"emoji,omitempty"` + + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + Address *string `json:"address,omitempty"` + FoursquareID *string `json:"foursquare_id,omitempty"` + FoursquareType *string `json:"foursquare_type,omitempty"` + GooglePlaceID *string `json:"google_place_id,omitempty"` + GooglePlaceType *string `json:"google_place_type,omitempty"` + + HorizontalAccuracy *float64 `json:"horizontal_accuracy,omitempty"` } // InputPaidMediaType represents the type of paid media. @@ -295,16 +393,19 @@ const ( InputPaidMediaTypeVideo InputPaidMediaType = "video" // InputPaidMediaTypePhoto represents a paid photo. InputPaidMediaTypePhoto InputPaidMediaType = "photo" + // InputPaidMediaTypeLivePhoto represents a paid live photo. + InputPaidMediaTypeLivePhoto InputPaidMediaType = "live_photo" // Since: Bot API 10.0 ) // InputPaidMedia describes the paid media to be sent. +// Since: Bot API 7.6 // See https://core.telegram.org/bots/api#inputpaidmedia type InputPaidMedia struct { Type InputPaidMediaType `json:"type"` Media string `json:"media"` - Cover *string `json:"cover,omitempty"` - StartTimestamp *int64 `json:"start_timestamp,omitempty"` + Cover *string `json:"cover,omitempty"` // Since: Bot API 8.3 + StartTimestamp *int64 `json:"start_timestamp,omitempty"` // Since: Bot API 8.3 Width *int `json:"width,omitempty"` Height *int `json:"height,omitempty"` Duration *int `json:"duration,omitempty"` @@ -312,6 +413,7 @@ type InputPaidMedia struct { } // PhotoSize represents one size of a photo or a file/sticker thumbnail. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#photosize type PhotoSize struct { FileID string `json:"file_id"` @@ -320,3 +422,16 @@ type PhotoSize struct { Height int `json:"height"` FileSize int64 `json:"file_size,omitempty"` } + +// LivePhoto represents a live photo (a photo with a short video attached). +// Since: Bot API 10.0 +type LivePhoto struct { + Photo []PhotoSize `json:"photo,omitempty"` + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` + MIMEType string `json:"mime_type,omitempty"` + FileSize int64 `json:"file_size,omitempty"` +} diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index c79fc08..8a23f5c 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // SetMyCommands holds parameters for the setMyCommands method. +// Since: Bot API 4.7 // See https://core.telegram.org/bots/api#setmycommands type SetMyCommands struct { Commands []BotCommand `json:"commands"` @@ -11,6 +12,7 @@ type SetMyCommands struct { } // SetMyCommands changes the list of the bot's commands. +// Since: Bot API 4.7 // Returns true on success. // See https://core.telegram.org/bots/api#setmycommands func (api *API) SetMyCommands(params SetMyCommands) (bool, error) { @@ -19,6 +21,7 @@ func (api *API) SetMyCommands(params SetMyCommands) (bool, error) { } // SetMyCommandsWithContext is the context-aware variant of SetMyCommands. +// Since: Bot API 4.7 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmycommands func (api *API) SetMyCommandsWithContext(ctx context.Context, params SetMyCommands) (bool, error) { @@ -27,6 +30,7 @@ func (api *API) SetMyCommandsWithContext(ctx context.Context, params SetMyComman } // DeleteMyCommands holds parameters for the deleteMyCommands method. +// Since: Bot API 5.3 // See https://core.telegram.org/bots/api#deletemycommands type DeleteMyCommands struct { Scope *BotCommandScope `json:"scope,omitempty"` @@ -34,6 +38,7 @@ type DeleteMyCommands struct { } // DeleteMyCommands deletes the list of the bot's commands for the given scope and user language. +// Since: Bot API 5.3 // Returns true on success. // See https://core.telegram.org/bots/api#deletemycommands func (api *API) DeleteMyCommands(params DeleteMyCommands) (bool, error) { @@ -42,6 +47,7 @@ func (api *API) DeleteMyCommands(params DeleteMyCommands) (bool, error) { } // DeleteMyCommandsWithContext is the context-aware variant of DeleteMyCommands. +// Since: Bot API 5.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletemycommands func (api *API) DeleteMyCommandsWithContext(ctx context.Context, params DeleteMyCommands) (bool, error) { @@ -50,6 +56,7 @@ func (api *API) DeleteMyCommandsWithContext(ctx context.Context, params DeleteMy } // GetMyCommands holds parameters for the getMyCommands method. +// Since: Bot API 4.7 // See https://core.telegram.org/bots/api#getmycommands type GetMyCommands struct { Scope *BotCommandScope `json:"scope,omitempty"` @@ -57,6 +64,7 @@ type GetMyCommands struct { } // GetMyCommands returns the current list of the bot's commands for the given scope and user language. +// Since: Bot API 4.7 // See https://core.telegram.org/bots/api#getmycommands func (api *API) GetMyCommands(params GetMyCommands) ([]BotCommand, error) { req := NewRequest[[]BotCommand]("getMyCommands", params) @@ -64,6 +72,7 @@ func (api *API) GetMyCommands(params GetMyCommands) ([]BotCommand, error) { } // GetMyCommandsWithContext is the context-aware variant of GetMyCommands. +// Since: Bot API 4.7 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getmycommands func (api *API) GetMyCommandsWithContext(ctx context.Context, params GetMyCommands) ([]BotCommand, error) { @@ -72,6 +81,7 @@ func (api *API) GetMyCommandsWithContext(ctx context.Context, params GetMyComman } // SetMyName holds parameters for the setMyName method. +// Since: Bot API 6.7 // See https://core.telegram.org/bots/api#setmyname type SetMyName struct { Name string `json:"name"` @@ -79,6 +89,7 @@ type SetMyName struct { } // SetMyName changes the bot's name. +// Since: Bot API 6.7 // Returns true on success. // See https://core.telegram.org/bots/api#setmyname func (api *API) SetMyName(params SetMyName) (bool, error) { @@ -87,6 +98,7 @@ func (api *API) SetMyName(params SetMyName) (bool, error) { } // SetMyNameWithContext is the context-aware variant of SetMyName. +// Since: Bot API 6.7 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmyname func (api *API) SetMyNameWithContext(ctx context.Context, params SetMyName) (bool, error) { @@ -95,12 +107,14 @@ func (api *API) SetMyNameWithContext(ctx context.Context, params SetMyName) (boo } // GetMyName holds parameters for the getMyName method. +// Since: Bot API 6.7 // See https://core.telegram.org/bots/api#getmyname type GetMyName struct { Language string `json:"language_code,omitempty"` } // GetMyName returns the bot's name for the given language. +// Since: Bot API 6.7 // See https://core.telegram.org/bots/api#getmyname func (api *API) GetMyName(params GetMyName) (BotName, error) { req := NewRequest[BotName]("getMyName", params) @@ -108,6 +122,7 @@ func (api *API) GetMyName(params GetMyName) (BotName, error) { } // GetMyNameWithContext is the context-aware variant of GetMyName. +// Since: Bot API 6.7 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getmyname func (api *API) GetMyNameWithContext(ctx context.Context, params GetMyName) (BotName, error) { @@ -116,6 +131,7 @@ func (api *API) GetMyNameWithContext(ctx context.Context, params GetMyName) (Bot } // SetMyDescription holds parameters for the setMyDescription method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#setmydescription type SetMyDescription struct { Description string `json:"description"` @@ -123,6 +139,7 @@ type SetMyDescription struct { } // SetMyDescription changes the bot's description. +// Since: Bot API 6.6 // Returns true on success. // See https://core.telegram.org/bots/api#setmydescription func (api *API) SetMyDescription(params SetMyDescription) (bool, error) { @@ -131,6 +148,7 @@ func (api *API) SetMyDescription(params SetMyDescription) (bool, error) { } // SetMyDescriptionWithContext is the context-aware variant of SetMyDescription. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmydescription func (api *API) SetMyDescriptionWithContext(ctx context.Context, params SetMyDescription) (bool, error) { @@ -139,12 +157,14 @@ func (api *API) SetMyDescriptionWithContext(ctx context.Context, params SetMyDes } // GetMyDescription holds parameters for the getMyDescription method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#getmydescription type GetMyDescription struct { Language string `json:"language_code,omitempty"` } // GetMyDescription returns the bot's description for the given language. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#getmydescription func (api *API) GetMyDescription(params GetMyDescription) (BotDescription, error) { req := NewRequest[BotDescription]("getMyDescription", params) @@ -152,6 +172,7 @@ func (api *API) GetMyDescription(params GetMyDescription) (BotDescription, error } // GetMyDescriptionWithContext is the context-aware variant of GetMyDescription. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getmydescription func (api *API) GetMyDescriptionWithContext(ctx context.Context, params GetMyDescription) (BotDescription, error) { @@ -160,6 +181,7 @@ func (api *API) GetMyDescriptionWithContext(ctx context.Context, params GetMyDes } // SetMyShortDescription holds parameters for the setMyShortDescription method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#setmyshortdescription type SetMyShortDescription struct { ShortDescription string `json:"short_description,omitempty"` @@ -167,6 +189,7 @@ type SetMyShortDescription struct { } // SetMyShortDescription changes the bot's short description. +// Since: Bot API 6.6 // Returns true on success. // See https://core.telegram.org/bots/api#setmyshortdescription func (api *API) SetMyShortDescription(params SetMyShortDescription) (bool, error) { @@ -175,6 +198,7 @@ func (api *API) SetMyShortDescription(params SetMyShortDescription) (bool, error } // SetMyShortDescriptionWithContext is the context-aware variant of SetMyShortDescription. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmyshortdescription func (api *API) SetMyShortDescriptionWithContext(ctx context.Context, params SetMyShortDescription) (bool, error) { @@ -183,12 +207,14 @@ func (api *API) SetMyShortDescriptionWithContext(ctx context.Context, params Set } // GetMyShortDescription holds parameters for the getMyShortDescription method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#getmyshortdescription type GetMyShortDescription struct { Language string `json:"language_code,omitempty"` } // GetMyShortDescription returns the bot's short description for the given language. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#getmyshortdescription func (api *API) GetMyShortDescription(params GetMyShortDescription) (BotShortDescription, error) { req := NewRequest[BotShortDescription]("getMyShortDescription", params) @@ -196,6 +222,7 @@ func (api *API) GetMyShortDescription(params GetMyShortDescription) (BotShortDes } // GetMyShortDescriptionWithContext is the context-aware variant of GetMyShortDescription. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getmyshortdescription func (api *API) GetMyShortDescriptionWithContext(ctx context.Context, params GetMyShortDescription) (BotShortDescription, error) { @@ -204,12 +231,14 @@ func (api *API) GetMyShortDescriptionWithContext(ctx context.Context, params Get } // SetMyProfilePhoto holds parameters for the setMyProfilePhoto method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#setmyprofilephoto type SetMyProfilePhoto struct { Photo InputProfilePhoto `json:"photo"` } // SetMyProfilePhoto changes the bot's profile photo. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#setmyprofilephoto func (api *API) SetMyProfilePhoto(params SetMyProfilePhoto) (bool, error) { @@ -218,6 +247,7 @@ func (api *API) SetMyProfilePhoto(params SetMyProfilePhoto) (bool, error) { } // SetMyProfilePhotoWithContext is the context-aware variant of SetMyProfilePhoto. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmyprofilephoto func (api *API) SetMyProfilePhotoWithContext(ctx context.Context, params SetMyProfilePhoto) (bool, error) { @@ -226,6 +256,7 @@ func (api *API) SetMyProfilePhotoWithContext(ctx context.Context, params SetMyPr } // RemoveMyProfilePhoto removes the bot's profile photo. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#removemyprofilephoto func (api *API) RemoveMyProfilePhoto() (bool, error) { @@ -234,6 +265,7 @@ func (api *API) RemoveMyProfilePhoto() (bool, error) { } // RemoveMyProfilePhotoWithContext is the context-aware variant of RemoveMyProfilePhoto. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#removemyprofilephoto func (api *API) RemoveMyProfilePhotoWithContext(ctx context.Context) (bool, error) { @@ -242,6 +274,7 @@ func (api *API) RemoveMyProfilePhotoWithContext(ctx context.Context) (bool, erro } // SetChatMenuButton holds parameters for the setChatMenuButton method. +// Since: Bot API 6.0 // See https://core.telegram.org/bots/api#setchatmenubutton type SetChatMenuButton struct { ChatID int64 `json:"chat_id,omitempty"` @@ -249,6 +282,7 @@ type SetChatMenuButton struct { } // SetChatMenuButton changes the menu button for a given chat or the default menu button. +// Since: Bot API 6.0 // Returns true on success. // See https://core.telegram.org/bots/api#setchatmenubutton func (api *API) SetChatMenuButton(params SetChatMenuButton) (bool, error) { @@ -257,6 +291,7 @@ func (api *API) SetChatMenuButton(params SetChatMenuButton) (bool, error) { } // SetChatMenuButtonWithContext is the context-aware variant of SetChatMenuButton. +// Since: Bot API 6.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatmenubutton func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChatMenuButton) (bool, error) { @@ -265,12 +300,14 @@ func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChat } // GetChatMenuButton holds parameters for the getChatMenuButton method. +// Since: Bot API 6.0 // See https://core.telegram.org/bots/api#getchatmenubutton type GetChatMenuButton struct { ChatID int64 `json:"chat_id,omitempty"` } // GetChatMenuButton returns the current menu button for the given chat. +// Since: Bot API 6.0 // See https://core.telegram.org/bots/api#getchatmenubutton func (api *API) GetChatMenuButton(params GetChatMenuButton) (MenuButton, error) { req := NewRequest[MenuButton]("getChatMenuButton", params) @@ -278,6 +315,7 @@ func (api *API) GetChatMenuButton(params GetChatMenuButton) (MenuButton, error) } // GetChatMenuButtonWithContext is the context-aware variant of GetChatMenuButton. +// Since: Bot API 6.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatmenubutton func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButton) (MenuButton, error) { @@ -286,6 +324,7 @@ func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChat } // SetMyDefaultAdministratorRights holds parameters for the setMyDefaultAdministratorRights method. +// Since: Bot API 6.0 // See https://core.telegram.org/bots/api#setmydefaultadministratorrights type SetMyDefaultAdministratorRights struct { Rights *ChatAdministratorRights `json:"rights"` @@ -293,6 +332,7 @@ type SetMyDefaultAdministratorRights struct { } // SetMyDefaultAdministratorRights changes the default administrator rights for the bot. +// Since: Bot API 6.0 // Returns true on success. // See https://core.telegram.org/bots/api#setmydefaultadministratorrights func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministratorRights) (bool, error) { @@ -301,6 +341,7 @@ func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministrator } // SetMyDefaultAdministratorRightsWithContext is the context-aware variant of SetMyDefaultAdministratorRights. +// Since: Bot API 6.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmydefaultadministratorrights func (api *API) SetMyDefaultAdministratorRightsWithContext(ctx context.Context, params SetMyDefaultAdministratorRights) (bool, error) { @@ -309,12 +350,14 @@ func (api *API) SetMyDefaultAdministratorRightsWithContext(ctx context.Context, } // GetMyDefaultAdministratorRights holds parameters for the getMyDefaultAdministratorRights method. +// Since: Bot API 6.0 // See https://core.telegram.org/bots/api#getmydefaultadministratorrights type GetMyDefaultAdministratorRights struct { ForChannels bool `json:"for_channels"` } // GetMyDefaultAdministratorRights returns the current default administrator rights for the bot. +// Since: Bot API 6.0 // See https://core.telegram.org/bots/api#getmydefaultadministratorrights func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministratorRights) (ChatAdministratorRights, error) { req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params) @@ -322,6 +365,7 @@ func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministrator } // GetMyDefaultAdministratorRightsWithContext is the context-aware variant of GetMyDefaultAdministratorRights. +// Since: Bot API 6.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getmydefaultadministratorrights func (api *API) GetMyDefaultAdministratorRightsWithContext(ctx context.Context, params GetMyDefaultAdministratorRights) (ChatAdministratorRights, error) { @@ -330,6 +374,7 @@ func (api *API) GetMyDefaultAdministratorRightsWithContext(ctx context.Context, } // GetAvailableGifts returns the list of gifts that can be sent by the bot. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#getavailablegifts func (api *API) GetAvailableGifts() (Gifts, error) { req := NewRequest[Gifts]("getAvailableGifts", NoParams) @@ -337,6 +382,7 @@ func (api *API) GetAvailableGifts() (Gifts, error) { } // GetAvailableGiftsWithContext is the context-aware variant of GetAvailableGifts. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getavailablegifts func (api *API) GetAvailableGiftsWithContext(ctx context.Context) (Gifts, error) { @@ -345,6 +391,7 @@ func (api *API) GetAvailableGiftsWithContext(ctx context.Context) (Gifts, error) } // SendGift holds parameters for the sendGift method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#sendgift type SendGift struct { UserID int64 `json:"user_id,omitempty"` @@ -357,6 +404,7 @@ type SendGift struct { } // SendGift sends a gift to the given user or chat. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#sendgift func (api *API) SendGift(params SendGift) (bool, error) { @@ -365,6 +413,7 @@ func (api *API) SendGift(params SendGift) (bool, error) { } // SendGiftWithContext is the context-aware variant of SendGift. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendgift func (api *API) SendGiftWithContext(ctx context.Context, params SendGift) (bool, error) { @@ -373,6 +422,7 @@ func (api *API) SendGiftWithContext(ctx context.Context, params SendGift) (bool, } // GiftPremiumSubscription holds parameters for the giftPremiumSubscription method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#giftpremiumsubscription type GiftPremiumSubscription struct { UserID int64 `json:"user_id"` @@ -384,6 +434,7 @@ type GiftPremiumSubscription struct { } // GiftPremiumSubscription gifts a Telegram Premium subscription to the user. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#giftpremiumsubscription func (api *API) GiftPremiumSubscription(params GiftPremiumSubscription) (bool, error) { @@ -392,9 +443,60 @@ func (api *API) GiftPremiumSubscription(params GiftPremiumSubscription) (bool, e } // GiftPremiumSubscriptionWithContext is the context-aware variant of GiftPremiumSubscription. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#giftpremiumsubscription func (api *API) GiftPremiumSubscriptionWithContext(ctx context.Context, params GiftPremiumSubscription) (bool, error) { req := NewRequest[bool]("giftPremiumSubscription", params) return req.DoWithContext(ctx, api) } + +// GetManagedBotAccessSettings holds parameters for the getManagedBotAccessSettings method. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#getmanagedbotaccesssettings +type GetManagedBotAccessSettings struct { + BotUserID int64 `json:"bot_user_id"` +} + +// GetManagedBotAccessSettings returns the access settings of a managed bot. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#getmanagedbotaccesssettings +func (api *API) GetManagedBotAccessSettings(params GetManagedBotAccessSettings) (BotAccessSettings, error) { + req := NewRequest[BotAccessSettings]("getManagedBotAccessSettings", params) + return req.Do(api) +} + +// GetManagedBotAccessSettingsWithContext is the context-aware variant of GetManagedBotAccessSettings. +// Since: Bot API 10.0 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getmanagedbotaccesssettings +func (api *API) GetManagedBotAccessSettingsWithContext(ctx context.Context, params GetManagedBotAccessSettings) (BotAccessSettings, error) { + req := NewRequest[BotAccessSettings]("getManagedBotAccessSettings", params) + return req.DoWithContext(ctx, api) +} + +// SetManagedBotAccessSettings holds parameters for the setManagedBotAccessSettings method. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#setmanagedbotaccesssettings +type SetManagedBotAccessSettings struct { + BotUserID int64 `json:"bot_user_id"` + AccessSettings BotAccessSettings `json:"access_settings"` +} + +// SetManagedBotAccessSettings changes the access settings of a managed bot. +// Since: Bot API 10.0 +// Returns True on success. +// See https://core.telegram.org/bots/api#setmanagedbotaccesssettings +func (api *API) SetManagedBotAccessSettings(params SetManagedBotAccessSettings) (bool, error) { + req := NewRequest[bool]("setManagedBotAccessSettings", params) + return req.Do(api) +} + +// SetManagedBotAccessSettingsWithContext is the context-aware variant of SetManagedBotAccessSettings. +// Since: Bot API 10.0 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#setmanagedbotaccesssettings +func (api *API) SetManagedBotAccessSettingsWithContext(ctx context.Context, params SetManagedBotAccessSettings) (bool, error) { + req := NewRequest[bool]("setManagedBotAccessSettings", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/bot_types.go b/tgapi/bot_types.go index fefd834..c1f6e55 100644 --- a/tgapi/bot_types.go +++ b/tgapi/bot_types.go @@ -1,6 +1,7 @@ package tgapi // BotCommand represents a bot command. +// Since: Bot API 4.7 // See https://core.telegram.org/bots/api#botcommand type BotCommand struct { Command string `json:"command"` @@ -28,6 +29,7 @@ const ( ) // BotCommandScope represents the scope to which bot commands are applied. +// Since: Bot API 5.3 // See https://core.telegram.org/bots/api#botcommandscope type BotCommandScope struct { Type BotCommandScopeType `json:"type"` @@ -36,16 +38,19 @@ type BotCommandScope struct { } // BotName represents the bot's name. +// Since: Bot API 6.7 type BotName struct { Name string `json:"name"` } // BotDescription represents the bot's description. +// Since: Bot API 6.6 type BotDescription struct { Description string `json:"description"` } // BotShortDescription represents the bot's short description. +// Since: Bot API 6.6 type BotShortDescription struct { ShortDescription string `json:"short_description"` } @@ -61,6 +66,7 @@ const ( ) // InputProfilePhoto describes a profile photo to set. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#inputprofilephoto type InputProfilePhoto struct { Type InputProfilePhotoType `json:"type"` @@ -86,6 +92,7 @@ const ( ) // MenuButton represents a menu button. +// Since: Bot API 6.0 // See https://core.telegram.org/bots/api#menubutton type MenuButton struct { Type MenuButtonType `json:"type"` @@ -94,3 +101,10 @@ type MenuButton struct { Text *string `json:"text"` WebApp *WebAppInfo `json:"web_app"` } + +// BotAccessSettings describes access settings of a managed bot. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#botaccesssettings +type BotAccessSettings struct { + AllowAllPrivateChats bool `json:"allow_all_private_chats"` +} diff --git a/tgapi/business_methods.go b/tgapi/business_methods.go index e3f8274..28c39f6 100644 --- a/tgapi/business_methods.go +++ b/tgapi/business_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // VerifyUser holds parameters for the verifyUser method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#verifyuser type VerifyUser struct { UserID int64 `json:"user_id"` @@ -10,6 +11,7 @@ type VerifyUser struct { } // VerifyUser verifies a user. +// Since: Bot API 8.0 // Returns true on success. // See https://core.telegram.org/bots/api#verifyuser func (api *API) VerifyUser(params VerifyUser) (bool, error) { @@ -18,6 +20,7 @@ func (api *API) VerifyUser(params VerifyUser) (bool, error) { } // VerifyUserWithContext is the context-aware variant of VerifyUser. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#verifyuser func (api *API) VerifyUserWithContext(ctx context.Context, params VerifyUser) (bool, error) { @@ -26,6 +29,7 @@ func (api *API) VerifyUserWithContext(ctx context.Context, params VerifyUser) (b } // VerifyChat holds parameters for the verifyChat method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#verifychat type VerifyChat struct { ChatID int64 `json:"chat_id"` @@ -33,6 +37,7 @@ type VerifyChat struct { } // VerifyChat verifies a chat. +// Since: Bot API 8.0 // Returns true on success. // See https://core.telegram.org/bots/api#verifychat func (api *API) VerifyChat(params VerifyChat) (bool, error) { @@ -41,6 +46,7 @@ func (api *API) VerifyChat(params VerifyChat) (bool, error) { } // VerifyChatWithContext is the context-aware variant of VerifyChat. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#verifychat func (api *API) VerifyChatWithContext(ctx context.Context, params VerifyChat) (bool, error) { @@ -49,12 +55,14 @@ func (api *API) VerifyChatWithContext(ctx context.Context, params VerifyChat) (b } // RemoveUserVerification holds parameters for the removeUserVerification method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#removeuserverification type RemoveUserVerification struct { UserID int64 `json:"user_id"` } // RemoveUserVerification removes a user's verification. +// Since: Bot API 8.0 // Returns true on success. // See https://core.telegram.org/bots/api#removeuserverification func (api *API) RemoveUserVerification(params RemoveUserVerification) (bool, error) { @@ -63,6 +71,7 @@ func (api *API) RemoveUserVerification(params RemoveUserVerification) (bool, err } // RemoveUserVerificationWithContext is the context-aware variant of RemoveUserVerification. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#removeuserverification func (api *API) RemoveUserVerificationWithContext(ctx context.Context, params RemoveUserVerification) (bool, error) { @@ -71,12 +80,14 @@ func (api *API) RemoveUserVerificationWithContext(ctx context.Context, params Re } // RemoveChatVerification holds parameters for the removeChatVerification method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#removechatverification type RemoveChatVerification struct { ChatID int64 `json:"chat_id"` } // RemoveChatVerification removes a chat's verification. +// Since: Bot API 8.0 // Returns true on success. // See https://core.telegram.org/bots/api#removechatverification func (api *API) RemoveChatVerification(params RemoveChatVerification) (bool, error) { @@ -85,6 +96,7 @@ func (api *API) RemoveChatVerification(params RemoveChatVerification) (bool, err } // RemoveChatVerificationWithContext is the context-aware variant of RemoveChatVerification. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#removechatverification func (api *API) RemoveChatVerificationWithContext(ctx context.Context, params RemoveChatVerification) (bool, error) { @@ -93,6 +105,7 @@ func (api *API) RemoveChatVerificationWithContext(ctx context.Context, params Re } // ReadBusinessMessage holds parameters for the readBusinessMessage method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#readbusinessmessage type ReadBusinessMessage struct { BusinessConnectionID string `json:"business_connection_id"` @@ -101,6 +114,7 @@ type ReadBusinessMessage struct { } // ReadBusinessMessage marks a business message as read. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#readbusinessmessage func (api *API) ReadBusinessMessage(params ReadBusinessMessage) (bool, error) { @@ -109,6 +123,7 @@ func (api *API) ReadBusinessMessage(params ReadBusinessMessage) (bool, error) { } // ReadBusinessMessageWithContext is the context-aware variant of ReadBusinessMessage. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#readbusinessmessage func (api *API) ReadBusinessMessageWithContext(ctx context.Context, params ReadBusinessMessage) (bool, error) { @@ -117,12 +132,14 @@ func (api *API) ReadBusinessMessageWithContext(ctx context.Context, params ReadB } // GetBusinessConnection holds parameters for the getBusinessConnection method. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#getbusinessconnection type GetBusinessConnection struct { BusinessConnectionID string `json:"business_connection_id"` } // GetBusinessConnection returns information about a business connection. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#getbusinessconnection func (api *API) GetBusinessConnection(params GetBusinessConnection) (BusinessConnection, error) { req := NewRequest[BusinessConnection]("getBusinessConnection", params) @@ -130,6 +147,7 @@ func (api *API) GetBusinessConnection(params GetBusinessConnection) (BusinessCon } // GetBusinessConnectionWithContext is the context-aware variant of GetBusinessConnection. +// Since: Bot API 7.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getbusinessconnection func (api *API) GetBusinessConnectionWithContext(ctx context.Context, params GetBusinessConnection) (BusinessConnection, error) { @@ -138,6 +156,7 @@ func (api *API) GetBusinessConnectionWithContext(ctx context.Context, params Get } // DeleteBusinessMessages holds parameters for the deleteBusinessMessages method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#deletebusinessmessages type DeleteBusinessMessages struct { BusinessConnectionID string `json:"business_connection_id"` @@ -145,6 +164,7 @@ type DeleteBusinessMessages struct { } // DeleteBusinessMessages deletes business messages. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#deletebusinessmessages func (api *API) DeleteBusinessMessages(params DeleteBusinessMessages) (bool, error) { @@ -153,6 +173,7 @@ func (api *API) DeleteBusinessMessages(params DeleteBusinessMessages) (bool, err } // DeleteBusinessMessagesWithContext is the context-aware variant of DeleteBusinessMessages. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletebusinessmessages func (api *API) DeleteBusinessMessagesWithContext(ctx context.Context, params DeleteBusinessMessages) (bool, error) { @@ -161,6 +182,7 @@ func (api *API) DeleteBusinessMessagesWithContext(ctx context.Context, params De } // SetBusinessAccountName holds parameters for the setBusinessAccountName method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#setbusinessaccountname type SetBusinessAccountName struct { BusinessConnectionID string `json:"business_connection_id"` @@ -169,6 +191,7 @@ type SetBusinessAccountName struct { } // SetBusinessAccountName sets the first and last name of a business account. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#setbusinessaccountname func (api *API) SetBusinessAccountName(params SetBusinessAccountName) (bool, error) { @@ -177,6 +200,7 @@ func (api *API) SetBusinessAccountName(params SetBusinessAccountName) (bool, err } // SetBusinessAccountNameWithContext is the context-aware variant of SetBusinessAccountName. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setbusinessaccountname func (api *API) SetBusinessAccountNameWithContext(ctx context.Context, params SetBusinessAccountName) (bool, error) { @@ -185,6 +209,7 @@ func (api *API) SetBusinessAccountNameWithContext(ctx context.Context, params Se } // SetBusinessAccountUsername holds parameters for the setBusinessAccountUsername method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#setbusinessaccountusername type SetBusinessAccountUsername struct { BusinessConnectionID string `json:"business_connection_id"` @@ -192,6 +217,7 @@ type SetBusinessAccountUsername struct { } // SetBusinessAccountUsername sets the username of a business account. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#setbusinessaccountusername func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsername) (bool, error) { @@ -200,6 +226,7 @@ func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsername) (b } // SetBusinessAccountUsernameWithContext is the context-aware variant of SetBusinessAccountUsername. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setbusinessaccountusername func (api *API) SetBusinessAccountUsernameWithContext(ctx context.Context, params SetBusinessAccountUsername) (bool, error) { @@ -208,6 +235,7 @@ func (api *API) SetBusinessAccountUsernameWithContext(ctx context.Context, param } // SetBusinessAccountBio holds parameters for the setBusinessAccountBio method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#setbusinessaccountbio type SetBusinessAccountBio struct { BusinessConnectionID string `json:"business_connection_id"` @@ -215,6 +243,7 @@ type SetBusinessAccountBio struct { } // SetBusinessAccountBio sets the bio of a business account. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#setbusinessaccountbio func (api *API) SetBusinessAccountBio(params SetBusinessAccountBio) (bool, error) { @@ -223,6 +252,7 @@ func (api *API) SetBusinessAccountBio(params SetBusinessAccountBio) (bool, error } // SetBusinessAccountBioWithContext is the context-aware variant of SetBusinessAccountBio. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setbusinessaccountbio func (api *API) SetBusinessAccountBioWithContext(ctx context.Context, params SetBusinessAccountBio) (bool, error) { @@ -231,6 +261,7 @@ func (api *API) SetBusinessAccountBioWithContext(ctx context.Context, params Set } // SetBusinessAccountProfilePhoto holds parameters for the setBusinessAccountProfilePhoto method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto type SetBusinessAccountProfilePhoto struct { BusinessConnectionID string `json:"business_connection_id"` @@ -239,6 +270,7 @@ type SetBusinessAccountProfilePhoto struct { } // SetBusinessAccountProfilePhoto sets the profile photo of a business account. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto func (api *API) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfilePhoto) (bool, error) { @@ -247,6 +279,7 @@ func (api *API) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfileP } // SetBusinessAccountProfilePhotoWithContext is the context-aware variant of SetBusinessAccountProfilePhoto. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto func (api *API) SetBusinessAccountProfilePhotoWithContext(ctx context.Context, params SetBusinessAccountProfilePhoto) (bool, error) { @@ -255,6 +288,7 @@ func (api *API) SetBusinessAccountProfilePhotoWithContext(ctx context.Context, p } // RemoveBusinessAccountProfilePhoto holds parameters for the removeBusinessAccountProfilePhoto method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto type RemoveBusinessAccountProfilePhoto struct { BusinessConnectionID string `json:"business_connection_id"` @@ -262,6 +296,7 @@ type RemoveBusinessAccountProfilePhoto struct { } // RemoveBusinessAccountProfilePhoto removes the profile photo of a business account. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountProfilePhoto) (bool, error) { @@ -270,6 +305,7 @@ func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountPr } // RemoveBusinessAccountProfilePhotoWithContext is the context-aware variant of RemoveBusinessAccountProfilePhoto. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto func (api *API) RemoveBusinessAccountProfilePhotoWithContext(ctx context.Context, params RemoveBusinessAccountProfilePhoto) (bool, error) { @@ -278,6 +314,7 @@ func (api *API) RemoveBusinessAccountProfilePhotoWithContext(ctx context.Context } // SetBusinessAccountGiftSettings holds parameters for the setBusinessAccountGiftSettings method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings type SetBusinessAccountGiftSettings struct { BusinessConnectionID string `json:"business_connection_id"` @@ -286,6 +323,7 @@ type SetBusinessAccountGiftSettings struct { } // SetBusinessAccountGiftSettings sets gift settings for a business account. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSettings) (bool, error) { @@ -294,6 +332,7 @@ func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSett } // SetBusinessAccountGiftSettingsWithContext is the context-aware variant of SetBusinessAccountGiftSettings. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings func (api *API) SetBusinessAccountGiftSettingsWithContext(ctx context.Context, params SetBusinessAccountGiftSettings) (bool, error) { @@ -302,12 +341,14 @@ func (api *API) SetBusinessAccountGiftSettingsWithContext(ctx context.Context, p } // GetBusinessAccountStarBalance holds parameters for the getBusinessAccountStarBalance method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance type GetBusinessAccountStarBalance struct { BusinessConnectionID string `json:"business_connection_id"` } // GetBusinessAccountStarBalance returns the star balance of a business account. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalance) (StarAmount, error) { req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params) @@ -315,6 +356,7 @@ func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalan } // GetBusinessAccountStarBalanceWithContext is the context-aware variant of GetBusinessAccountStarBalance. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance func (api *API) GetBusinessAccountStarBalanceWithContext(ctx context.Context, params GetBusinessAccountStarBalance) (StarAmount, error) { @@ -323,6 +365,7 @@ func (api *API) GetBusinessAccountStarBalanceWithContext(ctx context.Context, pa } // TransferBusinessAccountStars holds parameters for the transferBusinessAccountStars method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#transferbusinessaccountstars type TransferBusinessAccountStars struct { BusinessConnectionID string `json:"business_connection_id"` @@ -330,6 +373,7 @@ type TransferBusinessAccountStars struct { } // TransferBusinessAccountStars transfers stars from a business account. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#transferbusinessaccountstars func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars) (bool, error) { @@ -338,6 +382,7 @@ func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars } // TransferBusinessAccountStarsWithContext is the context-aware variant of TransferBusinessAccountStars. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#transferbusinessaccountstars func (api *API) TransferBusinessAccountStarsWithContext(ctx context.Context, params TransferBusinessAccountStars) (bool, error) { @@ -346,6 +391,7 @@ func (api *API) TransferBusinessAccountStarsWithContext(ctx context.Context, par } // GetBusinessAccountGifts holds parameters for the getBusinessAccountGifts method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#getbusinessaccountgifts type GetBusinessAccountGifts struct { BusinessConnectionID string `json:"business_connection_id"` @@ -362,6 +408,7 @@ type GetBusinessAccountGifts struct { } // GetBusinessAccountGifts returns gifts owned by a business account. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#getbusinessaccountgifts func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGifts) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params) @@ -369,6 +416,7 @@ func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGifts) (OwnedGi } // GetBusinessAccountGiftsWithContext is the context-aware variant of GetBusinessAccountGifts. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getbusinessaccountgifts func (api *API) GetBusinessAccountGiftsWithContext(ctx context.Context, params GetBusinessAccountGifts) (OwnedGifts, error) { @@ -377,6 +425,7 @@ func (api *API) GetBusinessAccountGiftsWithContext(ctx context.Context, params G } // ConvertGiftToStars holds parameters for the convertGiftToStars method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#convertgifttostars type ConvertGiftToStars struct { BusinessConnectionID string `json:"business_connection_id"` @@ -384,6 +433,7 @@ type ConvertGiftToStars struct { } // ConvertGiftToStars converts a gift to Telegram Stars. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#convertgifttostars func (api *API) ConvertGiftToStars(params ConvertGiftToStars) (bool, error) { @@ -392,6 +442,7 @@ func (api *API) ConvertGiftToStars(params ConvertGiftToStars) (bool, error) { } // ConvertGiftToStarsWithContext is the context-aware variant of ConvertGiftToStars. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#convertgifttostars func (api *API) ConvertGiftToStarsWithContext(ctx context.Context, params ConvertGiftToStars) (bool, error) { @@ -400,6 +451,7 @@ func (api *API) ConvertGiftToStarsWithContext(ctx context.Context, params Conver } // UpgradeGift holds parameters for the upgradeGift method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#upgradegift type UpgradeGift struct { BusinessConnectionID string `json:"business_connection_id"` @@ -409,6 +461,7 @@ type UpgradeGift struct { } // UpgradeGift upgrades a gift. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#upgradegift func (api *API) UpgradeGift(params UpgradeGift) (bool, error) { @@ -417,6 +470,7 @@ func (api *API) UpgradeGift(params UpgradeGift) (bool, error) { } // UpgradeGiftWithContext is the context-aware variant of UpgradeGift. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#upgradegift func (api *API) UpgradeGiftWithContext(ctx context.Context, params UpgradeGift) (bool, error) { @@ -425,6 +479,7 @@ func (api *API) UpgradeGiftWithContext(ctx context.Context, params UpgradeGift) } // TransferGift holds parameters for the transferGift method. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#transfergift type TransferGift struct { BusinessConnectionID string `json:"business_connection_id"` @@ -434,6 +489,7 @@ type TransferGift struct { } // TransferGift transfers a gift to another chat. +// Since: Bot API 9.0 // Returns true on success. // See https://core.telegram.org/bots/api#transfergift func (api *API) TransferGift(params TransferGift) (bool, error) { @@ -442,6 +498,7 @@ func (api *API) TransferGift(params TransferGift) (bool, error) { } // TransferGiftWithContext is the context-aware variant of TransferGift. +// Since: Bot API 9.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#transfergift func (api *API) TransferGiftWithContext(ctx context.Context, params TransferGift) (bool, error) { @@ -450,6 +507,7 @@ func (api *API) TransferGiftWithContext(ctx context.Context, params TransferGift } // PostStory holds parameters for the postStory method. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#poststory type PostStory struct { BusinessConnectionID string `json:"business_connection_id"` @@ -466,13 +524,15 @@ type PostStory struct { } // PostStory posts a story with a photo. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#poststory func (api *API) PostStory(params PostStory) (Story, error) { req := NewRequest[Story]("postStory", params) return req.Do(api) } -// PostStoryWithContext is the context-aware variant of PostStoryPhoto. +// PostStoryWithContext is the context-aware variant of PostStory. +// Since: Bot API 7.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#poststory func (api *API) PostStoryWithContext(ctx context.Context, params PostStory) (Story, error) { @@ -481,6 +541,7 @@ func (api *API) PostStoryWithContext(ctx context.Context, params PostStory) (Sto } // RepostStory holds parameters for the repostStory method. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#repoststory type RepostStory struct { BusinessConnectionID string `json:"business_connection_id"` @@ -492,6 +553,7 @@ type RepostStory struct { } // RepostStory reposts a story from another chat. +// Since: Bot API 7.2 // Returns the reposted story. // See https://core.telegram.org/bots/api#repoststory func (api *API) RepostStory(params RepostStory) (Story, error) { @@ -500,6 +562,7 @@ func (api *API) RepostStory(params RepostStory) (Story, error) { } // RepostStoryWithContext is the context-aware variant of RepostStory. +// Since: Bot API 7.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#repoststory func (api *API) RepostStoryWithContext(ctx context.Context, params RepostStory) (Story, error) { @@ -508,6 +571,7 @@ func (api *API) RepostStoryWithContext(ctx context.Context, params RepostStory) } // EditStory holds parameters for the editStory method. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#editstory type EditStory struct { BusinessConnectionID string `json:"business_connection_id"` @@ -521,6 +585,7 @@ type EditStory struct { } // EditStory edits an existing story. +// Since: Bot API 7.2 // Returns the updated story. // See https://core.telegram.org/bots/api#editstory func (api *API) EditStory(params EditStory) (Story, error) { @@ -529,6 +594,7 @@ func (api *API) EditStory(params EditStory) (Story, error) { } // EditStoryWithContext is the context-aware variant of EditStory. +// Since: Bot API 7.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editstory func (api *API) EditStoryWithContext(ctx context.Context, params EditStory) (Story, error) { @@ -537,6 +603,7 @@ func (api *API) EditStoryWithContext(ctx context.Context, params EditStory) (Sto } // DeleteStory holds parameters for the deleteStory method. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#deletestory type DeleteStory struct { BusinessConnectionID string `json:"business_connection_id"` @@ -544,6 +611,7 @@ type DeleteStory struct { } // DeleteStory deletes a story. +// Since: Bot API 7.2 // Returns true on success. // See https://core.telegram.org/bots/api#deletestory func (api *API) DeleteStory(params DeleteStory) (bool, error) { @@ -552,6 +620,7 @@ func (api *API) DeleteStory(params DeleteStory) (bool, error) { } // DeleteStoryWithContext is the context-aware variant of DeleteStory. +// Since: Bot API 7.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletestory func (api *API) DeleteStoryWithContext(ctx context.Context, params DeleteStory) (bool, error) { diff --git a/tgapi/business_types.go b/tgapi/business_types.go index e667413..3f896ce 100644 --- a/tgapi/business_types.go +++ b/tgapi/business_types.go @@ -1,6 +1,7 @@ package tgapi // BusinessIntro contains information about the business intro. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#businessintro type BusinessIntro struct { Title string `json:"title,omitempty"` @@ -9,6 +10,7 @@ type BusinessIntro struct { } // BusinessLocation contains information about the business location. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#businesslocation type BusinessLocation struct { Address string `json:"address"` @@ -16,6 +18,7 @@ type BusinessLocation struct { } // BusinessOpeningHoursInterval represents an interval of opening hours. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#businessopeninghoursinterval type BusinessOpeningHoursInterval struct { OpeningMinute int `json:"opening_minute"` @@ -23,6 +26,7 @@ type BusinessOpeningHoursInterval struct { } // BusinessOpeningHours represents the opening hours of a business. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#businessopeninghours type BusinessOpeningHours struct { TimeZoneName string `json:"time_zone_name"` @@ -31,6 +35,7 @@ type BusinessOpeningHours struct { // BusinessBotRights represents the rights of a business bot. // All fields are optional booleans that, when present, are always true. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#businessbotrights type BusinessBotRights struct { CanReply *bool `json:"can_reply,omitempty"` @@ -50,6 +55,7 @@ type BusinessBotRights struct { } // BusinessConnection contains information about a business connection. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#businessconnection type BusinessConnection struct { ID string `json:"id"` @@ -61,6 +67,7 @@ type BusinessConnection struct { } // BusinessMessagesDeleted is received when messages are deleted from a connected business account. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#businessmessagesdeleted type BusinessMessagesDeleted struct { BusinessConnectionID string `json:"business_connection_id"` @@ -79,6 +86,7 @@ const ( ) // InputStoryContent represents the content of a story to be posted. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#inputstorycontent type InputStoryContent struct { Type InputStoryContentType `json:"type"` @@ -94,6 +102,7 @@ type InputStoryContent struct { } // StoryAreaPosition describes the position of a clickable area on a story. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#storyareaposition type StoryAreaPosition struct { XPercentage float64 `json:"x_percentage"` @@ -121,7 +130,7 @@ const ( ) // StoryAreaType describes the type of a clickable area on a story. -// Fields should be set according to the Type. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#storyareatype type StoryAreaType struct { Type StoryAreaTypeType `json:"type"` @@ -149,6 +158,7 @@ type StoryAreaType struct { } // StoryArea represents a clickable area on a story. +// Since: Bot API 9.0 // See https://core.telegram.org/bots/api#storyarea type StoryArea struct { Position StoryAreaPosition `json:"position"` diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index 79b9aa6..d718914 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // BanChatMember holds parameters for the banChatMember method. +// Since: Bot API 5.3 // See https://core.telegram.org/bots/api#banchatmember type BanChatMember struct { ChatID int64 `json:"chat_id"` @@ -12,6 +13,7 @@ type BanChatMember struct { } // BanChatMember bans a user in a chat. +// Since: Bot API 5.3 // Returns True on success. // See https://core.telegram.org/bots/api#banchatmember func (api *API) BanChatMember(params BanChatMember) (bool, error) { @@ -20,6 +22,7 @@ func (api *API) BanChatMember(params BanChatMember) (bool, error) { } // BanChatMemberWithContext is the context-aware variant of BanChatMember. +// Since: Bot API 5.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#banchatmember func (api *API) BanChatMemberWithContext(ctx context.Context, params BanChatMember) (bool, error) { @@ -28,6 +31,7 @@ func (api *API) BanChatMemberWithContext(ctx context.Context, params BanChatMemb } // UnbanChatMember holds parameters for the unbanChatMember method. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#unbanchatmember type UnbanChatMember struct { ChatID int64 `json:"chat_id"` @@ -36,6 +40,7 @@ type UnbanChatMember struct { } // UnbanChatMember unbans a previously banned user in a chat. +// Since: Bot API 2.0 // Returns True on success. // See https://core.telegram.org/bots/api#unbanchatmember func (api *API) UnbanChatMember(params UnbanChatMember) (bool, error) { @@ -44,6 +49,7 @@ func (api *API) UnbanChatMember(params UnbanChatMember) (bool, error) { } // UnbanChatMemberWithContext is the context-aware variant of UnbanChatMember. +// Since: Bot API 2.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unbanchatmember func (api *API) UnbanChatMemberWithContext(ctx context.Context, params UnbanChatMember) (bool, error) { @@ -52,6 +58,7 @@ func (api *API) UnbanChatMemberWithContext(ctx context.Context, params UnbanChat } // RestrictChatMember holds parameters for the restrictChatMember method. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#restrictchatmember type RestrictChatMember struct { ChatID int64 `json:"chat_id"` @@ -62,6 +69,7 @@ type RestrictChatMember struct { } // RestrictChatMember restricts a user in a chat. +// Since: Bot API 3.1 // Returns True on success. // See https://core.telegram.org/bots/api#restrictchatmember func (api *API) RestrictChatMember(params RestrictChatMember) (bool, error) { @@ -70,6 +78,7 @@ func (api *API) RestrictChatMember(params RestrictChatMember) (bool, error) { } // RestrictChatMemberWithContext is the context-aware variant of RestrictChatMember. +// Since: Bot API 3.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#restrictchatmember func (api *API) RestrictChatMemberWithContext(ctx context.Context, params RestrictChatMember) (bool, error) { @@ -78,31 +87,33 @@ func (api *API) RestrictChatMemberWithContext(ctx context.Context, params Restri } // PromoteChatMember holds parameters for the promoteChatMember method. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#promotechatmember type PromoteChatMember struct { ChatID int64 `json:"chat_id"` UserID int64 `json:"user_id"` - IsAnonymous bool `json:"is_anonymous,omitempty"` + IsAnonymous bool `json:"is_anonymous,omitempty"` // Since: Bot API 5.1 - CanManageChat bool `json:"can_manage_chat,omitempty"` + CanManageChat bool `json:"can_manage_chat,omitempty"` // Since: Bot API 5.3 CanDeleteMessages bool `json:"can_delete_messages,omitempty"` - CanManageVideoChats bool `json:"can_manage_video_chats,omitempty"` + CanManageVideoChats bool `json:"can_manage_video_chats,omitempty"` // Since: Bot API 6.0 CanRestrictMembers bool `json:"can_restrict_members,omitempty"` CanPromoteMembers bool `json:"can_promote_members,omitempty"` CanChangeInfo bool `json:"can_change_info,omitempty"` CanInviteUsers bool `json:"can_invite_users,omitempty"` - CanPostStories bool `json:"can_post_stories,omitempty"` - CanEditStories bool `json:"can_edit_stories,omitempty"` - CanDeleteStories bool `json:"can_delete_stories,omitempty"` + CanPostStories bool `json:"can_post_stories,omitempty"` // Since: Bot API 6.9 + CanEditStories bool `json:"can_edit_stories,omitempty"` // Since: Bot API 6.9 + CanDeleteStories bool `json:"can_delete_stories,omitempty"` // Since: Bot API 6.9 CanPostMessages bool `json:"can_post_messages,omitempty"` CanEditMessages bool `json:"can_edit_messages,omitempty"` CanPinMessages bool `json:"can_pin_messages,omitempty"` - CanManageTopics bool `json:"can_manage_topics,omitempty"` - CanManageDirectMessages bool `json:"can_manage_direct_messages,omitempty"` - CanManageTags bool `json:"can_manage_tags,omitempty"` + CanManageTopics bool `json:"can_manage_topics,omitempty"` // Since: Bot API 6.3 + CanManageDirectMessages bool `json:"can_manage_direct_messages,omitempty"` // Since: Bot API 9.1 + CanManageTags bool `json:"can_manage_tags,omitempty"` // Since: Bot API 9.5 } // PromoteChatMember promotes or demotes a user in a chat. +// Since: Bot API 3.1 // Returns True on success. // See https://core.telegram.org/bots/api#promotechatmember func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) { @@ -111,6 +122,7 @@ func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) { } // PromoteChatMemberWithContext is the context-aware variant of PromoteChatMember. +// Since: Bot API 3.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#promotechatmember func (api *API) PromoteChatMemberWithContext(ctx context.Context, params PromoteChatMember) (bool, error) { @@ -119,6 +131,7 @@ func (api *API) PromoteChatMemberWithContext(ctx context.Context, params Promote } // SetChatAdministratorCustomTitle holds parameters for the setChatAdministratorCustomTitle method. +// Since: Bot API 5.0 // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle type SetChatAdministratorCustomTitle struct { ChatID int64 `json:"chat_id"` @@ -127,6 +140,7 @@ type SetChatAdministratorCustomTitle struct { } // SetChatAdministratorCustomTitle sets a custom title for an administrator. +// Since: Bot API 5.0 // Returns True on success. // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCustomTitle) (bool, error) { @@ -135,6 +149,7 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto } // SetChatAdministratorCustomTitleWithContext is the context-aware variant of SetChatAdministratorCustomTitle. +// Since: Bot API 5.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle func (api *API) SetChatAdministratorCustomTitleWithContext(ctx context.Context, params SetChatAdministratorCustomTitle) (bool, error) { @@ -143,6 +158,7 @@ func (api *API) SetChatAdministratorCustomTitleWithContext(ctx context.Context, } // SetChatMemberTag holds parameters for the setChatMemberTag method. +// Since: Bot API 9.5 // See https://core.telegram.org/bots/api#setchatmembertag type SetChatMemberTag struct { ChatID int64 `json:"chat_id"` @@ -151,6 +167,7 @@ type SetChatMemberTag struct { } // SetChatMemberTag sets a tag for a chat member. +// Since: Bot API 9.5 // Returns True on success. // See https://core.telegram.org/bots/api#setchatmembertag func (api *API) SetChatMemberTag(params SetChatMemberTag) (bool, error) { @@ -159,6 +176,7 @@ func (api *API) SetChatMemberTag(params SetChatMemberTag) (bool, error) { } // SetChatMemberTagWithContext is the context-aware variant of SetChatMemberTag. +// Since: Bot API 9.5 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatmembertag func (api *API) SetChatMemberTagWithContext(ctx context.Context, params SetChatMemberTag) (bool, error) { @@ -167,6 +185,7 @@ func (api *API) SetChatMemberTagWithContext(ctx context.Context, params SetChatM } // BanChatSenderChat holds parameters for the banChatSenderChat method. +// Since: Bot API 5.6 // See https://core.telegram.org/bots/api#banchatsenderchat type BanChatSenderChat struct { ChatID int64 `json:"chat_id"` @@ -174,6 +193,7 @@ type BanChatSenderChat struct { } // BanChatSenderChat bans a channel chat in a supergroup or channel. +// Since: Bot API 5.6 // Returns True on success. // See https://core.telegram.org/bots/api#banchatsenderchat func (api *API) BanChatSenderChat(params BanChatSenderChat) (bool, error) { @@ -182,6 +202,7 @@ func (api *API) BanChatSenderChat(params BanChatSenderChat) (bool, error) { } // BanChatSenderChatWithContext is the context-aware variant of BanChatSenderChat. +// Since: Bot API 5.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#banchatsenderchat func (api *API) BanChatSenderChatWithContext(ctx context.Context, params BanChatSenderChat) (bool, error) { @@ -190,6 +211,7 @@ func (api *API) BanChatSenderChatWithContext(ctx context.Context, params BanChat } // UnbanChatSenderChat holds parameters for the unbanChatSenderChat method. +// Since: Bot API 5.6 // See https://core.telegram.org/bots/api#unbanchatsenderchat type UnbanChatSenderChat struct { ChatID int64 `json:"chat_id"` @@ -197,6 +219,7 @@ type UnbanChatSenderChat struct { } // UnbanChatSenderChat unbans a previously banned channel chat. +// Since: Bot API 5.6 // Returns True on success. // See https://core.telegram.org/bots/api#unbanchatsenderchat func (api *API) UnbanChatSenderChat(params UnbanChatSenderChat) (bool, error) { @@ -205,6 +228,7 @@ func (api *API) UnbanChatSenderChat(params UnbanChatSenderChat) (bool, error) { } // UnbanChatSenderChatWithContext is the context-aware variant of UnbanChatSenderChat. +// Since: Bot API 5.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unbanchatsenderchat func (api *API) UnbanChatSenderChatWithContext(ctx context.Context, params UnbanChatSenderChat) (bool, error) { @@ -213,6 +237,7 @@ func (api *API) UnbanChatSenderChatWithContext(ctx context.Context, params Unban } // SetChatPermissions holds parameters for the setChatPermissions method. +// Since: Bot API 4.4 // See https://core.telegram.org/bots/api#setchatpermissions type SetChatPermissions struct { ChatID int64 `json:"chat_id"` @@ -221,6 +246,7 @@ type SetChatPermissions struct { } // SetChatPermissions sets default chat permissions for all members. +// Since: Bot API 4.4 // Returns True on success. // See https://core.telegram.org/bots/api#setchatpermissions func (api *API) SetChatPermissions(params SetChatPermissions) (bool, error) { @@ -229,6 +255,7 @@ func (api *API) SetChatPermissions(params SetChatPermissions) (bool, error) { } // SetChatPermissionsWithContext is the context-aware variant of SetChatPermissions. +// Since: Bot API 4.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatpermissions func (api *API) SetChatPermissionsWithContext(ctx context.Context, params SetChatPermissions) (bool, error) { @@ -237,12 +264,14 @@ func (api *API) SetChatPermissionsWithContext(ctx context.Context, params SetCha } // ExportChatInviteLink holds parameters for the exportChatInviteLink method. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#exportchatinvitelink type ExportChatInviteLink struct { ChatID int64 `json:"chat_id"` } // ExportChatInviteLink generates a new primary invite link for a chat. +// Since: Bot API 3.1 // Returns the new invite link as string. // See https://core.telegram.org/bots/api#exportchatinvitelink func (api *API) ExportChatInviteLink(params ExportChatInviteLink) (string, error) { @@ -251,6 +280,7 @@ func (api *API) ExportChatInviteLink(params ExportChatInviteLink) (string, error } // ExportChatInviteLinkWithContext is the context-aware variant of ExportChatInviteLink. +// Since: Bot API 3.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#exportchatinvitelink func (api *API) ExportChatInviteLinkWithContext(ctx context.Context, params ExportChatInviteLink) (string, error) { @@ -259,6 +289,7 @@ func (api *API) ExportChatInviteLinkWithContext(ctx context.Context, params Expo } // CreateChatInviteLink holds parameters for the createChatInviteLink method. +// Since: Bot API 5.1 // See https://core.telegram.org/bots/api#createchatinvitelink type CreateChatInviteLink struct { ChatID int64 `json:"chat_id"` @@ -269,6 +300,7 @@ type CreateChatInviteLink struct { } // CreateChatInviteLink creates an additional invite link for a chat. +// Since: Bot API 5.1 // Returns the created invite link. // See https://core.telegram.org/bots/api#createchatinvitelink func (api *API) CreateChatInviteLink(params CreateChatInviteLink) (ChatInviteLink, error) { @@ -277,6 +309,7 @@ func (api *API) CreateChatInviteLink(params CreateChatInviteLink) (ChatInviteLin } // CreateChatInviteLinkWithContext is the context-aware variant of CreateChatInviteLink. +// Since: Bot API 5.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createchatinvitelink func (api *API) CreateChatInviteLinkWithContext(ctx context.Context, params CreateChatInviteLink) (ChatInviteLink, error) { @@ -285,6 +318,7 @@ func (api *API) CreateChatInviteLinkWithContext(ctx context.Context, params Crea } // EditChatInviteLink holds parameters for the editChatInviteLink method. +// Since: Bot API 5.1 // See https://core.telegram.org/bots/api#editchatinvitelink type EditChatInviteLink struct { ChatID int64 `json:"chat_id"` @@ -297,6 +331,7 @@ type EditChatInviteLink struct { } // EditChatInviteLink edits a non‑primary invite link. +// Since: Bot API 5.1 // Returns the edited invite link. // See https://core.telegram.org/bots/api#editchatinvitelink func (api *API) EditChatInviteLink(params EditChatInviteLink) (ChatInviteLink, error) { @@ -305,6 +340,7 @@ func (api *API) EditChatInviteLink(params EditChatInviteLink) (ChatInviteLink, e } // EditChatInviteLinkWithContext is the context-aware variant of EditChatInviteLink. +// Since: Bot API 5.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editchatinvitelink func (api *API) EditChatInviteLinkWithContext(ctx context.Context, params EditChatInviteLink) (ChatInviteLink, error) { @@ -313,6 +349,7 @@ func (api *API) EditChatInviteLinkWithContext(ctx context.Context, params EditCh } // CreateChatSubscriptionInviteLink holds parameters for the createChatSubscriptionInviteLink method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink type CreateChatSubscriptionInviteLink struct { ChatID int64 `json:"chat_id"` @@ -322,6 +359,7 @@ type CreateChatSubscriptionInviteLink struct { } // CreateChatSubscriptionInviteLink creates a subscription invite link for a channel chat. +// Since: Bot API 8.0 // Returns the created invite link. // See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionInviteLink) (ChatInviteLink, error) { @@ -330,6 +368,7 @@ func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionIn } // CreateChatSubscriptionInviteLinkWithContext is the context-aware variant of CreateChatSubscriptionInviteLink. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink func (api *API) CreateChatSubscriptionInviteLinkWithContext(ctx context.Context, params CreateChatSubscriptionInviteLink) (ChatInviteLink, error) { @@ -338,6 +377,7 @@ func (api *API) CreateChatSubscriptionInviteLinkWithContext(ctx context.Context, } // EditChatSubscriptionInviteLink holds parameters for the editChatSubscriptionInviteLink method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink type EditChatSubscriptionInviteLink struct { ChatID int64 `json:"chat_id"` @@ -346,6 +386,7 @@ type EditChatSubscriptionInviteLink struct { } // EditChatSubscriptionInviteLink edits a subscription invite link. +// Since: Bot API 8.0 // Returns the edited invite link. // See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInviteLink) (ChatInviteLink, error) { @@ -354,6 +395,7 @@ func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInvite } // EditChatSubscriptionInviteLinkWithContext is the context-aware variant of EditChatSubscriptionInviteLink. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink func (api *API) EditChatSubscriptionInviteLinkWithContext(ctx context.Context, params EditChatSubscriptionInviteLink) (ChatInviteLink, error) { @@ -362,6 +404,7 @@ func (api *API) EditChatSubscriptionInviteLinkWithContext(ctx context.Context, p } // RevokeChatInviteLink holds parameters for the revokeChatInviteLink method. +// Since: Bot API 5.1 // See https://core.telegram.org/bots/api#revokechatinvitelink type RevokeChatInviteLink struct { ChatID int64 `json:"chat_id"` @@ -369,6 +412,7 @@ type RevokeChatInviteLink struct { } // RevokeChatInviteLink revokes an invite link. +// Since: Bot API 5.1 // Returns the revoked invite link object. // See https://core.telegram.org/bots/api#revokechatinvitelink func (api *API) RevokeChatInviteLink(params RevokeChatInviteLink) (ChatInviteLink, error) { @@ -377,6 +421,7 @@ func (api *API) RevokeChatInviteLink(params RevokeChatInviteLink) (ChatInviteLin } // RevokeChatInviteLinkWithContext is the context-aware variant of RevokeChatInviteLink. +// Since: Bot API 5.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#revokechatinvitelink func (api *API) RevokeChatInviteLinkWithContext(ctx context.Context, params RevokeChatInviteLink) (ChatInviteLink, error) { @@ -385,6 +430,7 @@ func (api *API) RevokeChatInviteLinkWithContext(ctx context.Context, params Revo } // ApproveChatJoinRequest holds parameters for the approveChatJoinRequest method. +// Since: Bot API 5.4 // See https://core.telegram.org/bots/api#approvechatjoinrequest type ApproveChatJoinRequest struct { ChatID int64 `json:"chat_id"` @@ -392,6 +438,7 @@ type ApproveChatJoinRequest struct { } // ApproveChatJoinRequest approves a chat join request. +// Since: Bot API 5.4 // Returns True on success. // See https://core.telegram.org/bots/api#approvechatjoinrequest func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequest) (bool, error) { @@ -400,6 +447,7 @@ func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequest) (bool, err } // ApproveChatJoinRequestWithContext is the context-aware variant of ApproveChatJoinRequest. +// Since: Bot API 5.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#approvechatjoinrequest func (api *API) ApproveChatJoinRequestWithContext(ctx context.Context, params ApproveChatJoinRequest) (bool, error) { @@ -408,6 +456,7 @@ func (api *API) ApproveChatJoinRequestWithContext(ctx context.Context, params Ap } // DeclineChatJoinRequest holds parameters for the declineChatJoinRequest method. +// Since: Bot API 5.4 // See https://core.telegram.org/bots/api#declinechatjoinrequest type DeclineChatJoinRequest struct { ChatID int64 `json:"chat_id"` @@ -415,6 +464,7 @@ type DeclineChatJoinRequest struct { } // DeclineChatJoinRequest declines a chat join request. +// Since: Bot API 5.4 // Returns True on success. // See https://core.telegram.org/bots/api#declinechatjoinrequest func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequest) (bool, error) { @@ -423,6 +473,7 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequest) (bool, err } // DeclineChatJoinRequestWithContext is the context-aware variant of DeclineChatJoinRequest. +// Since: Bot API 5.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#declinechatjoinrequest func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params DeclineChatJoinRequest) (bool, error) { @@ -431,12 +482,14 @@ func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params De } // SetChatPhoto holds parameters for the setChatPhoto method. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#setchatphoto type SetChatPhoto struct { ChatID int64 `json:"chat_id"` } // SetChatPhoto changes the chat photo. +// Since: Bot API 3.1 // photo is the file to upload as the new photo. // Returns True on success. // See https://core.telegram.org/bots/api#setchatphoto @@ -450,12 +503,14 @@ func (api *API) SetChatPhoto(params SetChatPhoto, photo UploaderFile) (bool, err } // DeleteChatPhoto holds parameters for the deleteChatPhoto method. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#deletechatphoto type DeleteChatPhoto struct { ChatID int64 `json:"chat_id"` } // DeleteChatPhoto deletes a chat photo. +// Since: Bot API 3.1 // Returns True on success. // See https://core.telegram.org/bots/api#deletechatphoto func (api *API) DeleteChatPhoto(params DeleteChatPhoto) (bool, error) { @@ -464,6 +519,7 @@ func (api *API) DeleteChatPhoto(params DeleteChatPhoto) (bool, error) { } // DeleteChatPhotoWithContext is the context-aware variant of DeleteChatPhoto. +// Since: Bot API 3.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletechatphoto func (api *API) DeleteChatPhotoWithContext(ctx context.Context, params DeleteChatPhoto) (bool, error) { @@ -472,6 +528,7 @@ func (api *API) DeleteChatPhotoWithContext(ctx context.Context, params DeleteCha } // SetChatTitle holds parameters for the setChatTitle method. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#setchattitle type SetChatTitle struct { ChatID int64 `json:"chat_id"` @@ -479,6 +536,7 @@ type SetChatTitle struct { } // SetChatTitle changes the chat title. +// Since: Bot API 3.1 // Returns True on success. // See https://core.telegram.org/bots/api#setchattitle func (api *API) SetChatTitle(params SetChatTitle) (bool, error) { @@ -487,6 +545,7 @@ func (api *API) SetChatTitle(params SetChatTitle) (bool, error) { } // SetChatTitleWithContext is the context-aware variant of SetChatTitle. +// Since: Bot API 3.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchattitle func (api *API) SetChatTitleWithContext(ctx context.Context, params SetChatTitle) (bool, error) { @@ -495,6 +554,7 @@ func (api *API) SetChatTitleWithContext(ctx context.Context, params SetChatTitle } // SetChatDescription holds parameters for the setChatDescription method. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#setchatdescription type SetChatDescription struct { ChatID int64 `json:"chat_id"` @@ -502,6 +562,7 @@ type SetChatDescription struct { } // SetChatDescription changes the chat description. +// Since: Bot API 3.1 // Returns True on success. // See https://core.telegram.org/bots/api#setchatdescription func (api *API) SetChatDescription(params SetChatDescription) (bool, error) { @@ -510,6 +571,7 @@ func (api *API) SetChatDescription(params SetChatDescription) (bool, error) { } // SetChatDescriptionWithContext is the context-aware variant of SetChatDescription. +// Since: Bot API 3.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatdescription func (api *API) SetChatDescriptionWithContext(ctx context.Context, params SetChatDescription) (bool, error) { @@ -518,6 +580,7 @@ func (api *API) SetChatDescriptionWithContext(ctx context.Context, params SetCha } // PinChatMessage holds parameters for the pinChatMessage method. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#pinchatmessage type PinChatMessage struct { BusinessConnectionID *string `json:"business_connection_id,omitempty"` @@ -527,6 +590,7 @@ type PinChatMessage struct { } // PinChatMessage pins a message in a chat. +// Since: Bot API 3.1 // Returns True on success. // See https://core.telegram.org/bots/api#pinchatmessage func (api *API) PinChatMessage(params PinChatMessage) (bool, error) { @@ -535,6 +599,7 @@ func (api *API) PinChatMessage(params PinChatMessage) (bool, error) { } // PinChatMessageWithContext is the context-aware variant of PinChatMessage. +// Since: Bot API 3.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#pinchatmessage func (api *API) PinChatMessageWithContext(ctx context.Context, params PinChatMessage) (bool, error) { @@ -543,6 +608,7 @@ func (api *API) PinChatMessageWithContext(ctx context.Context, params PinChatMes } // UnpinChatMessage holds parameters for the unpinChatMessage method. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#unpinchatmessage type UnpinChatMessage struct { BusinessConnectionID *string `json:"business_connection_id,omitempty"` @@ -551,6 +617,7 @@ type UnpinChatMessage struct { } // UnpinChatMessage unpins a message in a chat. +// Since: Bot API 3.1 // Returns True on success. // See https://core.telegram.org/bots/api#unpinchatmessage func (api *API) UnpinChatMessage(params UnpinChatMessage) (bool, error) { @@ -559,6 +626,7 @@ func (api *API) UnpinChatMessage(params UnpinChatMessage) (bool, error) { } // UnpinChatMessageWithContext is the context-aware variant of UnpinChatMessage. +// Since: Bot API 3.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unpinchatmessage func (api *API) UnpinChatMessageWithContext(ctx context.Context, params UnpinChatMessage) (bool, error) { @@ -567,12 +635,14 @@ func (api *API) UnpinChatMessageWithContext(ctx context.Context, params UnpinCha } // UnpinAllChatMessages holds parameters for the unpinAllChatMessages method. +// Since: Bot API 5.0 // See https://core.telegram.org/bots/api#unpinallchatmessages type UnpinAllChatMessages struct { ChatID int64 `json:"chat_id"` } // UnpinAllChatMessages unpins all pinned messages in a chat. +// Since: Bot API 5.0 // Returns True on success. // See https://core.telegram.org/bots/api#unpinallchatmessages func (api *API) UnpinAllChatMessages(params UnpinAllChatMessages) (bool, error) { @@ -581,6 +651,7 @@ func (api *API) UnpinAllChatMessages(params UnpinAllChatMessages) (bool, error) } // UnpinAllChatMessagesWithContext is the context-aware variant of UnpinAllChatMessages. +// Since: Bot API 5.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unpinallchatmessages func (api *API) UnpinAllChatMessagesWithContext(ctx context.Context, params UnpinAllChatMessages) (bool, error) { @@ -589,55 +660,64 @@ func (api *API) UnpinAllChatMessagesWithContext(ctx context.Context, params Unpi } // LeaveChat holds parameters for the leaveChat method. +// Since: Bot API 2.1 // See https://core.telegram.org/bots/api#leavechat type LeaveChat struct { ChatID int64 `json:"chat_id"` } // LeaveChat makes the bot leave a chat. +// Since: Bot API 2.1 // Returns True on success. // See https://core.telegram.org/bots/api#leavechat func (api *API) LeaveChat(params LeaveChat) (bool, error) { - req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name + req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) return req.Do(api) } // LeaveChatWithContext is the context-aware variant of LeaveChat. +// Since: Bot API 2.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#leavechat func (api *API) LeaveChatWithContext(ctx context.Context, params LeaveChat) (bool, error) { - req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name + req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) return req.DoWithContext(ctx, api) } // GetChat holds parameters for the getChat method. +// Since: Bot API 2.1 // See https://core.telegram.org/bots/api#getchat type GetChat struct { ChatID int64 `json:"chat_id"` } // GetChat gets up‑to‑date information about a chat. +// Since: Bot API 2.1 // See https://core.telegram.org/bots/api#getchat func (api *API) GetChat(params GetChat) (ChatFullInfo, error) { - req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name + req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) return req.Do(api) } // GetChatWithContext is the context-aware variant of GetChat. +// Since: Bot API 2.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchat func (api *API) GetChatWithContext(ctx context.Context, params GetChat) (ChatFullInfo, error) { - req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name + req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) return req.DoWithContext(ctx, api) } // GetChatAdministrators holds parameters for the getChatAdministrators method. +// Since: Bot API 2.1 // See https://core.telegram.org/bots/api#getchatadministrators type GetChatAdministrators struct { - ChatID int64 `json:"chat_id"` + ChatID int64 `json:"chat_id"` + ReturnBots bool `json:"return_bots,omitempty"` // Since: Bot API 10.0 } // GetChatAdministrators returns a list of administrators in a chat. +// Since: Bot API 2.1 // See https://core.telegram.org/bots/api#getchatadministrators func (api *API) GetChatAdministrators(params GetChatAdministrators) ([]ChatMember, error) { req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID) @@ -645,6 +725,7 @@ func (api *API) GetChatAdministrators(params GetChatAdministrators) ([]ChatMembe } // GetChatAdministratorsWithContext is the context-aware variant of GetChatAdministrators. +// Since: Bot API 2.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatadministrators func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params GetChatAdministrators) ([]ChatMember, error) { @@ -653,12 +734,14 @@ func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params Get } // GetChatMemberCount holds parameters for the getChatMemberCount method. +// Since: Bot API 2.1 // See https://core.telegram.org/bots/api#getchatmembercount type GetChatMemberCount struct { ChatID int64 `json:"chat_id"` } // GetChatMemberCount returns the number of members in a chat. +// Since: Bot API 2.1 // See https://core.telegram.org/bots/api#getchatmembercount func (api *API) GetChatMemberCount(params GetChatMemberCount) (int, error) { req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID) @@ -666,6 +749,7 @@ func (api *API) GetChatMemberCount(params GetChatMemberCount) (int, error) { } // GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount. +// Since: Bot API 2.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatmembercount func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMemberCount) (int, error) { @@ -674,6 +758,7 @@ func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetCha } // GetChatMember holds parameters for the getChatMember method. +// Since: Bot API 2.1 // See https://core.telegram.org/bots/api#getchatmember type GetChatMember struct { ChatID int64 `json:"chat_id"` @@ -681,6 +766,7 @@ type GetChatMember struct { } // GetChatMember returns information about a member of a chat. +// Since: Bot API 2.1 // See https://core.telegram.org/bots/api#getchatmember func (api *API) GetChatMember(params GetChatMember) (ChatMember, error) { req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID) @@ -688,6 +774,7 @@ func (api *API) GetChatMember(params GetChatMember) (ChatMember, error) { } // GetChatMemberWithContext is the context-aware variant of GetChatMember. +// Since: Bot API 2.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatmember func (api *API) GetChatMemberWithContext(ctx context.Context, params GetChatMember) (ChatMember, error) { @@ -696,6 +783,7 @@ func (api *API) GetChatMemberWithContext(ctx context.Context, params GetChatMemb } // SetChatStickerSet holds parameters for the setChatStickerSet method. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#setchatstickerset type SetChatStickerSet struct { ChatID int64 `json:"chat_id"` @@ -703,6 +791,7 @@ type SetChatStickerSet struct { } // SetChatStickerSet associates a sticker set with a supergroup. +// Since: Bot API 3.2 // Returns True on success. // See https://core.telegram.org/bots/api#setchatstickerset func (api *API) SetChatStickerSet(params SetChatStickerSet) (bool, error) { @@ -711,6 +800,7 @@ func (api *API) SetChatStickerSet(params SetChatStickerSet) (bool, error) { } // SetChatStickerSetWithContext is the context-aware variant of SetChatStickerSet. +// Since: Bot API 3.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatstickerset func (api *API) SetChatStickerSetWithContext(ctx context.Context, params SetChatStickerSet) (bool, error) { @@ -719,12 +809,14 @@ func (api *API) SetChatStickerSetWithContext(ctx context.Context, params SetChat } // DeleteChatStickerSet holds parameters for the deleteChatStickerSet method. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#deletechatstickerset type DeleteChatStickerSet struct { ChatID int64 `json:"chat_id"` } // DeleteChatStickerSet deletes a sticker set from a supergroup. +// Since: Bot API 3.2 // Returns True on success. // See https://core.telegram.org/bots/api#deletechatstickerset func (api *API) DeleteChatStickerSet(params DeleteChatStickerSet) (bool, error) { @@ -733,6 +825,7 @@ func (api *API) DeleteChatStickerSet(params DeleteChatStickerSet) (bool, error) } // DeleteChatStickerSetWithContext is the context-aware variant of DeleteChatStickerSet. +// Since: Bot API 3.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletechatstickerset func (api *API) DeleteChatStickerSetWithContext(ctx context.Context, params DeleteChatStickerSet) (bool, error) { @@ -741,6 +834,7 @@ func (api *API) DeleteChatStickerSetWithContext(ctx context.Context, params Dele } // GetUserChatBoosts holds parameters for the getUserChatBoosts method. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#getuserchatboosts type GetUserChatBoosts struct { ChatID int64 `json:"chat_id"` @@ -748,6 +842,7 @@ type GetUserChatBoosts struct { } // GetUserChatBoosts returns the list of boosts a user has given to a chat. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#getuserchatboosts func (api *API) GetUserChatBoosts(params GetUserChatBoosts) (UserChatBoosts, error) { req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID) @@ -755,6 +850,7 @@ func (api *API) GetUserChatBoosts(params GetUserChatBoosts) (UserChatBoosts, err } // GetUserChatBoostsWithContext is the context-aware variant of GetUserChatBoosts. +// Since: Bot API 7.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getuserchatboosts func (api *API) GetUserChatBoostsWithContext(ctx context.Context, params GetUserChatBoosts) (UserChatBoosts, error) { @@ -763,6 +859,7 @@ func (api *API) GetUserChatBoostsWithContext(ctx context.Context, params GetUser } // GetChatGifts holds parameters for the getChatGifts method. +// Since: Bot API 9.3 // See https://core.telegram.org/bots/api#getchatgifts type GetChatGifts struct { ChatID int64 `json:"chat_id"` @@ -779,6 +876,7 @@ type GetChatGifts struct { } // GetChatGifts returns gifts owned by a chat. +// Since: Bot API 9.3 // See https://core.telegram.org/bots/api#getchatgifts func (api *API) GetChatGifts(params GetChatGifts) (OwnedGifts, error) { req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID) @@ -786,6 +884,7 @@ func (api *API) GetChatGifts(params GetChatGifts) (OwnedGifts, error) { } // GetChatGiftsWithContext is the context-aware variant of GetChatGifts. +// Since: Bot API 9.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getchatgifts func (api *API) GetChatGiftsWithContext(ctx context.Context, params GetChatGifts) (OwnedGifts, error) { diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index a78f684..b929a21 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -1,6 +1,7 @@ package tgapi // Chat represents a chat (private, group, supergroup, channel). +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#chat type Chat struct { ID int64 `json:"id"` @@ -9,8 +10,8 @@ type Chat struct { Username *string `json:"username,omitempty"` FirstName *string `json:"first_name,omitempty"` LastName *string `json:"last_name,omitempty"` - IsForum *bool `json:"is_forum,omitempty"` - IsDirectMessages *bool `json:"is_direct_messages,omitempty"` + IsForum *bool `json:"is_forum,omitempty"` // Since: Bot API 6.3 + IsDirectMessages *bool `json:"is_direct_messages,omitempty"` // Since: Bot API 9.2 } // ChatType represents the type of a chat. @@ -28,6 +29,7 @@ const ( ) // ChatFullInfo contains full information about a chat. +// Since: Bot API 7.5 // See https://core.telegram.org/bots/api#chatfullinfo type ChatFullInfo struct { ID int64 `json:"id"` @@ -49,7 +51,7 @@ type ChatFullInfo struct { BusinessOpeningHours *BusinessOpeningHours `json:"business_opening_hours,omitempty"` PersonalChat *Chat `json:"personal_chat,omitempty"` - ParentChat *Chat `json:"parent_chat,omitempty"` + ParentChat *Chat `json:"parent_chat,omitempty"` // Since: Bot API 9.2 AvailableReaction []ReactionType `json:"available_reaction,omitempty"` @@ -86,12 +88,13 @@ type ChatFullInfo struct { Location *ChatLocation `json:"location,omitempty"` Rating *UserRating `json:"rating,omitempty"` - FirstProfileAudio *Audio `json:"first_profile_audio,omitempty"` - UniqueGiftColors *UniqueGiftColors `json:"unique_gift_colors,omitempty"` - PaidMessageStarCount *int `json:"paid_message_star_count,omitempty"` + FirstProfileAudio *Audio `json:"first_profile_audio,omitempty"` // Since: Bot API 9.4 + UniqueGiftColors *UniqueGiftColors `json:"unique_gift_colors,omitempty"` // Since: Bot API 9.3 + PaidMessageStarCount *int `json:"paid_message_star_count,omitempty"` // Since: Bot API 9.3 } // ChatPhoto represents a chat photo. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#chatphoto type ChatPhoto struct { SmallFileID string `json:"small_file_id"` @@ -101,26 +104,29 @@ type ChatPhoto struct { } // ChatPermissions describes actions that a non‑administrator user is allowed to take in a chat. +// Since: Bot API 4.4 // See https://core.telegram.org/bots/api#chatpermissions type ChatPermissions struct { CanSendMessages bool `json:"can_send_messages"` - CanSendAudios bool `json:"can_send_audios"` - CanSendDocuments bool `json:"can_send_documents"` - CanSendPhotos bool `json:"can_send_photos"` - CanSendVideos bool `json:"can_send_videos"` - CanSendVideoNotes bool `json:"can_send_video_notes"` - CanSendVoiceNotes bool `json:"can_send_voice_notes"` + CanSendAudios bool `json:"can_send_audios"` // Since: Bot API 6.5 + CanSendDocuments bool `json:"can_send_documents"` // Since: Bot API 6.5 + CanSendPhotos bool `json:"can_send_photos"` // Since: Bot API 6.5 + CanSendVideos bool `json:"can_send_videos"` // Since: Bot API 6.5 + CanSendVideoNotes bool `json:"can_send_video_notes"` // Since: Bot API 6.5 + CanSendVoiceNotes bool `json:"can_send_voice_notes"` // Since: Bot API 6.5 CanSendPolls bool `json:"can_send_polls"` CanSendOtherMessages bool `json:"can_send_other_messages"` CanAddWebPagePreview bool `json:"can_add_web_page_previews"` - CanEditTag bool `json:"can_edit_tag"` + CanReactToMessages bool `json:"can_react_to_messages"` // Since: Bot API 10.0 + CanEditTag bool `json:"can_edit_tag"` // Since: Bot API 9.5 CanChangeInfo bool `json:"can_change_info"` CanInviteUsers bool `json:"can_invite_users"` CanPinMessages bool `json:"can_pin_messages"` - CanManageTopics bool `json:"can_manage_topics"` + CanManageTopics bool `json:"can_manage_topics"` // Since: Bot API 6.3 } // ChatLocation represents a location to which a chat is connected. +// Since: Bot API 5.0 // See https://core.telegram.org/bots/api#chatlocation type ChatLocation struct { Location Location `json:"location"` @@ -128,6 +134,7 @@ type ChatLocation struct { } // ChatInviteLink represents an invite link for a chat. +// Since: Bot API 5.1 // See https://core.telegram.org/bots/api#chatinvitelink type ChatInviteLink struct { InviteLink string `json:"invite_link"` @@ -163,11 +170,12 @@ const ( ) // ChatMember contains information about one member of a chat. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#chatmember type ChatMember struct { Status ChatMemberStatusType `json:"status"` User User `json:"user"` - Tag string `json:"tag,omitempty"` + Tag string `json:"tag,omitempty"` // Since: Bot API 9.5 // Owner IsAnonymous *bool `json:"is_anonymous"` @@ -182,16 +190,16 @@ type ChatMember struct { CanPromoteMembers *bool `json:"can_promote_members,omitempty"` CanChangeInfo *bool `json:"can_change_info,omitempty"` CanInviteUsers *bool `json:"can_invite_users,omitempty"` - CanPostStories *bool `json:"can_post_stories,omitempty"` - CanEditStories *bool `json:"can_edit_stories,omitempty"` - CanDeleteStories *bool `json:"can_delete_stories,omitempty"` + CanPostStories *bool `json:"can_post_stories,omitempty"` // Since: Bot API 6.9 + CanEditStories *bool `json:"can_edit_stories,omitempty"` // Since: Bot API 6.9 + CanDeleteStories *bool `json:"can_delete_stories,omitempty"` // Since: Bot API 6.9 CanPostMessages *bool `json:"can_post_messages,omitempty"` CanEditMessages *bool `json:"can_edit_messages,omitempty"` CanPinMessages *bool `json:"can_pin_messages,omitempty"` - CanManageTopics *bool `json:"can_manage_topics,omitempty"` - CanManageDirectMessages *bool `json:"can_manage_direct_messages,omitempty"` - CanManageTags *bool `json:"can_manage_tags,omitempty"` + CanManageTopics *bool `json:"can_manage_topics,omitempty"` // Since: Bot API 6.3 + CanManageDirectMessages *bool `json:"can_manage_direct_messages,omitempty"` // Since: Bot API 9.1 + CanManageTags *bool `json:"can_manage_tags,omitempty"` // Since: Bot API 9.5 // Member UntilDate *int `json:"until_date,omitempty"` @@ -199,19 +207,21 @@ type ChatMember struct { // Restricted IsMember *bool `json:"is_member,omitempty"` CanSendMessages *bool `json:"can_send_messages,omitempty"` - CanSendAudios *bool `json:"can_send_audios,omitempty"` - CanSendDocuments *bool `json:"can_send_documents,omitempty"` - CanSendPhotos *bool `json:"can_send_photos,omitempty"` - CanSendVideos *bool `json:"can_send_videos,omitempty"` - CanSendVideoNotes *bool `json:"can_send_video_notes,omitempty"` - CanSendVoiceNotes *bool `json:"can_send_voice_notes,omitempty"` + CanSendAudios *bool `json:"can_send_audios,omitempty"` // Since: Bot API 6.5 + CanSendDocuments *bool `json:"can_send_documents,omitempty"` // Since: Bot API 6.5 + CanSendPhotos *bool `json:"can_send_photos,omitempty"` // Since: Bot API 6.5 + CanSendVideos *bool `json:"can_send_videos,omitempty"` // Since: Bot API 6.5 + CanSendVideoNotes *bool `json:"can_send_video_notes,omitempty"` // Since: Bot API 6.5 + CanSendVoiceNotes *bool `json:"can_send_voice_notes,omitempty"` // Since: Bot API 6.5 CanSendPolls *bool `json:"can_send_polls,omitempty"` CanSendOtherMessages *bool `json:"can_send_other_messages,omitempty"` CanAddWebPagePreview *bool `json:"can_add_web_page_previews,omitempty"` - CanEditTag *bool `json:"can_edit_tag,omitempty"` + CanReactToMessages *bool `json:"can_react_to_messages,omitempty"` // Since: Bot API 10.0 + CanEditTag *bool `json:"can_edit_tag,omitempty"` // Since: Bot API 9.5 } // ChatBoostSource describes the source of a chat boost. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#chatboostsource type ChatBoostSource struct { Source string `json:"source"` @@ -224,6 +234,7 @@ type ChatBoostSource struct { } // ChatBoost represents a boost added to a chat. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#chatboost type ChatBoost struct { BoostID string `json:"boost_id"` @@ -233,31 +244,40 @@ type ChatBoost struct { } // UserChatBoosts represents a list of boosts a user has given to a chat. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#userchatboosts type UserChatBoosts struct { Boosts []ChatBoost `json:"boosts"` } + +// ChatBoostAdded describes a service message about a user boosting a chat. +// Since: Bot API 7.1 type ChatBoostAdded struct { BoostCount int `json:"boost_count"` } +// ChatBackground represents a chat background. +// Since: Bot API 7.5 type ChatBackground struct { Type BackgroundType `json:"type"` } // ChatOwnerLeft describes a service message about a chat owner leaving. +// Since: Bot API 9.4 // See https://core.telegram.org/bots/api#chatownerleft type ChatOwnerLeft struct { NewOwner *User `json:"new_owner,omitempty"` } // ChatOwnerChanged describes a service message about a chat owner change. +// Since: Bot API 9.4 // See https://core.telegram.org/bots/api#chatownerchanged type ChatOwnerChanged struct { NewOwner User `json:"new_owner"` } // ChatAdministratorRights represents the rights of an administrator in a chat. +// Since: Bot API 6.0 // See https://core.telegram.org/bots/api#chatadministratorrights type ChatAdministratorRights struct { IsAnonymous bool `json:"is_anonymous"` @@ -281,6 +301,7 @@ type ChatAdministratorRights struct { } // ChatBoostUpdated represents a boost added to a chat or changed. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#chatboostupdated type ChatBoostUpdated struct { Chat Chat `json:"chat"` @@ -288,6 +309,7 @@ type ChatBoostUpdated struct { } // ChatBoostRemoved represents a boost removed from a chat. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#chatboostremoved type ChatBoostRemoved struct { Chat Chat `json:"chat"` diff --git a/tgapi/forum_methods.go b/tgapi/forum_methods.go index 822e085..0904b8e 100644 --- a/tgapi/forum_methods.go +++ b/tgapi/forum_methods.go @@ -3,12 +3,14 @@ package tgapi import "context" // BaseForumTopic contains common fields for forum topic operations that require a chat ID and a message thread ID. +// Since: Bot API 6.3 type BaseForumTopic struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id"` } // GetForumTopicIconStickers returns the list of custom emoji that can be used as a forum topic icon. +// Since: Bot API 6.3 // See https://core.telegram.org/bots/api#getforumtopiciconstickers func (api *API) GetForumTopicIconStickers() ([]Sticker, error) { req := NewRequest[[]Sticker]("getForumTopicIconStickers", NoParams) @@ -16,6 +18,7 @@ func (api *API) GetForumTopicIconStickers() ([]Sticker, error) { } // GetForumTopicIconStickersWithContext is the context-aware variant of GetForumTopicIconStickers. +// Since: Bot API 6.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getforumtopiciconstickers func (api *API) GetForumTopicIconStickersWithContext(ctx context.Context) ([]Sticker, error) { @@ -24,6 +27,7 @@ func (api *API) GetForumTopicIconStickersWithContext(ctx context.Context) ([]Sti } // CreateForumTopic holds parameters for the createForumTopic method. +// Since: Bot API 6.3 // See https://core.telegram.org/bots/api#createforumtopic type CreateForumTopic struct { ChatID int64 `json:"chat_id"` @@ -33,6 +37,7 @@ type CreateForumTopic struct { } // CreateForumTopic creates a topic in a forum supergroup. +// Since: Bot API 6.3 // Returns the created ForumTopic on success. // See https://core.telegram.org/bots/api#createforumtopic func (api *API) CreateForumTopic(params CreateForumTopic) (ForumTopic, error) { @@ -41,6 +46,7 @@ func (api *API) CreateForumTopic(params CreateForumTopic) (ForumTopic, error) { } // CreateForumTopicWithContext is the context-aware variant of CreateForumTopic. +// Since: Bot API 6.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createforumtopic func (api *API) CreateForumTopicWithContext(ctx context.Context, params CreateForumTopic) (ForumTopic, error) { @@ -49,6 +55,7 @@ func (api *API) CreateForumTopicWithContext(ctx context.Context, params CreateFo } // EditForumTopic holds parameters for the editForumTopic method. +// Since: Bot API 6.3 // See https://core.telegram.org/bots/api#editforumtopic type EditForumTopic struct { BaseForumTopic @@ -57,6 +64,7 @@ type EditForumTopic struct { } // EditForumTopic edits name and icon of a forum topic. +// Since: Bot API 6.3 // Returns True on success. // See https://core.telegram.org/bots/api#editforumtopic func (api *API) EditForumTopic(params EditForumTopic) (bool, error) { @@ -65,6 +73,7 @@ func (api *API) EditForumTopic(params EditForumTopic) (bool, error) { } // EditForumTopicWithContext is the context-aware variant of EditForumTopic. +// Since: Bot API 6.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editforumtopic func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumTopic) (bool, error) { @@ -73,6 +82,7 @@ func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumT } // CloseForumTopic closes an open forum topic. +// Since: Bot API 6.3 // Returns True on success. // See https://core.telegram.org/bots/api#closeforumtopic func (api *API) CloseForumTopic(params BaseForumTopic) (bool, error) { @@ -81,6 +91,7 @@ func (api *API) CloseForumTopic(params BaseForumTopic) (bool, error) { } // CloseForumTopicWithContext is the context-aware variant of CloseForumTopic. +// Since: Bot API 6.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#closeforumtopic func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) { @@ -89,6 +100,7 @@ func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForum } // ReopenForumTopic reopens a closed forum topic. +// Since: Bot API 6.3 // Returns True on success. // See https://core.telegram.org/bots/api#reopenforumtopic func (api *API) ReopenForumTopic(params BaseForumTopic) (bool, error) { @@ -97,6 +109,7 @@ func (api *API) ReopenForumTopic(params BaseForumTopic) (bool, error) { } // ReopenForumTopicWithContext is the context-aware variant of ReopenForumTopic. +// Since: Bot API 6.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#reopenforumtopic func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) { @@ -105,6 +118,7 @@ func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForu } // DeleteForumTopic deletes a forum topic. +// Since: Bot API 6.3 // Returns True on success. // See https://core.telegram.org/bots/api#deleteforumtopic func (api *API) DeleteForumTopic(params BaseForumTopic) (bool, error) { @@ -113,6 +127,7 @@ func (api *API) DeleteForumTopic(params BaseForumTopic) (bool, error) { } // DeleteForumTopicWithContext is the context-aware variant of DeleteForumTopic. +// Since: Bot API 6.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deleteforumtopic func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) { @@ -121,6 +136,7 @@ func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForu } // UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic. +// Since: Bot API 6.3 // Returns True on success. // See https://core.telegram.org/bots/api#unpinallforumtopicmessages func (api *API) UnpinAllForumTopicMessages(params BaseForumTopic) (bool, error) { @@ -129,6 +145,7 @@ func (api *API) UnpinAllForumTopicMessages(params BaseForumTopic) (bool, error) } // UnpinAllForumTopicMessagesWithContext is the context-aware variant of UnpinAllForumTopicMessages. +// Since: Bot API 6.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unpinallforumtopicmessages func (api *API) UnpinAllForumTopicMessagesWithContext(ctx context.Context, params BaseForumTopic) (bool, error) { @@ -137,11 +154,13 @@ func (api *API) UnpinAllForumTopicMessagesWithContext(ctx context.Context, param } // BaseGeneralForumTopic contains common fields for general forum topic operations that require a chat ID. +// Since: Bot API 6.4 type BaseGeneralForumTopic struct { ChatID int64 `json:"chat_id"` } // EditGeneralForumTopic holds parameters for the editGeneralForumTopic method. +// Since: Bot API 6.4 // See https://core.telegram.org/bots/api#editgeneralforumtopic type EditGeneralForumTopic struct { ChatID int64 `json:"chat_id"` @@ -149,6 +168,7 @@ type EditGeneralForumTopic struct { } // EditGeneralForumTopic edits the name of the 'General' topic in a forum supergroup. +// Since: Bot API 6.4 // Returns True on success. // See https://core.telegram.org/bots/api#editgeneralforumtopic func (api *API) EditGeneralForumTopic(params EditGeneralForumTopic) (bool, error) { @@ -157,6 +177,7 @@ func (api *API) EditGeneralForumTopic(params EditGeneralForumTopic) (bool, error } // EditGeneralForumTopicWithContext is the context-aware variant of EditGeneralForumTopic. +// Since: Bot API 6.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editgeneralforumtopic func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params EditGeneralForumTopic) (bool, error) { @@ -165,6 +186,7 @@ func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params Edi } // CloseGeneralForumTopic closes the 'General' topic in a forum supergroup. +// Since: Bot API 6.4 // Returns True on success. // See https://core.telegram.org/bots/api#closegeneralforumtopic func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) { @@ -173,6 +195,7 @@ func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopic) (bool, erro } // CloseGeneralForumTopicWithContext is the context-aware variant of CloseGeneralForumTopic. +// Since: Bot API 6.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#closegeneralforumtopic func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { @@ -181,6 +204,7 @@ func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params Ba } // ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup. +// Since: Bot API 6.4 // Returns True on success. // See https://core.telegram.org/bots/api#reopengeneralforumtopic func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) { @@ -189,6 +213,7 @@ func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopic) (bool, err } // ReopenGeneralForumTopicWithContext is the context-aware variant of ReopenGeneralForumTopic. +// Since: Bot API 6.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#reopengeneralforumtopic func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { @@ -197,6 +222,7 @@ func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params B } // HideGeneralForumTopic hides the 'General' topic in a forum supergroup. +// Since: Bot API 6.4 // Returns True on success. // See https://core.telegram.org/bots/api#hidegeneralforumtopic func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) { @@ -205,6 +231,7 @@ func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopic) (bool, error } // HideGeneralForumTopicWithContext is the context-aware variant of HideGeneralForumTopic. +// Since: Bot API 6.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#hidegeneralforumtopic func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { @@ -213,6 +240,7 @@ func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params Bas } // UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup. +// Since: Bot API 6.4 // Returns True on success. // See https://core.telegram.org/bots/api#unhidegeneralforumtopic func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) { @@ -221,6 +249,7 @@ func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopic) (bool, err } // UnhideGeneralForumTopicWithContext is the context-aware variant of UnhideGeneralForumTopic. +// Since: Bot API 6.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unhidegeneralforumtopic func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { @@ -229,6 +258,7 @@ func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params B } // UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic. +// Since: Bot API 6.4 // Returns True on success. // See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopic) (bool, error) { @@ -237,6 +267,7 @@ func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopic) } // UnpinAllGeneralForumTopicMessagesWithContext is the context-aware variant of UnpinAllGeneralForumTopicMessages. +// Since: Bot API 6.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages func (api *API) UnpinAllGeneralForumTopicMessagesWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) { diff --git a/tgapi/forum_types.go b/tgapi/forum_types.go index 9223eb3..13a3c80 100644 --- a/tgapi/forum_types.go +++ b/tgapi/forum_types.go @@ -1,6 +1,7 @@ package tgapi // ForumTopic represents a forum topic. +// Since: Bot API 6.3 // See https://core.telegram.org/bots/api#forumtopic type ForumTopic struct { MessageThreadID int `json:"message_thread_id"` @@ -12,6 +13,7 @@ type ForumTopic struct { // ForumTopicIconColor represents the color of a forum topic icon. // The value is an integer representing the color in RGB format. +// Since: Bot API 6.3 // See https://core.telegram.org/bots/api#forumtopiciconcolor type ForumTopicIconColor int @@ -20,18 +22,34 @@ const ( ForumTopicIconColorBlue ForumTopicIconColor = 7322096 ) +// ForumTopicCreated represents a service message about a new forum topic created. +// Since: Bot API 6.3 type ForumTopicCreated struct { Name string `json:"name"` IconColor int `json:"icon_color"` IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` IsNameImplicit bool `json:"is_name_implicit,omitempty"` } + +// ForumTopicEdited represents a service message about an edited forum topic. +// Since: Bot API 6.4 type ForumTopicEdited struct { Name string `json:"name,omitempty"` IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` } + +// ForumTopicClosed represents a service message about a forum topic closed. +// Since: Bot API 6.3 type ForumTopicClosed struct{} + +// ForumTopicReopened represents a service message about a forum topic reopened. +// Since: Bot API 6.3 type ForumTopicReopened struct{} + +// GeneralForumTopicHidden represents a service message about the General forum topic hidden. +// Since: Bot API 6.4 type GeneralForumTopicHidden struct{} -type GeneralForumTopicUnhidden struct { -} + +// GeneralForumTopicUnhidden represents a service message about the General forum topic unhidden. +// Since: Bot API 6.4 +type GeneralForumTopicUnhidden struct{} diff --git a/tgapi/games_methods.go b/tgapi/games_methods.go index afa8615..910fc23 100644 --- a/tgapi/games_methods.go +++ b/tgapi/games_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // SendGame holds parameters for the sendGame method. +// Since: Bot API 2.2 // See https://core.telegram.org/bots/api#sendgame type SendGame struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -20,6 +21,7 @@ type SendGame struct { } // SendGame sends a game message. +// Since: Bot API 2.2 // See https://core.telegram.org/bots/api#sendgame func (api *API) SendGame(params SendGame) (Message, error) { req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID) @@ -27,6 +29,7 @@ func (api *API) SendGame(params SendGame) (Message, error) { } // SendGameWithContext is the context-aware variant of SendGame. +// Since: Bot API 2.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendgame func (api *API) SendGameWithContext(ctx context.Context, params SendGame) (Message, error) { @@ -35,6 +38,7 @@ func (api *API) SendGameWithContext(ctx context.Context, params SendGame) (Messa } // SetGameScore holds parameters for the setGameScore method. +// Since: Bot API 2.2 // See https://core.telegram.org/bots/api#setgamescore type SetGameScore struct { UserID int64 `json:"user_id"` @@ -47,6 +51,7 @@ type SetGameScore struct { } // SetGameScore sets a user's score in a game message. +// Since: Bot API 2.2 // If inline_message_id is provided, returns a boolean success flag. // Otherwise returns the edited Message. // See https://core.telegram.org/bots/api#setgamescore @@ -63,6 +68,7 @@ func (api *API) SetGameScore(params SetGameScore) (Message, bool, error) { } // SetGameScoreWithContext is the context-aware variant of SetGameScore. +// Since: Bot API 2.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setgamescore func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScore) (Message, bool, error) { @@ -78,6 +84,7 @@ func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScore } // GetGameHighScores holds parameters for the getGameHighScores method. +// Since: Bot API 2.2 // See https://core.telegram.org/bots/api#getgamehighscores type GetGameHighScores struct { UserID int64 `json:"user_id"` @@ -87,6 +94,7 @@ type GetGameHighScores struct { } // GetGameHighScores returns game high score data for a user. +// Since: Bot API 2.2 // See https://core.telegram.org/bots/api#getgamehighscores func (api *API) GetGameHighScores(params GetGameHighScores) ([]GameHighScore, error) { req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID) @@ -94,6 +102,7 @@ func (api *API) GetGameHighScores(params GetGameHighScores) ([]GameHighScore, er } // GetGameHighScoresWithContext is the context-aware variant of GetGameHighScores. +// Since: Bot API 2.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getgamehighscores func (api *API) GetGameHighScoresWithContext(ctx context.Context, params GetGameHighScores) ([]GameHighScore, error) { diff --git a/tgapi/games_types.go b/tgapi/games_types.go index ab2f420..1c553a4 100644 --- a/tgapi/games_types.go +++ b/tgapi/games_types.go @@ -1,5 +1,7 @@ package tgapi +// Game represents a game. +// Since: Bot API 2.2 type Game struct { Title string `json:"title"` Description string `json:"description"` @@ -8,9 +10,13 @@ type Game struct { TextEntities []MessageEntity `json:"text_entities,omitempty"` Animation *Animation `json:"animation,omitempty"` } + +// CallbackGame is a placeholder for the future use of callback games. +// Since: Bot API 2.2 type CallbackGame struct{} // GameHighScore represents one row in a game high score table. +// Since: Bot API 2.2 // See https://core.telegram.org/bots/api#gamehighscore type GameHighScore struct { Position int `json:"position"` diff --git a/tgapi/inline_methods.go b/tgapi/inline_methods.go index 420c812..e8e9b6c 100644 --- a/tgapi/inline_methods.go +++ b/tgapi/inline_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // AnswerInlineQuery holds parameters for the answerInlineQuery method. +// Since: Bot API 1.7 // See https://core.telegram.org/bots/api#answerinlinequery type AnswerInlineQuery struct { InlineQueryID string `json:"inline_query_id"` @@ -14,6 +15,7 @@ type AnswerInlineQuery struct { } // AnswerInlineQuery sends answers to an inline query. +// Since: Bot API 1.7 // Returns true on success. // See https://core.telegram.org/bots/api#answerinlinequery func (api *API) AnswerInlineQuery(params AnswerInlineQuery) (bool, error) { @@ -22,6 +24,7 @@ func (api *API) AnswerInlineQuery(params AnswerInlineQuery) (bool, error) { } // AnswerInlineQueryWithContext is the context-aware variant of AnswerInlineQuery. +// Since: Bot API 1.7 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answerinlinequery func (api *API) AnswerInlineQueryWithContext(ctx context.Context, params AnswerInlineQuery) (bool, error) { @@ -30,6 +33,7 @@ func (api *API) AnswerInlineQueryWithContext(ctx context.Context, params AnswerI } // AnswerWebAppQuery holds parameters for the answerWebAppQuery method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#answerwebappquery type AnswerWebAppQuery struct { WebAppQueryID string `json:"web_app_query_id"` @@ -37,6 +41,7 @@ type AnswerWebAppQuery struct { } // AnswerWebAppQuery sets the result of a Web App interaction. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#answerwebappquery func (api *API) AnswerWebAppQuery(params AnswerWebAppQuery) (SentWebAppMessage, error) { req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params) @@ -44,6 +49,7 @@ func (api *API) AnswerWebAppQuery(params AnswerWebAppQuery) (SentWebAppMessage, } // AnswerWebAppQueryWithContext is the context-aware variant of AnswerWebAppQuery. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answerwebappquery func (api *API) AnswerWebAppQueryWithContext(ctx context.Context, params AnswerWebAppQuery) (SentWebAppMessage, error) { @@ -52,6 +58,7 @@ func (api *API) AnswerWebAppQueryWithContext(ctx context.Context, params AnswerW } // SavePreparedInlineMessage holds parameters for the savePreparedInlineMessage method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#savepreparedinlinemessage type SavePreparedInlineMessage struct { UserID int64 `json:"user_id"` @@ -63,6 +70,7 @@ type SavePreparedInlineMessage struct { } // SavePreparedInlineMessage stores a prepared message for Mini App users. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#savepreparedinlinemessage func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessage) (PreparedInlineMessage, error) { req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params) @@ -70,6 +78,7 @@ func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessage) (Pre } // SavePreparedInlineMessageWithContext is the context-aware variant of SavePreparedInlineMessage. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#savepreparedinlinemessage func (api *API) SavePreparedInlineMessageWithContext(ctx context.Context, params SavePreparedInlineMessage) (PreparedInlineMessage, error) { @@ -78,6 +87,7 @@ func (api *API) SavePreparedInlineMessageWithContext(ctx context.Context, params } // SavePreparedKeyboardButton holds parameters for the savePreparedKeyboardButton method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#savepreparedkeyboardbutton type SavePreparedKeyboardButton struct { UserID int64 `json:"user_id"` @@ -85,6 +95,7 @@ type SavePreparedKeyboardButton struct { } // SavePreparedKeyboardButton stores a prepared keyboard button for Mini App users. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#savepreparedkeyboardbutton func (api *API) SavePreparedKeyboardButton(params SavePreparedKeyboardButton) (PreparedKeyboardButton, error) { req := NewRequest[PreparedKeyboardButton]("savePreparedKeyboardButton", params) @@ -92,6 +103,7 @@ func (api *API) SavePreparedKeyboardButton(params SavePreparedKeyboardButton) (P } // SavePreparedKeyboardButtonWithContext is the context-aware variant of SavePreparedKeyboardButton. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#savepreparedkeyboardbutton func (api *API) SavePreparedKeyboardButtonWithContext(ctx context.Context, params SavePreparedKeyboardButton) (PreparedKeyboardButton, error) { diff --git a/tgapi/inline_types.go b/tgapi/inline_types.go index 2c592be..bfc5c27 100644 --- a/tgapi/inline_types.go +++ b/tgapi/inline_types.go @@ -1,10 +1,12 @@ package tgapi // InlineQueryResult is a JSON-serializable inline query result object. +// Since: Bot API 1.7 // See https://core.telegram.org/bots/api#inlinequeryresult type InlineQueryResult map[string]any // InlineQueryResultsButton represents a button shown above inline query results. +// Since: Bot API 6.3 // See https://core.telegram.org/bots/api#inlinequeryresultsbutton type InlineQueryResultsButton struct { Text string `json:"text"` @@ -13,12 +15,14 @@ type InlineQueryResultsButton struct { } // SentWebAppMessage describes an inline message sent by a Web App on behalf of a user. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#sentwebappmessage type SentWebAppMessage struct { InlineMessageID string `json:"inline_message_id,omitempty"` } // PreparedInlineMessage describes a prepared inline message. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#preparedinlinemessage type PreparedInlineMessage struct { ID string `json:"id"` @@ -26,6 +30,7 @@ type PreparedInlineMessage struct { } // PreparedKeyboardButton describes a prepared keyboard button. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#preparedkeyboardbutton type PreparedKeyboardButton struct { ID string `json:"id"` diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index 7584e12..a77d190 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // SendMessage holds parameters for the sendMessage method. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendmessage type SendMessage struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -25,6 +26,7 @@ type SendMessage struct { } // SendMessage sends a text message. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendmessage func (api *API) SendMessage(params SendMessage) (Message, error) { req := NewRequestWithChatID[Message, SendMessage]("sendMessage", params, params.ChatID) @@ -32,6 +34,7 @@ func (api *API) SendMessage(params SendMessage) (Message, error) { } // SendMessageWithContext is the context-aware variant of SendMessage. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendmessage func (api *API) SendMessageWithContext(ctx context.Context, params SendMessage) (Message, error) { @@ -40,6 +43,7 @@ func (api *API) SendMessageWithContext(ctx context.Context, params SendMessage) } // ForwardMessage holds parameters for the forwardMessage method. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#forwardmessage type ForwardMessage struct { ChatID int64 `json:"chat_id"` @@ -57,6 +61,7 @@ type ForwardMessage struct { } // ForwardMessage forwards a message. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#forwardmessage func (api *API) ForwardMessage(params ForwardMessage) (Message, error) { req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID) @@ -64,6 +69,7 @@ func (api *API) ForwardMessage(params ForwardMessage) (Message, error) { } // ForwardMessageWithContext is the context-aware variant of ForwardMessage. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#forwardmessage func (api *API) ForwardMessageWithContext(ctx context.Context, params ForwardMessage) (Message, error) { @@ -72,6 +78,7 @@ func (api *API) ForwardMessageWithContext(ctx context.Context, params ForwardMes } // ForwardMessages holds parameters for the forwardMessages method. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#forwardmessages type ForwardMessages struct { ChatID int64 `json:"chat_id"` @@ -85,6 +92,7 @@ type ForwardMessages struct { } // ForwardMessages forwards multiple messages. +// Since: Bot API 7.0 // Returns an array of message IDs of the sent messages. // See https://core.telegram.org/bots/api#forwardmessages func (api *API) ForwardMessages(params ForwardMessages) ([]MessageID, error) { @@ -93,6 +101,7 @@ func (api *API) ForwardMessages(params ForwardMessages) ([]MessageID, error) { } // ForwardMessagesWithContext is the context-aware variant of ForwardMessages. +// Since: Bot API 7.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#forwardmessages func (api *API) ForwardMessagesWithContext(ctx context.Context, params ForwardMessages) ([]MessageID, error) { @@ -101,6 +110,7 @@ func (api *API) ForwardMessagesWithContext(ctx context.Context, params ForwardMe } // CopyMessage holds parameters for the copyMessage method. +// Since: Bot API 5.0 // See https://core.telegram.org/bots/api#copymessage type CopyMessage struct { ChatID int64 `json:"chat_id"` @@ -126,6 +136,7 @@ type CopyMessage struct { } // CopyMessage copies a message. +// Since: Bot API 5.0 // Returns the MessageID of the sent copy. // See https://core.telegram.org/bots/api#copymessage func (api *API) CopyMessage(params CopyMessage) (int, error) { @@ -137,6 +148,7 @@ func (api *API) CopyMessage(params CopyMessage) (int, error) { } // CopyMessageWithContext is the context-aware variant of CopyMessage. +// Since: Bot API 5.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#copymessage func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessage) (int, error) { @@ -148,6 +160,7 @@ func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessage) } // CopyMessages holds parameters for the copyMessages method. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#copymessages type CopyMessages struct { ChatID int64 `json:"chat_id"` @@ -162,6 +175,7 @@ type CopyMessages struct { } // CopyMessages copies multiple messages. +// Since: Bot API 7.0 // Returns an array of message IDs of the sent copies. // See https://core.telegram.org/bots/api#copymessages func (api *API) CopyMessages(params CopyMessages) ([]MessageID, error) { @@ -170,6 +184,7 @@ func (api *API) CopyMessages(params CopyMessages) ([]MessageID, error) { } // CopyMessagesWithContext is the context-aware variant of CopyMessages. +// Since: Bot API 7.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#copymessages func (api *API) CopyMessagesWithContext(ctx context.Context, params CopyMessages) ([]MessageID, error) { @@ -178,6 +193,7 @@ func (api *API) CopyMessagesWithContext(ctx context.Context, params CopyMessages } // SendLocation holds parameters for the sendLocation method. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendlocation type SendLocation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -203,6 +219,7 @@ type SendLocation struct { } // SendLocation sends a point on the map. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendlocation func (api *API) SendLocation(params SendLocation) (Message, error) { req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID) @@ -210,6 +227,7 @@ func (api *API) SendLocation(params SendLocation) (Message, error) { } // SendLocationWithContext is the context-aware variant of SendLocation. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendlocation func (api *API) SendLocationWithContext(ctx context.Context, params SendLocation) (Message, error) { @@ -218,6 +236,7 @@ func (api *API) SendLocationWithContext(ctx context.Context, params SendLocation } // SendVenue holds parameters for the sendVenue method. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#sendvenue type SendVenue struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -245,6 +264,7 @@ type SendVenue struct { } // SendVenue sends information about a venue. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#sendvenue func (api *API) SendVenue(params SendVenue) (Message, error) { req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID) @@ -252,6 +272,7 @@ func (api *API) SendVenue(params SendVenue) (Message, error) { } // SendVenueWithContext is the context-aware variant of SendVenue. +// Since: Bot API 2.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvenue func (api *API) SendVenueWithContext(ctx context.Context, params SendVenue) (Message, error) { @@ -260,6 +281,7 @@ func (api *API) SendVenueWithContext(ctx context.Context, params SendVenue) (Mes } // SendContact holds parameters for the sendContact method. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#sendcontact type SendContact struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -283,6 +305,7 @@ type SendContact struct { } // SendContact sends a phone contact. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#sendcontact func (api *API) SendContact(params SendContact) (Message, error) { req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID) @@ -290,6 +313,7 @@ func (api *API) SendContact(params SendContact) (Message, error) { } // SendContactWithContext is the context-aware variant of SendContact. +// Since: Bot API 2.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendcontact func (api *API) SendContactWithContext(ctx context.Context, params SendContact) (Message, error) { @@ -298,6 +322,7 @@ func (api *API) SendContactWithContext(ctx context.Context, params SendContact) } // SendPoll holds parameters for the sendPoll method. +// Since: Bot API 4.2 // See https://core.telegram.org/bots/api#sendpoll type SendPoll struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -315,10 +340,14 @@ type SendPoll struct { ShuffleOptions bool `json:"shuffle_options,omitempty"` AllowAddingOptions bool `json:"allow_adding_options,omitempty"` HideResultsUntilCloses bool `json:"hide_results_until_closes,omitempty"` + MembersOnly bool `json:"members_only,omitempty"` // Since: Bot API 10.0 + CountryCodes []string `json:"country_codes,omitempty"` // Since: Bot API 10.0 CorrectOptionIDs []int `json:"correct_option_ids,omitempty"` Explanation string `json:"explanation,omitempty"` ExplanationParseMode ParseMode `json:"explanation_parse_mode,omitempty"` ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"` + ExplanationMedia *InputPollMedia `json:"explanation_media,omitempty"` + Media *InputPollMedia `json:"media,omitempty"` OpenPeriod int `json:"open_period,omitempty"` CloseDate int `json:"close_date"` IsClosed bool `json:"is_closed,omitempty"` @@ -337,6 +366,7 @@ type SendPoll struct { } // SendPoll sends a native poll. +// Since: Bot API 4.2 // See https://core.telegram.org/bots/api#sendpoll func (api *API) SendPoll(params SendPoll) (Message, error) { req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID) @@ -344,6 +374,7 @@ func (api *API) SendPoll(params SendPoll) (Message, error) { } // SendPollWithContext is the context-aware variant of SendPoll. +// Since: Bot API 4.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendpoll func (api *API) SendPollWithContext(ctx context.Context, params SendPoll) (Message, error) { @@ -352,6 +383,7 @@ func (api *API) SendPollWithContext(ctx context.Context, params SendPoll) (Messa } // SendChecklist holds parameters for the sendChecklist method. +// Since: Bot API 9.1 // See https://core.telegram.org/bots/api#sendchecklist type SendChecklist struct { BusinessConnectionID string `json:"business_connection_id"` @@ -367,6 +399,7 @@ type SendChecklist struct { } // SendChecklist sends a checklist. +// Since: Bot API 9.1 // See https://core.telegram.org/bots/api#sendchecklist func (api *API) SendChecklist(params SendChecklist) (Message, error) { req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID) @@ -374,6 +407,7 @@ func (api *API) SendChecklist(params SendChecklist) (Message, error) { } // SendChecklistWithContext is the context-aware variant of SendChecklist. +// Since: Bot API 9.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendchecklist func (api *API) SendChecklistWithContext(ctx context.Context, params SendChecklist) (Message, error) { @@ -382,6 +416,7 @@ func (api *API) SendChecklistWithContext(ctx context.Context, params SendCheckli } // SendDice holds parameters for the sendDice method. +// Since: Bot API 4.7 // See https://core.telegram.org/bots/api#senddice type SendDice struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -402,6 +437,7 @@ type SendDice struct { } // SendDice sends a dice, which will have a random value. +// Since: Bot API 4.7 // See https://core.telegram.org/bots/api#senddice func (api *API) SendDice(params SendDice) (Message, error) { req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID) @@ -409,6 +445,7 @@ func (api *API) SendDice(params SendDice) (Message, error) { } // SendDiceWithContext is the context-aware variant of SendDice. +// Since: Bot API 4.7 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#senddice func (api *API) SendDiceWithContext(ctx context.Context, params SendDice) (Message, error) { @@ -417,6 +454,7 @@ func (api *API) SendDiceWithContext(ctx context.Context, params SendDice) (Messa } // SendMessageDraft holds parameters for the sendMessageDraft method. +// Since: Bot API 9.1 // See https://core.telegram.org/bots/api#sendmessagedraft type SendMessageDraft struct { ChatID int64 `json:"chat_id"` @@ -428,6 +466,7 @@ type SendMessageDraft struct { } // SendMessageDraft sends or updates a draft message in the target chat. +// Since: Bot API 9.1 // Returns True on success. // See https://core.telegram.org/bots/api#sendmessagedraft func (api *API) SendMessageDraft(params SendMessageDraft) (bool, error) { @@ -436,6 +475,7 @@ func (api *API) SendMessageDraft(params SendMessageDraft) (bool, error) { } // SendMessageDraftWithContext is the context-aware variant of SendMessageDraft. +// Since: Bot API 9.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendmessagedraft func (api *API) SendMessageDraftWithContext(ctx context.Context, params SendMessageDraft) (bool, error) { @@ -444,6 +484,7 @@ func (api *API) SendMessageDraftWithContext(ctx context.Context, params SendMess } // SendChatAction holds parameters for the sendChatAction method. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendchataction type SendChatAction struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -453,6 +494,7 @@ type SendChatAction struct { } // SendChatAction sends a chat action (typing, uploading photo, etc.). +// Since: Bot API 1.0 // Returns True on success. // See https://core.telegram.org/bots/api#sendchataction func (api *API) SendChatAction(params SendChatAction) (bool, error) { @@ -461,6 +503,7 @@ func (api *API) SendChatAction(params SendChatAction) (bool, error) { } // SendChatActionWithContext is the context-aware variant of SendChatAction. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendchataction func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatAction) (bool, error) { @@ -469,6 +512,7 @@ func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatAc } // SetMessageReaction holds parameters for the setMessageReaction method. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#setmessagereaction type SetMessageReaction struct { ChatID int64 `json:"chat_id"` @@ -478,6 +522,7 @@ type SetMessageReaction struct { } // SetMessageReaction changes the chosen reaction on a message. +// Since: Bot API 7.0 // Returns True on success. // See https://core.telegram.org/bots/api#setmessagereaction func (api *API) SetMessageReaction(params SetMessageReaction) (bool, error) { @@ -486,6 +531,7 @@ func (api *API) SetMessageReaction(params SetMessageReaction) (bool, error) { } // SetMessageReactionWithContext is the context-aware variant of SetMessageReaction. +// Since: Bot API 7.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setmessagereaction func (api *API) SetMessageReactionWithContext(ctx context.Context, params SetMessageReaction) (bool, error) { @@ -494,6 +540,7 @@ func (api *API) SetMessageReactionWithContext(ctx context.Context, params SetMes } // EditMessageText holds parameters for the editMessageText method. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#editmessagetext type EditMessageText struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -508,6 +555,7 @@ type EditMessageText struct { } // EditMessageText edits text messages. +// Since: Bot API 2.0 // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagetext @@ -524,6 +572,7 @@ func (api *API) EditMessageText(params EditMessageText) (Message, bool, error) { } // EditMessageTextWithContext is the context-aware variant of EditMessageText. +// Since: Bot API 2.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagetext func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessageText) (Message, bool, error) { @@ -539,6 +588,7 @@ func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessa } // EditMessageCaption holds parameters for the editMessageCaption method. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#editmessagecaption type EditMessageCaption struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -553,6 +603,7 @@ type EditMessageCaption struct { } // EditMessageCaption edits captions of messages. +// Since: Bot API 2.0 // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagecaption @@ -569,6 +620,7 @@ func (api *API) EditMessageCaption(params EditMessageCaption) (Message, bool, er } // EditMessageCaptionWithContext is the context-aware variant of EditMessageCaption. +// Since: Bot API 2.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagecaption func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMessageCaption) (Message, bool, error) { @@ -584,6 +636,7 @@ func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMe } // EditMessageMedia holds parameters for the editMessageMedia method. +// Since: Bot API 4.0 // See https://core.telegram.org/bots/api#editmessagemedia type EditMessageMedia struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -595,6 +648,7 @@ type EditMessageMedia struct { } // EditMessageMedia edits media messages. +// Since: Bot API 4.0 // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagemedia @@ -611,6 +665,7 @@ func (api *API) EditMessageMedia(params EditMessageMedia) (Message, bool, error) } // EditMessageMediaWithContext is the context-aware variant of EditMessageMedia. +// Since: Bot API 4.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagemedia func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMessageMedia) (Message, bool, error) { @@ -626,6 +681,7 @@ func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMess } // EditMessageLiveLocation holds parameters for the editMessageLiveLocation method. +// Since: Bot API 3.4 // See https://core.telegram.org/bots/api#editmessagelivelocation type EditMessageLiveLocation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -643,6 +699,7 @@ type EditMessageLiveLocation struct { } // EditMessageLiveLocation edits live location messages. +// Since: Bot API 3.4 // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagelivelocation @@ -659,6 +716,7 @@ func (api *API) EditMessageLiveLocation(params EditMessageLiveLocation) (Message } // EditMessageLiveLocationWithContext is the context-aware variant of EditMessageLiveLocation. +// Since: Bot API 3.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagelivelocation func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params EditMessageLiveLocation) (Message, bool, error) { @@ -674,6 +732,7 @@ func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params E } // StopMessageLiveLocation holds parameters for the stopMessageLiveLocation method. +// Since: Bot API 3.4 // See https://core.telegram.org/bots/api#stopmessagelivelocation type StopMessageLiveLocation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -684,6 +743,7 @@ type StopMessageLiveLocation struct { } // StopMessageLiveLocation stops a live location message. +// Since: Bot API 3.4 // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#stopmessagelivelocation @@ -700,6 +760,7 @@ func (api *API) StopMessageLiveLocation(params StopMessageLiveLocation) (Message } // StopMessageLiveLocationWithContext is the context-aware variant of StopMessageLiveLocation. +// Since: Bot API 3.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#stopmessagelivelocation func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params StopMessageLiveLocation) (Message, bool, error) { @@ -715,6 +776,8 @@ func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params S } // EditMessageChecklist holds parameters for the editMessageChecklist method. +// Since: Bot API 9.1 +// See https://core.telegram.org/bots/api#editmessagechecklist type EditMessageChecklist struct { BusinessConnectionID string `json:"business_connection_id"` ChatID int64 `json:"chat_id"` @@ -724,6 +787,7 @@ type EditMessageChecklist struct { } // EditMessageChecklist edits a checklist message. +// Since: Bot API 9.1 // See https://core.telegram.org/bots/api#editmessagechecklist func (api *API) EditMessageChecklist(params EditMessageChecklist) (Message, error) { req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID) @@ -731,6 +795,7 @@ func (api *API) EditMessageChecklist(params EditMessageChecklist) (Message, erro } // EditMessageChecklistWithContext is the context-aware variant of EditMessageChecklist. +// Since: Bot API 9.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagechecklist func (api *API) EditMessageChecklistWithContext(ctx context.Context, params EditMessageChecklist) (Message, error) { @@ -739,6 +804,7 @@ func (api *API) EditMessageChecklistWithContext(ctx context.Context, params Edit } // EditMessageReplyMarkup holds parameters for the editMessageReplyMarkup method. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#editmessagereplymarkup type EditMessageReplyMarkup struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -749,6 +815,7 @@ type EditMessageReplyMarkup struct { } // EditMessageReplyMarkup edits only the reply markup of messages. +// Since: Bot API 2.0 // If inline_message_id is provided, returns a boolean success flag; // otherwise returns the edited Message. // See https://core.telegram.org/bots/api#editmessagereplymarkup @@ -765,6 +832,7 @@ func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkup) (Message, } // EditMessageReplyMarkupWithContext is the context-aware variant of EditMessageReplyMarkup. +// Since: Bot API 2.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#editmessagereplymarkup func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params EditMessageReplyMarkup) (Message, bool, error) { @@ -780,6 +848,7 @@ func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params Ed } // StopPoll holds parameters for the stopPoll method. +// Since: Bot API 4.2 // See https://core.telegram.org/bots/api#stoppoll type StopPoll struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -789,6 +858,7 @@ type StopPoll struct { } // StopPoll stops a poll that was sent by the bot. +// Since: Bot API 4.2 // Returns the stopped Poll. // See https://core.telegram.org/bots/api#stoppoll func (api *API) StopPoll(params StopPoll) (Poll, error) { @@ -797,6 +867,7 @@ func (api *API) StopPoll(params StopPoll) (Poll, error) { } // StopPollWithContext is the context-aware variant of StopPoll. +// Since: Bot API 4.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#stoppoll func (api *API) StopPollWithContext(ctx context.Context, params StopPoll) (Poll, error) { @@ -805,6 +876,7 @@ func (api *API) StopPollWithContext(ctx context.Context, params StopPoll) (Poll, } // ApproveSuggestedPost holds parameters for the approveSuggestedPost method. +// Since: Bot API 9.2 // See https://core.telegram.org/bots/api#approvesuggestedpost type ApproveSuggestedPost struct { ChatID int64 `json:"chat_id"` @@ -813,6 +885,7 @@ type ApproveSuggestedPost struct { } // ApproveSuggestedPost approves a suggested channel post. +// Since: Bot API 9.2 // Returns True on success. // See https://core.telegram.org/bots/api#approvesuggestedpost func (api *API) ApproveSuggestedPost(params ApproveSuggestedPost) (bool, error) { @@ -821,6 +894,7 @@ func (api *API) ApproveSuggestedPost(params ApproveSuggestedPost) (bool, error) } // ApproveSuggestedPostWithContext is the context-aware variant of ApproveSuggestedPost. +// Since: Bot API 9.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#approvesuggestedpost func (api *API) ApproveSuggestedPostWithContext(ctx context.Context, params ApproveSuggestedPost) (bool, error) { @@ -829,6 +903,7 @@ func (api *API) ApproveSuggestedPostWithContext(ctx context.Context, params Appr } // DeclineSuggestedPost holds parameters for the declineSuggestedPost method. +// Since: Bot API 9.2 // See https://core.telegram.org/bots/api#declinesuggestedpost type DeclineSuggestedPost struct { ChatID int64 `json:"chat_id"` @@ -837,6 +912,7 @@ type DeclineSuggestedPost struct { } // DeclineSuggestedPost declines a suggested channel post. +// Since: Bot API 9.2 // Returns True on success. // See https://core.telegram.org/bots/api#declinesuggestedpost func (api *API) DeclineSuggestedPost(params DeclineSuggestedPost) (bool, error) { @@ -845,6 +921,7 @@ func (api *API) DeclineSuggestedPost(params DeclineSuggestedPost) (bool, error) } // DeclineSuggestedPostWithContext is the context-aware variant of DeclineSuggestedPost. +// Since: Bot API 9.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#declinesuggestedpost func (api *API) DeclineSuggestedPostWithContext(ctx context.Context, params DeclineSuggestedPost) (bool, error) { @@ -853,6 +930,7 @@ func (api *API) DeclineSuggestedPostWithContext(ctx context.Context, params Decl } // DeleteMessage holds parameters for the deleteMessage method. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#deletemessage type DeleteMessage struct { ChatID int64 `json:"chat_id"` @@ -860,6 +938,7 @@ type DeleteMessage struct { } // DeleteMessage deletes a message. +// Since: Bot API 3.0 // Returns True on success. // See https://core.telegram.org/bots/api#deletemessage func (api *API) DeleteMessage(params DeleteMessage) (bool, error) { @@ -868,6 +947,7 @@ func (api *API) DeleteMessage(params DeleteMessage) (bool, error) { } // DeleteMessageWithContext is the context-aware variant of DeleteMessage. +// Since: Bot API 3.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletemessage func (api *API) DeleteMessageWithContext(ctx context.Context, params DeleteMessage) (bool, error) { @@ -876,6 +956,7 @@ func (api *API) DeleteMessageWithContext(ctx context.Context, params DeleteMessa } // DeleteMessages holds parameters for the deleteMessages method. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#deletemessages type DeleteMessages struct { ChatID int64 `json:"chat_id"` @@ -883,6 +964,7 @@ type DeleteMessages struct { } // DeleteMessages deletes multiple messages at once. +// Since: Bot API 7.0 // Returns True on success. // See https://core.telegram.org/bots/api#deletemessages func (api *API) DeleteMessages(params DeleteMessages) (bool, error) { @@ -891,6 +973,7 @@ func (api *API) DeleteMessages(params DeleteMessages) (bool, error) { } // DeleteMessagesWithContext is the context-aware variant of DeleteMessages. +// Since: Bot API 7.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletemessages func (api *API) DeleteMessagesWithContext(ctx context.Context, params DeleteMessages) (bool, error) { @@ -899,6 +982,7 @@ func (api *API) DeleteMessagesWithContext(ctx context.Context, params DeleteMess } // AnswerCallbackQuery holds parameters for the answerCallbackQuery method. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#answercallbackquery type AnswerCallbackQuery struct { CallbackQueryID string `json:"callback_query_id"` @@ -909,6 +993,7 @@ type AnswerCallbackQuery struct { } // AnswerCallbackQuery sends answers to callback queries sent from inline keyboards. +// Since: Bot API 2.0 // Returns True on success. // See https://core.telegram.org/bots/api#answercallbackquery func (api *API) AnswerCallbackQuery(params AnswerCallbackQuery) (bool, error) { @@ -917,9 +1002,90 @@ func (api *API) AnswerCallbackQuery(params AnswerCallbackQuery) (bool, error) { } // AnswerCallbackQueryWithContext is the context-aware variant of AnswerCallbackQuery. +// Since: Bot API 2.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answercallbackquery func (api *API) AnswerCallbackQueryWithContext(ctx context.Context, params AnswerCallbackQuery) (bool, error) { req := NewRequest[bool]("answerCallbackQuery", params) return req.DoWithContext(ctx, api) } + +// AnswerGuestQuery holds parameters for the answerGuestQuery method. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#answerguestquery +type AnswerGuestQuery struct { + GuestQueryID string `json:"guest_query_id"` + Result InlineQueryResult `json:"result"` +} + +// AnswerGuestQuery answers a guest query. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#answerguestquery +func (api *API) AnswerGuestQuery(params AnswerGuestQuery) (SentGuestMessage, error) { + req := NewRequest[SentGuestMessage]("answerGuestQuery", params) + return req.Do(api) +} + +// AnswerGuestQueryWithContext is the context-aware variant of AnswerGuestQuery. +// Since: Bot API 10.0 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#answerguestquery +func (api *API) AnswerGuestQueryWithContext(ctx context.Context, params AnswerGuestQuery) (SentGuestMessage, error) { + req := NewRequest[SentGuestMessage]("answerGuestQuery", params) + return req.DoWithContext(ctx, api) +} + +// DeleteAllMessageReactions holds parameters for the deleteAllMessageReactions method. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#deleteallmessagereactions +type DeleteAllMessageReactions struct { + ChatID int64 `json:"chat_id"` + UserID int64 `json:"user_id,omitempty"` + ActorChatID int64 `json:"actor_chat_id,omitempty"` +} + +// DeleteAllMessageReactions deletes all reactions on a message. +// Since: Bot API 10.0 +// Returns True on success. +// See https://core.telegram.org/bots/api#deleteallmessagereactions +func (api *API) DeleteAllMessageReactions(params DeleteAllMessageReactions) (bool, error) { + req := NewRequest[bool]("deleteAllMessageReactions", params) + return req.Do(api) +} + +// DeleteAllMessageReactionWithContext is the context-aware variant of DeleteAllMessageReactions. +// Since: Bot API 10.0 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deleteallmessagereactions +func (api *API) DeleteAllMessageReactionWithContext(ctx context.Context, params DeleteAllMessageReactions) (bool, error) { + req := NewRequest[bool]("deleteAllMessageReactions", params) + return req.DoWithContext(ctx, api) +} + +// DeleteMessageReaction holds parameters for the deleteMessageReaction method. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#deletemessagereaction +type DeleteMessageReaction struct { + ChatID int64 `json:"chat_id"` + MessageID int `json:"message_id"` + UserID int64 `json:"user_id,omitempty"` + ActorChatID int64 `json:"actor_chat_id,omitempty"` +} + +// DeleteMessageReaction deletes a reaction on a message. +// Since: Bot API 10.0 +// Returns True on success. +// See https://core.telegram.org/bots/api#deletemessagereaction +func (api *API) DeleteMessageReaction(params DeleteMessageReaction) (bool, error) { + req := NewRequest[bool]("deleteMessageReaction", params) + return req.Do(api) +} + +// DeleteMessageReactionWithContext is the context-aware variant of DeleteMessageReaction. +// Since: Bot API 10.0 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#deletemessagereaction +func (api *API) DeleteMessageReactionWithContext(ctx context.Context, params DeleteMessageReaction) (bool, error) { + req := NewRequest[bool]("deleteMessageReaction", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 7529531..95c1522 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -7,16 +7,19 @@ import ( ) // MessageID represents a message identifier wrapper returned by some API methods. +// Since: Bot API 7.0 type MessageID struct { MessageID int `json:"message_id"` } // DirectMessageTopic represents a forum topic in a direct message. +// Since: Bot API 9.2 type DirectMessageTopic struct { TopicID int64 `json:"topic_id"` User *User `json:"user,omitempty"` } +// MessageOriginType represents the type of a message origin. type MessageOriginType string const ( @@ -26,6 +29,8 @@ const ( MessageOriginChannel = "channel" ) +// MessageOrigin describes the origin of a message. +// Since: Bot API 7.0 type MessageOrigin struct { Type MessageOriginType `json:"type"` Date int64 `json:"date"` @@ -42,6 +47,8 @@ type MessageOrigin struct { AuthorSignature string `json:"author_signature,omitempty"` } +// ExternalReplyInfo contains information about a message that is being replied to. +// Since: Bot API 7.0 type ExternalReplyInfo struct { Origin MessageOrigin `json:"origin"` Chat *Chat `json:"chat,omitempty"` @@ -50,15 +57,16 @@ type ExternalReplyInfo struct { Animation *Animation `json:"animation,omitempty"` Audio *Audio `json:"audio,omitempty"` Document *Document `json:"document,omitempty"` - PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` + PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` // Since: Bot API 7.6 Photo []PhotoSize `json:"photo,omitempty"` + LivePhoto *LivePhoto `json:"live_photo,omitempty"` // Since: Bot API 10.0 Sticker *Sticker `json:"sticker,omitempty"` Story *Story `json:"story,omitempty"` Video *Video `json:"video,omitempty"` VideoNote *VideoNote `json:"video_note,omitempty"` Voice *Voice `json:"voice,omitempty"` HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"` - Checklist *Checklist `json:"checklist,omitempty"` + Checklist *Checklist `json:"checklist,omitempty"` // Since: Bot API 9.1 Contact *Contact `json:"contact,omitempty"` Dice *Dice `json:"dice,omitempty"` Game *Game `json:"game,omitempty"` @@ -70,6 +78,8 @@ type ExternalReplyInfo struct { Venue *Venue `json:"venue,omitempty"` } +// TextQuote contains information about the quoted part of a message. +// Since: Bot API 7.0 type TextQuote struct { Text string `json:"text"` Entities []MessageEntity `json:"entities"` @@ -77,75 +87,86 @@ type TextQuote struct { IsManual bool `json:"is_manual,omitempty"` } +// MessageAutoDeleteTimerChanged represents a service message about a change in auto-delete timer settings. +// Since: Bot API 5.1 type MessageAutoDeleteTimerChanged struct { MessageAutoDeleteTime int `json:"message_auto_delete_time"` } +// DirectMessagePriceChanged represents a service message about a change in the price of direct messages. +// Since: Bot API 9.1 type DirectMessagePriceChanged struct { AreDirectMessagesEnabled bool `json:"are_direct_messages_enabled"` DirectMessageStarCount int `json:"direct_message_star_count,omitempty"` } +// PaidMessagePriceChanged represents a service message about a change in the price of paid messages. +// Since: Bot API 9.x type PaidMessagePriceChanged struct { PaidMessageStarCount int `json:"paid_message_star_count"` } // Message represents a Telegram message. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#message type Message struct { MessageID int `json:"message_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"` + MessageThreadID int `json:"message_thread_id,omitempty"` // Since: Bot API 6.3 + DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"` // Since: Bot API 9.2 From *User `json:"from,omitempty"` - SenderChat *Chat `json:"sender_chat,omitempty"` - SenderBoostCount int `json:"sender_boost_count,omitempty"` - SenderBusinessBot *User `json:"sender_business_bot,omitempty"` - SenderTag string `json:"sender_tag,omitempty"` + SenderChat *Chat `json:"sender_chat,omitempty"` // Since: Bot API 5.0 + SenderBoostCount int `json:"sender_boost_count,omitempty"` // Since: Bot API 7.1 + SenderBusinessBot *User `json:"sender_business_bot,omitempty"` // Since: Bot API 7.2 + SenderTag string `json:"sender_tag,omitempty"` // Since: Bot API 9.5 Date int `json:"date"` - BusinessConnectionID string `json:"business_connection_id,omitempty"` + GuestQueryID string `json:"guest_query_id,omitempty"` // Since: Bot API 10.0 + BusinessConnectionID string `json:"business_connection_id,omitempty"` // Since: Bot API 7.2 Chat *Chat `json:"chat,omitempty"` - ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"` + ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"` // Since: Bot API 7.0 - IsTopicMessage bool `json:"is_topic_message,omitempty"` - IsAutomaticForward bool `json:"is_automatic_forward,omitempty"` + IsTopicMessage bool `json:"is_topic_message,omitempty"` // Since: Bot API 6.3 + IsAutomaticForward bool `json:"is_automatic_forward,omitempty"` // Since: Bot API 5.5 ReplyToMessage *Message `json:"reply_to_message,omitempty"` - ExternalReply *ExternalReplyInfo `json:"external_reply,omitempty"` - Quote *TextQuote `json:"quote,omitempty"` + ExternalReply *ExternalReplyInfo `json:"external_reply,omitempty"` // Since: Bot API 7.0 + Quote *TextQuote `json:"quote,omitempty"` // Since: Bot API 7.0 - ReplyToStory *Story `json:"reply_to_story,omitempty"` - ReplyToChecklistTaskID int `json:"reply_to_checklist_task_id,omitempty"` - ReplyToPollOptionID string `json:"reply_to_poll_option_id,omitempty"` + ReplyToStory *Story `json:"reply_to_story,omitempty"` // Since: Bot API 7.1 + ReplyToChecklistTaskID int `json:"reply_to_checklist_task_id,omitempty"` // Since: Bot API 9.1 + ReplyToPollOptionID string `json:"reply_to_poll_option_id,omitempty"` // Since: Bot API 9.6 ViaBot *User `json:"via_bot,omitempty"` - EditDate int `json:"edit_date,omitempty"` - HasProtectedContent bool `json:"has_protected_content,omitempty"` - IsFromOffline bool `json:"is_from_offline,omitempty"` - IsPaidPost bool `json:"is_paid_post,omitempty"` - MediaGroupID string `json:"media_group_id,omitempty"` + GuestBotCallerUser *User `json:"guest_bot_caller_user,omitempty"` // Since: Bot API 10.0 + GuestBotCallerChat *Chat `json:"guest_bot_caller_chat,omitempty"` // Since: Bot API 10.0 + EditDate int `json:"edit_date,omitempty"` // Since: Bot API 2.1 + HasProtectedContent bool `json:"has_protected_content,omitempty"` // Since: Bot API 5.5 + IsFromOffline bool `json:"is_from_offline,omitempty"` // Since: Bot API 7.2 + IsPaidPost bool `json:"is_paid_post,omitempty"` // Since: Bot API 9.1 + MediaGroupID string `json:"media_group_id,omitempty"` // Since: Bot API 3.5 AuthorSignature string `json:"author_signature,omitempty"` - PaidStarCount int `json:"paid_star_count,omitempty"` + PaidStarCount int `json:"paid_star_count,omitempty"` // Since: Bot API 8.3 Text string `json:"text"` Entities []MessageEntity `json:"entities,omitempty"` LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` - SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"` - EffectID string `json:"effect_id,omitempty"` + SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"` // Since: Bot API 9.1 + EffectID string `json:"effect_id,omitempty"` // Since: Bot API 7.4 - Animation *Animation `json:"animation,omitempty"` + Animation *Animation `json:"animation,omitempty"` // Since: Bot API 4.0 Audio *Audio `json:"audio,omitempty"` Document *Document `json:"document,omitempty"` - PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` + PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` // Since: Bot API 7.6 Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"` + LivePhoto *LivePhoto `json:"live_photo,omitempty"` // Since: Bot API 10.0 Sticker *Sticker `json:"sticker,omitempty"` Story *Story `json:"story,omitempty"` Video *Video `json:"video,omitempty"` - VideoNote *VideoNote `json:"video_note,omitempty"` - Voice *Voice `json:"voice,omitempty"` - Caption string `json:"caption,omitempty"` - CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` - ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` - HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"` - Checklist *Checklist `json:"checklist,omitempty"` + VideoNote *VideoNote `json:"video_note,omitempty"` // Since: Bot API 3.0 + Voice *Voice `json:"voice,omitempty"` // Since: Bot API 1.2 + Caption string `json:"caption,omitempty"` // Since: Bot API 3.4 + CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` // Since: Bot API 3.4 + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` // Since: Bot API 7.4 + HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"` // Since: Bot API 6.4 + Checklist *Checklist `json:"checklist,omitempty"` // Since: Bot API 9.1 Contact *Contact `json:"contact,omitempty"` Dice *Dice `json:"dice,omitempty"` Game *Game `json:"game,omitempty"` @@ -163,63 +184,64 @@ type Message struct { GroupChatCreated bool `json:"group_chat_created,omitempty"` SupergroupChatCreated bool `json:"supergroup_chat_created,omitempty"` ChannelChatCreated bool `json:"channel_chat_created,omitempty"` - MessageAutoDeleteTimerChanged *MessageAutoDeleteTimerChanged `json:"message_auto_delete_timer_changed,omitempty"` + MessageAutoDeleteTimerChanged *MessageAutoDeleteTimerChanged `json:"message_auto_delete_timer_changed,omitempty"` // Since: Bot API 5.1 MigrateToChatID int64 `json:"migrate_to_chat_id,omitempty"` MigrateFromChatID int64 `json:"migrate_from_chat_id,omitempty"` PinnedMessage *MaybeInaccessibleMessage `json:"pinned_message,omitempty"` - Invoice *Invoice `json:"invoice,omitempty"` - SuccessfulPayment *SuccessfulPayment `json:"successful_payment,omitempty"` - RefundedPayment *RefundedPayment `json:"refunded_payment,omitempty"` - UsersShared *UsersShared `json:"users_shared,omitempty"` - ChatShared *ChatShared `json:"chat_shared,omitempty"` - Gift *GiftInfo `json:"gift,omitempty"` - UniqueGift *UniqueGiftInfo `json:"unique_gift,omitempty"` - GiftUpgradeSent *GiftInfo `json:"gift_upgrade_sent,omitempty"` + Invoice *Invoice `json:"invoice,omitempty"` // Since: Bot API 3.0 + SuccessfulPayment *SuccessfulPayment `json:"successful_payment,omitempty"` // Since: Bot API 3.0 + RefundedPayment *RefundedPayment `json:"refunded_payment,omitempty"` // Since: Bot API 7.7 + UsersShared *UsersShared `json:"users_shared,omitempty"` // Since: Bot API 6.5 + ChatShared *ChatShared `json:"chat_shared,omitempty"` // Since: Bot API 6.5 + Gift *GiftInfo `json:"gift,omitempty"` // Since: Bot API 9.0 + UniqueGift *UniqueGiftInfo `json:"unique_gift,omitempty"` // Since: Bot API 9.0 + GiftUpgradeSent *GiftInfo `json:"gift_upgrade_sent,omitempty"` // Since: Bot API 9.3 ConnectedWebsite string `json:"connected_website,omitempty"` - WriteAccessAllowed *WriteAccessAllowed `json:"write_access_allowed,omitempty"` + WriteAccessAllowed *WriteAccessAllowed `json:"write_access_allowed,omitempty"` // Since: Bot API 6.4 PassportData *PassportData `json:"passport_data,omitempty"` - ProximityAlertTriggered *ProximityAlertTriggered `json:"proximity_alert_triggered,omitempty"` - BoostAdded *ChatBoostAdded `json:"boost_added,omitempty"` - ChatBackgroundSet *ChatBackground `json:"chat_background_set,omitempty"` + ProximityAlertTriggered *ProximityAlertTriggered `json:"proximity_alert_triggered,omitempty"` // Since: Bot API 5.0 + BoostAdded *ChatBoostAdded `json:"boost_added,omitempty"` // Since: Bot API 7.1 + ChatBackgroundSet *ChatBackground `json:"chat_background_set,omitempty"` // Since: Bot API 7.5 - ChecklistTaskDone *ChecklistTaskDone `json:"checklist_task_done,omitempty"` - ChecklistTasksAdded *ChecklistTasksAdded `json:"checklist_tasks_added,omitempty"` - DirectMessagePriceChanged *DirectMessagePriceChanged `json:"direct_message_price_changed,omitempty"` - ForumTopicCreated *ForumTopicCreated `json:"forum_topic_created,omitempty"` - ForumTopicEdited *ForumTopicEdited `json:"forum_topic_edited,omitempty"` - ForumTopicClosed *ForumTopicClosed `json:"forum_topic_closed,omitempty"` - ForumTopicReopened *ForumTopicReopened `json:"forum_topic_reopened,omitempty"` - GeneralForumTopicHidden *GeneralForumTopicHidden `json:"general_forum_topic_hidden,omitempty"` - GeneralForumTopicUnhidden *GeneralForumTopicUnhidden `json:"general_forum_topic_unhidden,omitempty"` + ChecklistTaskDone *ChecklistTaskDone `json:"checklist_task_done,omitempty"` // Since: Bot API 9.1 + ChecklistTasksAdded *ChecklistTasksAdded `json:"checklist_tasks_added,omitempty"` // Since: Bot API 9.1 + DirectMessagePriceChanged *DirectMessagePriceChanged `json:"direct_message_price_changed,omitempty"` // Since: Bot API 9.1 + PaidMessagePriceChanged *PaidMessagePriceChanged `json:"paid_message_price_changed,omitempty"` // Since: Bot API 9.x + ForumTopicCreated *ForumTopicCreated `json:"forum_topic_created,omitempty"` // Since: Bot API 6.3 + ForumTopicEdited *ForumTopicEdited `json:"forum_topic_edited,omitempty"` // Since: Bot API 6.4 + ForumTopicClosed *ForumTopicClosed `json:"forum_topic_closed,omitempty"` // Since: Bot API 6.3 + ForumTopicReopened *ForumTopicReopened `json:"forum_topic_reopened,omitempty"` // Since: Bot API 6.3 + GeneralForumTopicHidden *GeneralForumTopicHidden `json:"general_forum_topic_hidden,omitempty"` // Since: Bot API 6.4 + GeneralForumTopicUnhidden *GeneralForumTopicUnhidden `json:"general_forum_topic_unhidden,omitempty"` // Since: Bot API 6.4 - GiveawayCreated *GiveawayCreated `json:"giveaway_created,omitempty"` - Giveaway *Giveaway `json:"giveaway,omitempty"` - GiveawayWinners *GiveawayWinners `json:"giveaway_winners,omitempty"` - GiveawayCompleted *GiveawayCompleted `json:"giveaway_completed,omitempty"` + GiveawayCreated *GiveawayCreated `json:"giveaway_created,omitempty"` // Since: Bot API 7.0 + Giveaway *Giveaway `json:"giveaway,omitempty"` // Since: Bot API 7.0 + GiveawayWinners *GiveawayWinners `json:"giveaway_winners,omitempty"` // Since: Bot API 7.0 + GiveawayCompleted *GiveawayCompleted `json:"giveaway_completed,omitempty"` // Since: Bot API 7.0 - ManagedBotCreated *ManagedBotCreated `json:"managed_bot_created,omitempty"` - PaidMessagePriceChanged *PaidMessagePriceChanged `json:"paid_message_price_changed,omitempty"` - PollOptionAdded *PollOptionAdded `json:"poll_option_added,omitempty"` - PollOptionDeleted *PollOptionDeleted `json:"poll_option_deleted,omitempty"` + ManagedBotCreated *ManagedBotCreated `json:"managed_bot_created,omitempty"` // Since: Bot API 9.6 + PollOptionAdded *PollOptionAdded `json:"poll_option_added,omitempty"` // Since: Bot API 9.6 + PollOptionDeleted *PollOptionDeleted `json:"poll_option_deleted,omitempty"` // Since: Bot API 9.6 - SuggestedPostApproved *SuggestedPostApproved `json:"suggested_post_approved,omitempty"` - SuggestedPostApprovalFailed *SuggestedPostApprovalFailed `json:"suggested_post_approval_failed,omitempty"` - SuggestedPostDeclined *SuggestedPostDeclined `json:"suggested_post_declined,omitempty"` - SuggestedPostPaid *SuggestedPostPaid `json:"suggested_post_paid,omitempty"` - SuggestedPostRefunded *SuggestedPostRefunded `json:"suggested_post_refunded,omitempty"` + SuggestedPostApproved *SuggestedPostApproved `json:"suggested_post_approved,omitempty"` // Since: Bot API 9.1 + SuggestedPostApprovalFailed *SuggestedPostApprovalFailed `json:"suggested_post_approval_failed,omitempty"` // Since: Bot API 9.1 + SuggestedPostDeclined *SuggestedPostDeclined `json:"suggested_post_declined,omitempty"` // Since: Bot API 9.1 + SuggestedPostPaid *SuggestedPostPaid `json:"suggested_post_paid,omitempty"` // Since: Bot API 9.1 + SuggestedPostRefunded *SuggestedPostRefunded `json:"suggested_post_refunded,omitempty"` // Since: Bot API 9.1 - VideoChatScheduled *VideoChatScheduled `json:"video_chat_scheduled,omitempty"` - VideoChatStarted *VideoChatStarted `json:"video_chat_started,omitempty"` - VideoChatEnded *VideoChatEnded `json:"video_chat_ended,omitempty"` - VideoChatParticipantsInvited *VideoChatParticipantsInvited `json:"video_chat_participants_invited,omitempty"` + VideoChatScheduled *VideoChatScheduled `json:"video_chat_scheduled,omitempty"` // Since: Bot API 6.0 + VideoChatStarted *VideoChatStarted `json:"video_chat_started,omitempty"` // Since: Bot API 5.1 + VideoChatEnded *VideoChatEnded `json:"video_chat_ended,omitempty"` // Since: Bot API 5.1 + VideoChatParticipantsInvited *VideoChatParticipantsInvited `json:"video_chat_participants_invited,omitempty"` // Since: Bot API 5.1 - WebAppData *WebAppData `json:"web_app_data,omitempty"` - ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` + WebAppData *WebAppData `json:"web_app_data,omitempty"` // Since: Bot API 6.0 + ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` // Since: Bot API 4.3 } // InaccessibleMessage describes a message that was deleted or is otherwise inaccessible. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#inaccessiblemessage type InaccessibleMessage struct { Chat Chat `json:"chat"` @@ -228,6 +250,7 @@ type InaccessibleMessage struct { } // MaybeInaccessibleMessage is a union type that can be either Message or InaccessibleMessage. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#maybeinaccessiblemessage type MaybeInaccessibleMessage struct { msg *Message @@ -331,11 +354,11 @@ const ( // MessageEntityStrike identifies strikethrough text. MessageEntityStrike MessageEntityType = "strikethrough" // MessageEntitySpoiler identifies spoiler text. - MessageEntitySpoiler MessageEntityType = "spoiler" + MessageEntitySpoiler MessageEntityType = "spoiler" // Since: Bot API 5.6 // MessageEntityBlockquote identifies a blockquote entity. MessageEntityBlockquote MessageEntityType = "blockquote" // MessageEntityExpandableBlockquote identifies an expandable blockquote entity. - MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote" + MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote" // Since: Bot API 7.5 // MessageEntityCode identifies inline code. MessageEntityCode MessageEntityType = "code" // MessageEntityPre identifies a preformatted block. @@ -345,12 +368,13 @@ const ( // MessageEntityTextMention identifies a text mention. MessageEntityTextMention MessageEntityType = "text_mention" // MessageEntityCustomEmoji identifies a custom emoji entity. - MessageEntityCustomEmoji MessageEntityType = "custom_emoji" + MessageEntityCustomEmoji MessageEntityType = "custom_emoji" // Since: Bot API 6.2 // MessageEntityDateTime identifies a date-time entity. - MessageEntityDateTime MessageEntityType = "date_time" + MessageEntityDateTime MessageEntityType = "date_time" // Since: Bot API 9.5 ) // MessageEntity represents one special entity in a text message. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#messageentity type MessageEntity struct { Type MessageEntityType `json:"type"` @@ -360,13 +384,14 @@ type MessageEntity struct { URL string `json:"url,omitempty"` User *User `json:"user,omitempty"` Language string `json:"language,omitempty"` - CustomEmojiID string `json:"custom_emoji_id,omitempty"` + CustomEmojiID string `json:"custom_emoji_id,omitempty"` // Since: Bot API 6.2 UnixTime int64 `json:"unix_time,omitempty"` - DateTimeFormat string `json:"date_time_format,omitempty"` + DateTimeFormat string `json:"date_time_format,omitempty"` // Since: Bot API 9.5 } // ReplyParameters describes the parameters to use when replying to a message. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#replyparameters type ReplyParameters struct { MessageID int `json:"message_id"` @@ -382,6 +407,7 @@ type ReplyParameters struct { } // LinkPreviewOptions describes the options used for link preview generation. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#linkpreviewoptions type LinkPreviewOptions struct { IsDisabled bool `json:"is_disabled,omitempty"` @@ -392,6 +418,7 @@ type LinkPreviewOptions struct { } // ReplyMarkup represents a custom keyboard or inline keyboard. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#replymarkup type ReplyMarkup struct { InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard,omitempty"` @@ -409,6 +436,7 @@ type ReplyMarkup struct { } // InlineKeyboardMarkup represents an inline keyboard that appears right next to the message it belongs to. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#inlinekeyboardmarkup type InlineKeyboardMarkup struct { InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard,omitempty"` @@ -427,21 +455,23 @@ const ( ) // KeyboardButton represents one button of the reply keyboard. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#keyboardbutton type KeyboardButton struct { Text string `json:"text"` - IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` - Style KeyboardButtonStyle `json:"style,omitempty"` - RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"` - RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"` - RequestManagedBot *KeyboardButtonRequestManagedBot `json:"request_managed_bot,omitempty"` + IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` // Since: Bot API 9.4 + Style KeyboardButtonStyle `json:"style,omitempty"` // Since: Bot API 9.4 + RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"` // Since: Bot API 7.0 + RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"` // Since: Bot API 6.5 + RequestManagedBot *KeyboardButtonRequestManagedBot `json:"request_managed_bot,omitempty"` // Since: Bot API 9.6 RequestContact bool `json:"request_contact,omitempty"` RequestLocation bool `json:"request_location,omitempty"` - RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"` - WebApp *WebAppInfo `json:"web_app,omitempty"` + RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"` // Since: Bot API 4.6 + WebApp *WebAppInfo `json:"web_app,omitempty"` // Since: Bot API 6.0 } // KeyboardButtonRequestUsers defines criteria used to request suitable users. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#keyboardbuttonrequestusers type KeyboardButtonRequestUsers struct { RequestID int `json:"request_id"` @@ -454,6 +484,7 @@ type KeyboardButtonRequestUsers struct { } // KeyboardButtonRequestChat defines criteria used to request a suitable chat. +// Since: Bot API 6.5 // See https://core.telegram.org/bots/api#keyboardbuttonrequestchat type KeyboardButtonRequestChat struct { RequestID int `json:"request_id"` @@ -470,6 +501,7 @@ type KeyboardButtonRequestChat struct { } // KeyboardButtonRequestManagedBot defines criteria used to request a managed bot. +// Since: Bot API 9.6 // See https://core.telegram.org/bots/api#keyboardbuttonrequestmanagedbot type KeyboardButtonRequestManagedBot struct { RequestID int32 `json:"request_id"` @@ -478,22 +510,25 @@ type KeyboardButtonRequestManagedBot struct { } // KeyboardButtonPollType represents the type of a poll that may be created from a keyboard button. +// Since: Bot API 4.6 // See https://core.telegram.org/bots/api#keyboardbuttonpolltype type KeyboardButtonPollType struct { Type PollType `json:"type,omitempty"` } // InlineKeyboardButton represents one button of an inline keyboard. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#inlinekeyboardbutton type InlineKeyboardButton struct { Text string `json:"text"` URL string `json:"url,omitempty"` CallbackData string `json:"callback_data,omitempty"` - Style KeyboardButtonStyle `json:"style,omitempty"` - IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` + Style KeyboardButtonStyle `json:"style,omitempty"` // Since: Bot API 9.4 + IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` // Since: Bot API 9.4 } // ReplyKeyboardMarkup represents a custom keyboard with reply options. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#replykeyboardmarkup type ReplyKeyboardMarkup struct { Keyboard [][]KeyboardButton `json:"keyboard"` @@ -505,6 +540,7 @@ type ReplyKeyboardMarkup struct { } // CallbackQuery represents an incoming callback query from a callback button in an inline keyboard. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#callbackquery type CallbackQuery struct { ID string `json:"id"` @@ -541,6 +577,7 @@ const ( ) // MessageReactionUpdated represents a change of a reaction on a message. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#messagereactionupdated type MessageReactionUpdated struct { Chat *Chat `json:"chat"` @@ -553,6 +590,7 @@ type MessageReactionUpdated struct { } // MessageReactionCountUpdated represents a change in the count of reactions on a message. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#messagereactioncountupdated type MessageReactionCountUpdated struct { Chat *Chat `json:"chat"` @@ -562,6 +600,7 @@ type MessageReactionCountUpdated struct { } // ReactionType describes the type of a reaction. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#reactiontype type ReactionType struct { Type string `json:"type"` @@ -572,6 +611,7 @@ type ReactionType struct { } // ReactionCount represents a reaction added to a message along with the number of times it was added. +// Since: Bot API 7.0 // See https://core.telegram.org/bots/api#reactioncount type ReactionCount struct { Type ReactionType `json:"type"` @@ -579,12 +619,14 @@ type ReactionCount struct { } // SuggestedPostPrice represents the price of a suggested post. +// Since: Bot API 9.1 type SuggestedPostPrice struct { Currency string `json:"currency"` Amount int `json:"amount"` } // SuggestedPostInfo contains information about a suggested post. +// Since: Bot API 9.1 // See https://core.telegram.org/bots/api#suggestedpostinfo type SuggestedPostInfo struct { State string `json:"state"` // "pending", "approved", or "declined" @@ -593,24 +635,29 @@ type SuggestedPostInfo struct { } // SuggestedPostParameters holds parameters for suggesting a post. +// Since: Bot API 9.2 type SuggestedPostParameters struct { Price SuggestedPostPrice `json:"price"` SendDate int `json:"send_date"` } // ManagedBotCreated describes a service message about a newly created managed bot. +// Since: Bot API 9.6 // See https://core.telegram.org/bots/api#managedbotcreated type ManagedBotCreated struct { Bot User `json:"bot"` } // ManagedBotUpdated describes an update about a managed bot and its manager. +// Since: Bot API 9.6 // See https://core.telegram.org/bots/api#managedbotupdated type ManagedBotUpdated struct { User User `json:"user"` Bot User `json:"bot"` } +// SharedUser represents a user shared via a KeyboardButtonRequestUsers button. +// Since: Bot API 7.2 type SharedUser struct { UserID int64 `json:"user_id"` FirstName string `json:"first_name,omitempty"` @@ -618,10 +665,16 @@ type SharedUser struct { Username string `json:"username,omitempty"` Photo []PhotoSize `json:"photo,omitempty"` } + +// UsersShared represents a service message about users shared via a KeyboardButtonRequestUsers button. +// Since: Bot API 6.5 type UsersShared struct { RequestID int `json:"request_id"` Users []SharedUser `json:"users"` } + +// ChatShared represents a service message about a chat shared via a KeyboardButtonRequestChat button. +// Since: Bot API 6.5 type ChatShared struct { RequestID int `json:"request_id"` ChatID int64 `json:"chat_id"` @@ -630,37 +683,68 @@ type ChatShared struct { Photo []PhotoSize `json:"photo,omitempty"` } +// SuggestedPostApproved is a service message about an approved suggested post. +// Since: Bot API 9.1 type SuggestedPostApproved struct { SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` Price SuggestedPostPrice `json:"price"` SendDate int `json:"send_date"` } + +// SuggestedPostApprovalFailed is a service message about a failed suggested post approval. +// Since: Bot API 9.1 type SuggestedPostApprovalFailed struct { SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` Price SuggestedPostPrice `json:"price"` } + +// SuggestedPostDeclined is a service message about a declined suggested post. +// Since: Bot API 9.1 type SuggestedPostDeclined struct { SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` Comment string `json:"comment,omitempty"` } + +// SuggestedPostPaid is a service message about a paid suggested post. +// Since: Bot API 9.1 type SuggestedPostPaid struct { SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` Currency string `json:"currency"` Amount int `json:"amount"` StarAmount *StarAmount `json:"star_amount,omitempty"` } + +// SuggestedPostRefunded is a service message about a refunded suggested post. +// Since: Bot API 9.1 type SuggestedPostRefunded struct { SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"` Reason string `json:"reason,omitempty"` } +// VideoChatScheduled represents a service message about a video chat scheduled in the chat. +// Since: Bot API 6.0 type VideoChatScheduled struct { StartDate int64 `json:"start_date"` } + +// VideoChatStarted represents a service message about a video chat started in the chat. +// Since: Bot API 5.1 type VideoChatStarted struct{} + +// VideoChatEnded represents a service message about a video chat ended in the chat. +// Since: Bot API 5.1 type VideoChatEnded struct { Duration int64 `json:"duration"` } + +// VideoChatParticipantsInvited represents a service message about new members invited to a video chat. +// Since: Bot API 5.1 type VideoChatParticipantsInvited struct { Users []User `json:"users"` } + +// SentGuestMessage describes an inline message sent by a guest bot. +// Since: Bot API 10.0 +type SentGuestMessage struct { + InlineMessageID string `json:"inline_message_id"` +} diff --git a/tgapi/passport_methods.go b/tgapi/passport_methods.go index 79aafe3..d1ba64f 100644 --- a/tgapi/passport_methods.go +++ b/tgapi/passport_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // SetPassportDataErrors holds parameters for the setPassportDataErrors method. +// Since: Bot API 4.0 // See https://core.telegram.org/bots/api#setpassportdataerrors type SetPassportDataErrors struct { UserID int64 `json:"user_id"` @@ -10,6 +11,7 @@ type SetPassportDataErrors struct { } // SetPassportDataErrors informs a user about Telegram Passport data errors. +// Since: Bot API 4.0 // Returns true on success. // See https://core.telegram.org/bots/api#setpassportdataerrors func (api *API) SetPassportDataErrors(params SetPassportDataErrors) (bool, error) { @@ -18,6 +20,7 @@ func (api *API) SetPassportDataErrors(params SetPassportDataErrors) (bool, error } // SetPassportDataErrorsWithContext is the context-aware variant of SetPassportDataErrors. +// Since: Bot API 4.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setpassportdataerrors func (api *API) SetPassportDataErrorsWithContext(ctx context.Context, params SetPassportDataErrors) (bool, error) { diff --git a/tgapi/passport_types.go b/tgapi/passport_types.go index e01f347..3b438d3 100644 --- a/tgapi/passport_types.go +++ b/tgapi/passport_types.go @@ -1,15 +1,22 @@ package tgapi +// PassportData contains information about Telegram Passport data shared with the bot. +// Since: Bot API 4.0 type PassportData struct { Data []EncryptedPassportElement `json:"data"` Credentials EncryptedCredentials `json:"credentials"` } + +// PassportFile represents a file uploaded to Telegram Passport. +// Since: Bot API 4.0 type PassportFile struct { FileID string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` FileSize int64 `json:"file_size"` FileDate int64 `json:"file_date"` } + +// PassportElementType represents the type of a Telegram Passport element. type PassportElementType string const ( @@ -28,6 +35,8 @@ const ( PassportEmailType PassportElementType = "email" ) +// EncryptedPassportElement contains information about documents or other Telegram Passport elements. +// Since: Bot API 4.0 type EncryptedPassportElement struct { Type PassportElementType `json:"type"` Data string `json:"data,omitempty"` @@ -40,6 +49,9 @@ type EncryptedPassportElement struct { Translation *PassportFile `json:"translation,omitempty"` Hash string `json:"hash,omitempty"` } + +// EncryptedCredentials contains data required for decrypting and authenticating EncryptedPassportElement. +// Since: Bot API 4.0 type EncryptedCredentials struct { Data string `json:"data"` Hash string `json:"hash"` @@ -47,6 +59,7 @@ type EncryptedCredentials struct { } // PassportElementError is a JSON-serializable passport element error object. +// Since: Bot API 4.0 // See https://core.telegram.org/bots/api#passportelementerror type PassportElementError struct { Source string `json:"source"` diff --git a/tgapi/payments_methods.go b/tgapi/payments_methods.go index 25c8743..6969542 100644 --- a/tgapi/payments_methods.go +++ b/tgapi/payments_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // SendInvoice holds parameters for the sendInvoice method. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#sendinvoice type SendInvoice struct { ChatID int64 `json:"chat_id"` @@ -42,6 +43,7 @@ type SendInvoice struct { } // SendInvoice sends an invoice. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#sendinvoice func (api *API) SendInvoice(params SendInvoice) (Message, error) { req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID) @@ -49,6 +51,7 @@ func (api *API) SendInvoice(params SendInvoice) (Message, error) { } // SendInvoiceWithContext is the context-aware variant of SendInvoice. +// Since: Bot API 3.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendinvoice func (api *API) SendInvoiceWithContext(ctx context.Context, params SendInvoice) (Message, error) { @@ -57,6 +60,7 @@ func (api *API) SendInvoiceWithContext(ctx context.Context, params SendInvoice) } // CreateInvoiceLink holds parameters for the createInvoiceLink method. +// Since: Bot API 6.1 // See https://core.telegram.org/bots/api#createinvoicelink type CreateInvoiceLink struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -86,6 +90,7 @@ type CreateInvoiceLink struct { } // CreateInvoiceLink creates an invoice link. +// Since: Bot API 6.1 // See https://core.telegram.org/bots/api#createinvoicelink func (api *API) CreateInvoiceLink(params CreateInvoiceLink) (string, error) { req := NewRequest[string]("createInvoiceLink", params) @@ -93,6 +98,7 @@ func (api *API) CreateInvoiceLink(params CreateInvoiceLink) (string, error) { } // CreateInvoiceLinkWithContext is the context-aware variant of CreateInvoiceLink. +// Since: Bot API 6.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createinvoicelink func (api *API) CreateInvoiceLinkWithContext(ctx context.Context, params CreateInvoiceLink) (string, error) { @@ -101,6 +107,7 @@ func (api *API) CreateInvoiceLinkWithContext(ctx context.Context, params CreateI } // AnswerShippingQuery holds parameters for the answerShippingQuery method. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#answershippingquery type AnswerShippingQuery struct { ShippingQueryID string `json:"shipping_query_id"` @@ -110,6 +117,7 @@ type AnswerShippingQuery struct { } // AnswerShippingQuery answers a shipping query. +// Since: Bot API 3.0 // Returns true on success. // See https://core.telegram.org/bots/api#answershippingquery func (api *API) AnswerShippingQuery(params AnswerShippingQuery) (bool, error) { @@ -118,6 +126,7 @@ func (api *API) AnswerShippingQuery(params AnswerShippingQuery) (bool, error) { } // AnswerShippingQueryWithContext is the context-aware variant of AnswerShippingQuery. +// Since: Bot API 3.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answershippingquery func (api *API) AnswerShippingQueryWithContext(ctx context.Context, params AnswerShippingQuery) (bool, error) { @@ -126,6 +135,7 @@ func (api *API) AnswerShippingQueryWithContext(ctx context.Context, params Answe } // AnswerPreCheckoutQuery holds parameters for the answerPreCheckoutQuery method. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#answerprecheckoutquery type AnswerPreCheckoutQuery struct { PreCheckoutQueryID string `json:"pre_checkout_query_id"` @@ -134,6 +144,7 @@ type AnswerPreCheckoutQuery struct { } // AnswerPreCheckoutQuery answers a pre-checkout query. +// Since: Bot API 3.0 // Returns true on success. // See https://core.telegram.org/bots/api#answerprecheckoutquery func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQuery) (bool, error) { @@ -142,6 +153,7 @@ func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQuery) (bool, err } // AnswerPreCheckoutQueryWithContext is the context-aware variant of AnswerPreCheckoutQuery. +// Since: Bot API 3.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#answerprecheckoutquery func (api *API) AnswerPreCheckoutQueryWithContext(ctx context.Context, params AnswerPreCheckoutQuery) (bool, error) { diff --git a/tgapi/payments_types.go b/tgapi/payments_types.go index b775ad7..48202fa 100644 --- a/tgapi/payments_types.go +++ b/tgapi/payments_types.go @@ -1,12 +1,15 @@ package tgapi // LabeledPrice represents a price portion. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#labeledprice type LabeledPrice struct { Label string `json:"label"` Amount int `json:"amount"` } +// Invoice contains basic information about an invoice. +// Since: Bot API 3.0 type Invoice struct { Title string `json:"title"` Description string `json:"description"` @@ -16,6 +19,7 @@ type Invoice struct { } // ShippingQuery represents an incoming shipping query. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#shippingquery type ShippingQuery struct { ID string `json:"id"` @@ -25,6 +29,7 @@ type ShippingQuery struct { } // ShippingAddress represents a shipping address. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#shippingaddress type ShippingAddress struct { CountryCode string `json:"country_code"` @@ -36,6 +41,7 @@ type ShippingAddress struct { } // OrderInfo represents information about an order. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#orderinfo type OrderInfo struct { Name string `json:"name"` @@ -45,6 +51,7 @@ type OrderInfo struct { } // PreCheckoutQuery represents an incoming pre-checkout query. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#precheckoutquery type PreCheckoutQuery struct { ID string `json:"id"` @@ -57,6 +64,7 @@ type PreCheckoutQuery struct { } // PaidMediaPurchased represents a purchased paid media. +// Since: Bot API 7.10 // See https://core.telegram.org/bots/api#paidmediapurchased type PaidMediaPurchased struct { From User `json:"from"` @@ -64,6 +72,7 @@ type PaidMediaPurchased struct { } // ShippingOption represents one shipping option. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#shippingoption type ShippingOption struct { ID string `json:"id"` @@ -71,14 +80,16 @@ type ShippingOption struct { Prices []LabeledPrice `json:"prices"` } +// SuccessfulPayment contains basic information about a successful payment. +// Since: Bot API 3.0 type SuccessfulPayment struct { Currency string `json:"currency"` TotalAmount int `json:"total_amount"` InvoicePayload string `json:"invoice_payload"` - SubscriptionExpirationDate int `json:"subscription_expiration_date,omitempty"` - IsRecurring bool `json:"is_recurring,omitempty"` - IsFirstRecurring bool `json:"is_first_recurring,omitempty"` + SubscriptionExpirationDate int `json:"subscription_expiration_date,omitempty"` // Since: Bot API 8.0 + IsRecurring bool `json:"is_recurring,omitempty"` // Since: Bot API 8.0 + IsFirstRecurring bool `json:"is_first_recurring,omitempty"` // Since: Bot API 8.0 ShippingOptionID string `json:"shipping_option_id,omitempty"` OrderInfo *OrderInfo `json:"order_info,omitempty"` @@ -86,6 +97,8 @@ type SuccessfulPayment struct { ProviderPaymentChargeID string `json:"proviced_payment_charge_id"` } +// RefundedPayment contains basic information about a refunded payment. +// Since: Bot API 7.7 type RefundedPayment struct { Currency string `json:"currency"` TotalAmount int `json:"total_amount"` diff --git a/tgapi/stars_methods.go b/tgapi/stars_methods.go index c2e743a..75ec390 100644 --- a/tgapi/stars_methods.go +++ b/tgapi/stars_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // GetStarTransactions holds parameters for the getStarTransactions method. +// Since: Bot API 7.5 // See https://core.telegram.org/bots/api#getstartransactions type GetStarTransactions struct { Offset int `json:"offset,omitempty"` @@ -10,6 +11,7 @@ type GetStarTransactions struct { } // GetMyStarBalance returns the bot's Telegram Star balance. +// Since: Bot API 7.5 // See https://core.telegram.org/bots/api#getmystarbalance func (api *API) GetMyStarBalance() (StarAmount, error) { req := NewRequest[StarAmount]("getMyStarBalance", NoParams) @@ -17,6 +19,7 @@ func (api *API) GetMyStarBalance() (StarAmount, error) { } // GetMyStarBalanceWithContext is the context-aware variant of GetMyStarBalance. +// Since: Bot API 7.5 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getmystarbalance func (api *API) GetMyStarBalanceWithContext(ctx context.Context) (StarAmount, error) { @@ -25,6 +28,7 @@ func (api *API) GetMyStarBalanceWithContext(ctx context.Context) (StarAmount, er } // GetStarTransactions returns Telegram Star transactions for the bot. +// Since: Bot API 7.5 // See https://core.telegram.org/bots/api#getstartransactions func (api *API) GetStarTransactions(params GetStarTransactions) (StarTransactions, error) { req := NewRequest[StarTransactions]("getStarTransactions", params) @@ -32,6 +36,7 @@ func (api *API) GetStarTransactions(params GetStarTransactions) (StarTransaction } // GetStarTransactionsWithContext is the context-aware variant of GetStarTransactions. +// Since: Bot API 7.5 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getstartransactions func (api *API) GetStarTransactionsWithContext(ctx context.Context, params GetStarTransactions) (StarTransactions, error) { @@ -40,6 +45,7 @@ func (api *API) GetStarTransactionsWithContext(ctx context.Context, params GetSt } // RefundStarPayment holds parameters for the refundStarPayment method. +// Since: Bot API 7.4 // See https://core.telegram.org/bots/api#refundstarpayment type RefundStarPayment struct { UserID int64 `json:"user_id"` @@ -47,6 +53,7 @@ type RefundStarPayment struct { } // RefundStarPayment refunds a successful Telegram Stars payment. +// Since: Bot API 7.4 // Returns true on success. // See https://core.telegram.org/bots/api#refundstarpayment func (api *API) RefundStarPayment(params RefundStarPayment) (bool, error) { @@ -55,6 +62,7 @@ func (api *API) RefundStarPayment(params RefundStarPayment) (bool, error) { } // RefundStarPaymentWithContext is the context-aware variant of RefundStarPayment. +// Since: Bot API 7.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#refundstarpayment func (api *API) RefundStarPaymentWithContext(ctx context.Context, params RefundStarPayment) (bool, error) { @@ -63,6 +71,7 @@ func (api *API) RefundStarPaymentWithContext(ctx context.Context, params RefundS } // EditUserStarSubscription holds parameters for the editUserStarSubscription method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#edituserstarsubscription type EditUserStarSubscription struct { UserID int64 `json:"user_id"` @@ -71,6 +80,7 @@ type EditUserStarSubscription struct { } // EditUserStarSubscription cancels or re-enables a user star subscription extension. +// Since: Bot API 8.0 // Returns true on success. // See https://core.telegram.org/bots/api#edituserstarsubscription func (api *API) EditUserStarSubscription(params EditUserStarSubscription) (bool, error) { @@ -79,6 +89,7 @@ func (api *API) EditUserStarSubscription(params EditUserStarSubscription) (bool, } // EditUserStarSubscriptionWithContext is the context-aware variant of EditUserStarSubscription. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#edituserstarsubscription func (api *API) EditUserStarSubscriptionWithContext(ctx context.Context, params EditUserStarSubscription) (bool, error) { diff --git a/tgapi/stars_types.go b/tgapi/stars_types.go index 1bb4bc1..9bac01a 100644 --- a/tgapi/stars_types.go +++ b/tgapi/stars_types.go @@ -1,6 +1,7 @@ package tgapi // StarTransaction describes a Telegram Star transaction. +// Since: Bot API 7.5 // See https://core.telegram.org/bots/api#startransaction type StarTransaction struct { ID string `json:"id"` @@ -12,6 +13,7 @@ type StarTransaction struct { } // StarTransactions contains a list of Telegram Star transactions. +// Since: Bot API 7.5 // See https://core.telegram.org/bots/api#startransactions type StarTransactions struct { Transactions []StarTransaction `json:"transactions"` diff --git a/tgapi/stickers_methods.go b/tgapi/stickers_methods.go index c76ace1..d214fcf 100644 --- a/tgapi/stickers_methods.go +++ b/tgapi/stickers_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // SendSticker holds parameters for the sendSticker method. +// Since: Bot API 1.3 // See https://core.telegram.org/bots/api#sendsticker type SendSticker struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -23,6 +24,7 @@ type SendSticker struct { } // SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker. +// Since: Bot API 1.3 // See https://core.telegram.org/bots/api#sendsticker func (api *API) SendSticker(params SendSticker) (Message, error) { req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID) @@ -30,6 +32,7 @@ func (api *API) SendSticker(params SendSticker) (Message, error) { } // SendStickerWithContext is the context-aware variant of SendSticker. +// Since: Bot API 1.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendsticker func (api *API) SendStickerWithContext(ctx context.Context, params SendSticker) (Message, error) { @@ -38,12 +41,14 @@ func (api *API) SendStickerWithContext(ctx context.Context, params SendSticker) } // GetStickerSet holds parameters for the getStickerSet method. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#getstickerset type GetStickerSet struct { Name string `json:"name"` } // GetStickerSet returns a sticker set by its name. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#getstickerset func (api *API) GetStickerSet(params GetStickerSet) (StickerSet, error) { req := NewRequest[StickerSet]("getStickerSet", params) @@ -51,6 +56,7 @@ func (api *API) GetStickerSet(params GetStickerSet) (StickerSet, error) { } // GetStickerSetWithContext is the context-aware variant of GetStickerSet. +// Since: Bot API 3.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getstickerset func (api *API) GetStickerSetWithContext(ctx context.Context, params GetStickerSet) (StickerSet, error) { @@ -59,12 +65,14 @@ func (api *API) GetStickerSetWithContext(ctx context.Context, params GetStickerS } // GetCustomEmojiStickers holds parameters for the getCustomEmojiStickers method. +// Since: Bot API 6.2 // See https://core.telegram.org/bots/api#getcustomemojistickers type GetCustomEmojiStickers struct { CustomEmojiIDs []string `json:"custom_emoji_ids"` } // GetCustomEmojiStickers returns information about custom emoji stickers by their IDs. +// Since: Bot API 6.2 // See https://core.telegram.org/bots/api#getcustomemojistickers func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickers) ([]Sticker, error) { req := NewRequest[[]Sticker]("getCustomEmojiStickers", params) @@ -72,6 +80,7 @@ func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickers) ([]Sticker } // GetCustomEmojiStickersWithContext is the context-aware variant of GetCustomEmojiStickers. +// Since: Bot API 6.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getcustomemojistickers func (api *API) GetCustomEmojiStickersWithContext(ctx context.Context, params GetCustomEmojiStickers) ([]Sticker, error) { @@ -80,6 +89,7 @@ func (api *API) GetCustomEmojiStickersWithContext(ctx context.Context, params Ge } // UploadStickerFile holds parameters for the uploadStickerFile method. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#uploadstickerfile type UploadStickerFile struct { UserID int64 `json:"user_id"` @@ -87,6 +97,7 @@ type UploadStickerFile struct { } // UploadStickerFile uploads a sticker file for later use in sticker set methods. +// Since: Bot API 3.2 // sticker is the file to upload. // See https://core.telegram.org/bots/api#uploadstickerfile func (api *API) UploadStickerFile(params UploadStickerFile, sticker UploaderFile) (File, error) { @@ -99,6 +110,7 @@ func (api *API) UploadStickerFile(params UploadStickerFile, sticker UploaderFile } // UploadStickerFileWithContext is the context-aware variant of UploadStickerFile. +// Since: Bot API 3.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#uploadstickerfile func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadStickerFile, sticker UploaderFile) (File, error) { @@ -111,6 +123,7 @@ func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadS } // CreateNewStickerSet holds parameters for the createNewStickerSet method. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#createnewstickerset type CreateNewStickerSet struct { UserID int64 `json:"user_id"` @@ -123,6 +136,7 @@ type CreateNewStickerSet struct { } // CreateNewStickerSet creates a new sticker set owned by a user. +// Since: Bot API 3.2 // Returns True on success. // See https://core.telegram.org/bots/api#createnewstickerset func (api *API) CreateNewStickerSet(params CreateNewStickerSet) (bool, error) { @@ -131,6 +145,7 @@ func (api *API) CreateNewStickerSet(params CreateNewStickerSet) (bool, error) { } // CreateNewStickerSetWithContext is the context-aware variant of CreateNewStickerSet. +// Since: Bot API 3.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#createnewstickerset func (api *API) CreateNewStickerSetWithContext(ctx context.Context, params CreateNewStickerSet) (bool, error) { @@ -139,6 +154,7 @@ func (api *API) CreateNewStickerSetWithContext(ctx context.Context, params Creat } // AddStickerToSet holds parameters for the addStickerToSet method. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#addstickertoset type AddStickerToSet struct { UserID int64 `json:"user_id"` @@ -147,6 +163,7 @@ type AddStickerToSet struct { } // AddStickerToSet adds a new sticker to a set created by the bot. +// Since: Bot API 3.2 // Returns True on success. // See https://core.telegram.org/bots/api#addstickertoset func (api *API) AddStickerToSet(params AddStickerToSet) (bool, error) { @@ -155,6 +172,7 @@ func (api *API) AddStickerToSet(params AddStickerToSet) (bool, error) { } // AddStickerToSetWithContext is the context-aware variant of AddStickerToSet. +// Since: Bot API 3.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#addstickertoset func (api *API) AddStickerToSetWithContext(ctx context.Context, params AddStickerToSet) (bool, error) { @@ -163,6 +181,7 @@ func (api *API) AddStickerToSetWithContext(ctx context.Context, params AddSticke } // SetStickerPositionInSet holds parameters for the setStickerPositionInSet method. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#setstickerpositioninset type SetStickerPositionInSet struct { Sticker string `json:"sticker"` @@ -170,6 +189,7 @@ type SetStickerPositionInSet struct { } // SetStickerPositionInSet moves a sticker in a set to a specific position. +// Since: Bot API 3.2 // Returns True on success. // See https://core.telegram.org/bots/api#setstickerpositioninset func (api *API) SetStickerPositionInSet(params SetStickerPositionInSet) (bool, error) { @@ -178,6 +198,7 @@ func (api *API) SetStickerPositionInSet(params SetStickerPositionInSet) (bool, e } // SetStickerPositionInSetWithContext is the context-aware variant of SetStickerPositionInSet. +// Since: Bot API 3.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickerpositioninset func (api *API) SetStickerPositionInSetWithContext(ctx context.Context, params SetStickerPositionInSet) (bool, error) { @@ -186,12 +207,14 @@ func (api *API) SetStickerPositionInSetWithContext(ctx context.Context, params S } // DeleteStickerFromSet holds parameters for the deleteStickerFromSet method. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#deletestickerfromset type DeleteStickerFromSet struct { Sticker string `json:"sticker"` } // DeleteStickerFromSet deletes a sticker from a set created by the bot. +// Since: Bot API 3.2 // Returns True on success. // See https://core.telegram.org/bots/api#deletestickerfromset func (api *API) DeleteStickerFromSet(params DeleteStickerFromSet) (bool, error) { @@ -200,6 +223,7 @@ func (api *API) DeleteStickerFromSet(params DeleteStickerFromSet) (bool, error) } // DeleteStickerFromSetWithContext is the context-aware variant of DeleteStickerFromSet. +// Since: Bot API 3.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletestickerfromset func (api *API) DeleteStickerFromSetWithContext(ctx context.Context, params DeleteStickerFromSet) (bool, error) { @@ -208,6 +232,7 @@ func (api *API) DeleteStickerFromSetWithContext(ctx context.Context, params Dele } // ReplaceStickerInSet holds parameters for the replaceStickerInSet method. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#replacestickerinset type ReplaceStickerInSet struct { UserID int64 `json:"user_id"` @@ -217,6 +242,7 @@ type ReplaceStickerInSet struct { } // ReplaceStickerInSet replaces an existing sticker in a set with a new one. +// Since: Bot API 7.2 // Returns True on success. // See https://core.telegram.org/bots/api#replacestickerinset func (api *API) ReplaceStickerInSet(params ReplaceStickerInSet) (bool, error) { @@ -225,6 +251,7 @@ func (api *API) ReplaceStickerInSet(params ReplaceStickerInSet) (bool, error) { } // ReplaceStickerInSetWithContext is the context-aware variant of ReplaceStickerInSet. +// Since: Bot API 7.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#replacestickerinset func (api *API) ReplaceStickerInSetWithContext(ctx context.Context, params ReplaceStickerInSet) (bool, error) { @@ -233,6 +260,7 @@ func (api *API) ReplaceStickerInSetWithContext(ctx context.Context, params Repla } // SetStickerEmojiList holds parameters for the setStickerEmojiList method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#setstickeremojilist type SetStickerEmojiList struct { Sticker string `json:"sticker"` @@ -240,6 +268,7 @@ type SetStickerEmojiList struct { } // SetStickerEmojiList changes the list of emoji associated with a sticker. +// Since: Bot API 6.6 // Returns True on success. // See https://core.telegram.org/bots/api#setstickeremojilist func (api *API) SetStickerEmojiList(params SetStickerEmojiList) (bool, error) { @@ -248,6 +277,7 @@ func (api *API) SetStickerEmojiList(params SetStickerEmojiList) (bool, error) { } // SetStickerEmojiListWithContext is the context-aware variant of SetStickerEmojiList. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickeremojilist func (api *API) SetStickerEmojiListWithContext(ctx context.Context, params SetStickerEmojiList) (bool, error) { @@ -256,6 +286,7 @@ func (api *API) SetStickerEmojiListWithContext(ctx context.Context, params SetSt } // SetStickerKeywords holds parameters for the setStickerKeywords method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#setstickerkeywords type SetStickerKeywords struct { Sticker string `json:"sticker"` @@ -263,6 +294,7 @@ type SetStickerKeywords struct { } // SetStickerKeywords changes the keywords of a sticker. +// Since: Bot API 6.6 // Returns True on success. // See https://core.telegram.org/bots/api#setstickerkeywords func (api *API) SetStickerKeywords(params SetStickerKeywords) (bool, error) { @@ -271,6 +303,7 @@ func (api *API) SetStickerKeywords(params SetStickerKeywords) (bool, error) { } // SetStickerKeywordsWithContext is the context-aware variant of SetStickerKeywords. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickerkeywords func (api *API) SetStickerKeywordsWithContext(ctx context.Context, params SetStickerKeywords) (bool, error) { @@ -279,6 +312,7 @@ func (api *API) SetStickerKeywordsWithContext(ctx context.Context, params SetSti } // SetStickerMaskPosition holds parameters for the setStickerMaskPosition method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#setstickermaskposition type SetStickerMaskPosition struct { Sticker string `json:"sticker"` @@ -286,6 +320,7 @@ type SetStickerMaskPosition struct { } // SetStickerMaskPosition changes the mask position of a mask sticker. +// Since: Bot API 6.6 // Returns True on success. // See https://core.telegram.org/bots/api#setstickermaskposition func (api *API) SetStickerMaskPosition(params SetStickerMaskPosition) (bool, error) { @@ -294,6 +329,7 @@ func (api *API) SetStickerMaskPosition(params SetStickerMaskPosition) (bool, err } // SetStickerMaskPositionWithContext is the context-aware variant of SetStickerMaskPosition. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickermaskposition func (api *API) SetStickerMaskPositionWithContext(ctx context.Context, params SetStickerMaskPosition) (bool, error) { @@ -302,6 +338,7 @@ func (api *API) SetStickerMaskPositionWithContext(ctx context.Context, params Se } // SetStickerSetTitle holds parameters for the setStickerSetTitle method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#setstickersettitle type SetStickerSetTitle struct { Name string `json:"name"` @@ -309,6 +346,7 @@ type SetStickerSetTitle struct { } // SetStickerSetTitle sets the title of a sticker set created by the bot. +// Since: Bot API 6.6 // Returns True on success. // See https://core.telegram.org/bots/api#setstickersettitle func (api *API) SetStickerSetTitle(params SetStickerSetTitle) (bool, error) { @@ -317,6 +355,7 @@ func (api *API) SetStickerSetTitle(params SetStickerSetTitle) (bool, error) { } // SetStickerSetTitleWithContext is the context-aware variant of SetStickerSetTitle. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickersettitle func (api *API) SetStickerSetTitleWithContext(ctx context.Context, params SetStickerSetTitle) (bool, error) { @@ -325,6 +364,7 @@ func (api *API) SetStickerSetTitleWithContext(ctx context.Context, params SetSti } // SetStickerSetThumbnail holds parameters for the setStickerSetThumbnail method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#setstickersetthumbnail type SetStickerSetThumbnail struct { Name string `json:"name"` @@ -334,6 +374,7 @@ type SetStickerSetThumbnail struct { } // SetStickerSetThumbnail sets the thumbnail of a sticker set. +// Since: Bot API 6.6 // Returns True on success. // See https://core.telegram.org/bots/api#setstickersetthumbnail func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnail) (bool, error) { @@ -342,6 +383,7 @@ func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnail) (bool, err } // SetStickerSetThumbnailWithContext is the context-aware variant of SetStickerSetThumbnail. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setstickersetthumbnail func (api *API) SetStickerSetThumbnailWithContext(ctx context.Context, params SetStickerSetThumbnail) (bool, error) { @@ -350,6 +392,7 @@ func (api *API) SetStickerSetThumbnailWithContext(ctx context.Context, params Se } // SetCustomEmojiStickerSetThumbnail holds parameters for the setCustomEmojiStickerSetThumbnail method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail type SetCustomEmojiStickerSetThumbnail struct { Name string `json:"name"` @@ -357,6 +400,7 @@ type SetCustomEmojiStickerSetThumbnail struct { } // SetCustomEmojiStickerSetThumbnail sets the thumbnail of a custom emoji sticker set. +// Since: Bot API 6.6 // Returns True on success. // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSetThumbnail) (bool, error) { @@ -365,6 +409,7 @@ func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSe } // SetCustomEmojiStickerSetThumbnailWithContext is the context-aware variant of SetCustomEmojiStickerSetThumbnail. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail func (api *API) SetCustomEmojiStickerSetThumbnailWithContext(ctx context.Context, params SetCustomEmojiStickerSetThumbnail) (bool, error) { @@ -373,12 +418,14 @@ func (api *API) SetCustomEmojiStickerSetThumbnailWithContext(ctx context.Context } // DeleteStickerSet holds parameters for the deleteStickerSet method. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#deletestickerset type DeleteStickerSet struct { Name string `json:"name"` } // DeleteStickerSet deletes a sticker set created by the bot. +// Since: Bot API 6.6 // Returns True on success. // See https://core.telegram.org/bots/api#deletestickerset func (api *API) DeleteStickerSet(params DeleteStickerSet) (bool, error) { @@ -387,6 +434,7 @@ func (api *API) DeleteStickerSet(params DeleteStickerSet) (bool, error) { } // DeleteStickerSetWithContext is the context-aware variant of DeleteStickerSet. +// Since: Bot API 6.6 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#deletestickerset func (api *API) DeleteStickerSetWithContext(ctx context.Context, params DeleteStickerSet) (bool, error) { diff --git a/tgapi/stickers_types.go b/tgapi/stickers_types.go index a1ded01..fdd6938 100644 --- a/tgapi/stickers_types.go +++ b/tgapi/stickers_types.go @@ -15,6 +15,7 @@ const ( ) // MaskPosition describes the position on faces where a mask should be placed by default. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#maskposition type MaskPosition struct { Point MaskPositionPoint `json:"point"` @@ -36,26 +37,28 @@ const ( ) // Sticker represents a sticker. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sticker type Sticker struct { - FileID string `json:"file_id"` - FileUniqueID string `json:"file_unique_id"` - Type StickerType `json:"type"` - Width int `json:"width"` - Height int `json:"height"` - IsAnimated bool `json:"is_animated"` - IsVideo bool `json:"is_video"` + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Width int `json:"width"` + Height int `json:"height"` - Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + Type StickerType `json:"type"` // Since: Bot API 6.2 + IsAnimated bool `json:"is_animated"` // Since: Bot API 4.4 + IsVideo bool `json:"is_video"` // Since: Bot API 5.7 + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` // Since: Bot API 6.6 Emoji *string `json:"emoji,omitempty"` - SetName *string `json:"set_name,omitempty"` - MaskPosition *MaskPosition `json:"mask_position,omitempty"` - CustomEmojiID *string `json:"custom_emoji_id,omitempty"` - NeedRepainting *bool `json:"need_repainting,omitempty"` + SetName *string `json:"set_name,omitempty"` // Since: Bot API 3.2 + MaskPosition *MaskPosition `json:"mask_position,omitempty"` // Since: Bot API 3.2 + CustomEmojiID *string `json:"custom_emoji_id,omitempty"` // Since: Bot API 6.2 + NeedRepainting *bool `json:"need_repainting,omitempty"` // Since: Bot API 6.6 FileSize *int64 `json:"file_size,omitempty"` } // StickerSet represents a sticker set. +// Since: Bot API 3.2 // See https://core.telegram.org/bots/api#stickerset type StickerSet struct { Name string `json:"name"` @@ -78,6 +81,7 @@ const ( ) // InputSticker describes a sticker to be added to a sticker set. +// Since: Bot API 6.6 // See https://core.telegram.org/bots/api#inputsticker type InputSticker struct { Sticker string `json:"sticker"` diff --git a/tgapi/types.go b/tgapi/types.go index 3575998..901ece6 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -59,9 +59,13 @@ const ( UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost" UpdateTypeManagedBot UpdateType = "managed_bot" + + // UpdateTypeGuestMessage is a guest message update. + UpdateTypeGuestMessage UpdateType = "guest_message" ) // Update represents an incoming update from Telegram. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#update type Update struct { Type UpdateType `json:"-"` @@ -69,32 +73,33 @@ type Update struct { UpdateID int `json:"update_id"` Message *Message `json:"message,omitempty"` EditedMessage *Message `json:"edited_message,omitempty"` - ChannelPost *Message `json:"channel_post,omitempty"` - EditedChannelPost *Message `json:"edited_channel_post,omitempty"` + ChannelPost *Message `json:"channel_post,omitempty"` // Since: Bot API 2.3 + EditedChannelPost *Message `json:"edited_channel_post,omitempty"` // Since: Bot API 2.3 - BusinessConnection *BusinessConnection `json:"business_connection,omitempty"` - BusinessMessage *Message `json:"business_message,omitempty"` - EditedBusinessMessage *Message `json:"edited_business_message,omitempty"` - DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"` - MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` - MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` + BusinessConnection *BusinessConnection `json:"business_connection,omitempty"` // Since: Bot API 7.2 + BusinessMessage *Message `json:"business_message,omitempty"` // Since: Bot API 7.2 + EditedBusinessMessage *Message `json:"edited_business_message,omitempty"` // Since: Bot API 7.2 + DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"` // Since: Bot API 7.2 + GuestMessage *Message `json:"guest_message,omitempty"` // Since: Bot API 10.0 + MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` // Since: Bot API 7.0 + MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` // Since: Bot API 7.0 - InlineQuery *InlineQuery `json:"inline_query,omitempty"` - ChosenInlineResult *ChosenInlineResult `json:"chosen_inline_result,omitempty"` - CallbackQuery *CallbackQuery `json:"callback_query,omitempty"` - ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"` - PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"` - PurchasedPaidMedia *PaidMediaPurchased `json:"purchased_paid_media,omitempty"` + InlineQuery *InlineQuery `json:"inline_query,omitempty"` // Since: Bot API 1.7 + ChosenInlineResult *ChosenInlineResult `json:"chosen_inline_result,omitempty"` // Since: Bot API 1.8 + CallbackQuery *CallbackQuery `json:"callback_query,omitempty"` // Since: Bot API 2.0 + ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"` // Since: Bot API 3.0 + PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"` // Since: Bot API 3.0 + PurchasedPaidMedia *PaidMediaPurchased `json:"purchased_paid_media,omitempty"` // Since: Bot API 7.10 - Poll *Poll `json:"poll,omitempty"` - PollAnswer *PollAnswer `json:"poll_answer,omitempty"` - MyChatMember *ChatMemberUpdated `json:"my_chat_member,omitempty"` - ChatMember *ChatMemberUpdated `json:"chat_member,omitempty"` - ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"` - ChatBoost *ChatBoostUpdated `json:"chat_boost,omitempty"` - RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` + Poll *Poll `json:"poll,omitempty"` // Since: Bot API 4.2 + PollAnswer *PollAnswer `json:"poll_answer,omitempty"` // Since: Bot API 4.6 + MyChatMember *ChatMemberUpdated `json:"my_chat_member,omitempty"` // Since: Bot API 5.1 + ChatMember *ChatMemberUpdated `json:"chat_member,omitempty"` // Since: Bot API 5.1 + ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"` // Since: Bot API 5.4 + ChatBoost *ChatBoostUpdated `json:"chat_boost,omitempty"` // Since: Bot API 7.0 + RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` // Since: Bot API 7.0 - ManagedBot *ManagedBotUpdated `json:"managed_bot,omitempty"` + ManagedBot *ManagedBotUpdated `json:"managed_bot,omitempty"` // Since: Bot API 9.6 } // UnmarshalJSON decodes an update and derives its Type from the populated payload field. @@ -126,6 +131,8 @@ func (u *Update) UnmarshalJSON(data []byte) error { u.Type = UpdateTypeEditedBusinessMessage case u.DeletedBusinessMessages != nil: u.Type = UpdateTypeDeletedBusinessMessages + case u.GuestMessage != nil: + u.Type = UpdateTypeGuestMessage case u.MessageReaction != nil: u.Type = UpdateTypeMessageReaction case u.MessageReactionCount != nil: @@ -168,6 +175,7 @@ func (u *Update) UnmarshalJSON(data []byte) error { } // WebhookInfo describes the current webhook status. +// Since: Bot API 2.2 // See https://core.telegram.org/bots/api#webhookinfo type WebhookInfo struct { URL string `json:"url"` @@ -181,6 +189,8 @@ type WebhookInfo struct { AllowedUpdates []string `json:"allowed_updates,omitempty"` } +// ProximityAlertTriggered represents the content of a service message sent when a user triggers a proximity alert. +// Since: Bot API 5.0 type ProximityAlertTriggered struct { Traveler User `json:"traveler"` Watcher User `json:"watcher"` @@ -188,6 +198,7 @@ type ProximityAlertTriggered struct { } // InlineQuery represents an incoming inline query. +// Since: Bot API 1.7 // See https://core.telegram.org/bots/api#inlinequery type InlineQuery struct { ID string `json:"id"` @@ -199,6 +210,7 @@ type InlineQuery struct { } // ChosenInlineResult represents a result of an inline query that was chosen by the user. +// Since: Bot API 1.8 // See https://core.telegram.org/bots/api#choseninlineresult type ChosenInlineResult struct { ResultID string `json:"result_id"` @@ -209,6 +221,7 @@ type ChosenInlineResult struct { } // File represents a file ready to be downloaded. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#file type File struct { FileID string `json:"file_id"` @@ -218,6 +231,7 @@ type File struct { } // ChatMemberUpdated represents changes in the status of a chat member. +// Since: Bot API 5.1 // See https://core.telegram.org/bots/api#chatmemberupdated type ChatMemberUpdated struct { Chat Chat `json:"chat"` @@ -231,6 +245,7 @@ type ChatMemberUpdated struct { } // ChatJoinRequest represents a join request sent to a chat. +// Since: Bot API 5.4 // See https://core.telegram.org/bots/api#chatjoinrequest type ChatJoinRequest struct { Chat Chat `json:"chat"` @@ -242,6 +257,7 @@ type ChatJoinRequest struct { } // Location represents a point on the map. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#location type Location struct { Latitude float64 `json:"latitude"` @@ -253,6 +269,7 @@ type Location struct { } // LocationAddress represents a human-readable address of a location. +// Since: Bot API 8.0 type LocationAddress struct { CountryCode string `json:"country_code"` State *string `json:"state,omitempty"` @@ -261,6 +278,7 @@ type LocationAddress struct { } // Venue represents a venue. +// Since: Bot API 2.0 // See https://core.telegram.org/bots/api#venue type Venue struct { Location Location `json:"location"` @@ -273,23 +291,28 @@ type Venue struct { } // WebAppInfo contains information about a Web App. +// Since: Bot API 6.0 // See https://core.telegram.org/bots/api#webappinfo type WebAppInfo struct { URL string `json:"url"` } +// WebAppData represents data sent from a Web App to the bot. +// Since: Bot API 6.0 type WebAppData struct { Data string `json:"data"` ButtonText string `json:"button_text"` } // StarAmount represents an amount of Telegram Stars. +// Since: Bot API 7.5 type StarAmount struct { Amount int `json:"amount"` NanostarAmount int `json:"nanostar_amount"` } // AcceptedGiftTypes represents the types of gifts accepted by a user or chat. +// Since: Bot API 9.0 type AcceptedGiftTypes struct { UnlimitedGifts bool `json:"unlimited_gifts"` LimitedGifts bool `json:"limited_gifts"` @@ -299,6 +322,7 @@ type AcceptedGiftTypes struct { } // GiftBackground represents the background of a gift. +// Since: Bot API 9.0 type GiftBackground struct { CenterColor int `json:"center_color"` EdgeColor int `json:"edge_color"` @@ -306,6 +330,7 @@ type GiftBackground struct { } // Gift represents a gift that can be sent. +// Since: Bot API 9.0 type Gift struct { ID string `json:"id"` Sticker Sticker `json:"sticker"` @@ -323,27 +348,39 @@ type Gift struct { } // Gifts represents a list of gifts. +// Since: Bot API 9.0 type Gifts struct { Gifts []Gift `json:"gifts"` } +// UniqueGiftModel describes the model component of a unique gift. +// Since: Bot API 9.0 type UniqueGiftModel struct { Name string `json:"name"` Sticker Sticker `json:"sticker"` RarityPerMille int `json:"rarity_per_mille"` Rarity string `json:"rarity,omitempty"` } + +// UniqueGiftSymbol describes the symbol component of a unique gift. +// Since: Bot API 9.0 type UniqueGiftSymbol struct { Name string `json:"name"` Sticker Sticker `json:"sticker"` RarityPerMille int `json:"rarity_per_mille"` } + +// UniqueGiftBackdropColors describes the colors of a unique gift backdrop. +// Since: Bot API 9.0 type UniqueGiftBackdropColors struct { CenterColor int `json:"center_color"` EdgeColor int `json:"edge_color"` SymbolColor int `json:"symbol_color"` TextColor int `json:"text_color"` } + +// UniqueGiftBackdrop describes the backdrop of a unique gift. +// Since: Bot API 9.0 type UniqueGiftBackdrop struct { Name string `json:"name"` Colors UniqueGiftBackdropColors `json:"colors"` @@ -351,6 +388,7 @@ type UniqueGiftBackdrop struct { } // UniqueGiftColors represents color information for a unique gift. +// Since: Bot API 9.3 type UniqueGiftColors struct { ModelCustomEmojiID string `json:"model_custom_emoji_id"` SymbolCustomEmojiID string `json:"symbol_custom_emoji_id"` @@ -360,6 +398,8 @@ type UniqueGiftColors struct { DarkThemeOtherColors []int `json:"dark_theme_other_colors"` } +// UniqueGift represents a unique gift. +// Since: Bot API 9.0 type UniqueGift struct { GiftID string `json:"gift_id"` BaseName string `json:"base_name"` @@ -376,6 +416,8 @@ type UniqueGift struct { PublisherChat *Chat `json:"publisher_chat,omitempty"` } +// GiftInfo contains information about a received gift. +// Since: Bot API 9.0 type GiftInfo struct { Gift Gift `json:"gift"` @@ -389,6 +431,9 @@ type GiftInfo struct { IsPrivate bool `json:"is_private,omitempty"` UniqueGiftNumber int `json:"unique_gift_number,omitempty"` } + +// UniqueGiftInfo contains information about a received unique gift. +// Since: Bot API 9.0 type UniqueGiftInfo struct { Gift UniqueGift `json:"gift"` Origin string `json:"origin"` @@ -400,6 +445,7 @@ type UniqueGiftInfo struct { } // OwnedGiftType represents the type of an owned gift. +// Since: Bot API 9.0 type OwnedGiftType string const ( @@ -410,6 +456,7 @@ const ( ) // OwnedGift represents a gift owned by a user or chat. +// Since: Bot API 9.0 type OwnedGift struct { Type OwnedGiftType `json:"type"` OwnedGiftID string `json:"ownen_gift_id,omitempty"` @@ -436,16 +483,21 @@ type OwnedGift struct { } // OwnedGifts represents a list of owned gifts with pagination. +// Since: Bot API 9.0 type OwnedGifts struct { TotalCount int `json:"total_count"` Gifts []OwnedGift `json:"gifts"` NextOffset string `json:"next_offset"` } +// GiveawayCreated represents a service message about a giveaway being created. +// Since: Bot API 7.0 type GiveawayCreated struct { PrizeStarCount int `json:"prize_star_count,omitempty"` } +// Giveaway represents a message about a scheduled giveaway. +// Since: Bot API 7.0 type Giveaway struct { Chats []Chat `json:"chats"` WinnersSelectionDate int `json:"winners_selection_date"` @@ -459,6 +511,8 @@ type Giveaway struct { PremiumSubscriptionMonthCount int `json:"premium_subscription_month_count,omitempty"` } +// GiveawayWinners represents a message about the completion of a giveaway with public winners. +// Since: Bot API 7.0 type GiveawayWinners struct { Chat Chat `json:"chat"` GiveawayMessageID int `json:"giveaway_message_id"` @@ -475,6 +529,8 @@ type GiveawayWinners struct { PrizeDescription string `json:"prize_description,omitempty"` } +// GiveawayCompleted represents a service message about the completion of a giveaway without public winners. +// Since: Bot API 7.0 type GiveawayCompleted struct { WinnerCount int `json:"winner_count"` UnclaimedPrizeCount int `json:"unclaimed_prize_count,omitempty"` @@ -482,12 +538,16 @@ type GiveawayCompleted struct { IsStarGiveaway bool `json:"is_star_giveaway,omitempty"` } +// WriteAccessAllowed represents a service message about a user allowing a bot to write messages. +// Since: Bot API 6.4 type WriteAccessAllowed struct { FromRequest bool `json:"from_request,omitempty"` WebAppName string `json:"web_app_name,omitempty"` FromAttachmentMenu bool `json:"from_attachment_menu,omitempty"` } +// BackgroundFillType represents the type of a background fill. +// Since: Bot API 7.5 type BackgroundFillType string const ( @@ -496,6 +556,8 @@ const ( BackgroundFillFreeformGradientType BackgroundFillType = "freeform_gradient" ) +// BackgroundFill describes the way a background is filled. +// Since: Bot API 7.5 type BackgroundFill struct { Type BackgroundFillType `json:"type"` @@ -508,6 +570,8 @@ type BackgroundFill struct { Colors []int `json:"colors,omitempty"` } +// BackgroundTypeType represents the type of a chat background. +// Since: Bot API 7.5 type BackgroundTypeType string const ( @@ -517,6 +581,8 @@ const ( BackgroundTypeChatThemeType BackgroundTypeType = "chat_theme" ) +// BackgroundType describes the type of a background. +// Since: Bot API 7.5 type BackgroundType struct { Type BackgroundTypeType `json:"type"` diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 13694af..17eb6a4 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -33,6 +33,8 @@ const ( UploaderStickerType UploaderFileType = "sticker" // UploaderCertificateType is the multipart field name for webhook certificate uploads. UploaderCertificateType UploaderFileType = "certificate" + // UploaderLivePhotoType is the multipart field name for live photo uploads. + UploaderLivePhotoType UploaderFileType = "live_photo" ) // UploaderFileType represents the Telegram form field name for a file upload. diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index f9b8213..00ac265 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // UploadPhoto holds parameters for uploading a photo using the Uploader. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendphoto type UploadPhoto struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -27,6 +28,7 @@ type UploadPhoto struct { } // SendPhoto uploads a photo via multipart and sends it as a message. +// Since: Bot API 1.0 // file is the photo file to upload. // See https://core.telegram.org/bots/api#sendphoto func (u *Uploader) SendPhoto(params UploadPhoto, file UploaderFile) (Message, error) { @@ -35,8 +37,7 @@ func (u *Uploader) SendPhoto(params UploadPhoto, file UploaderFile) (Message, er } // SendPhotoWithContext is the context-aware variant of SendPhoto. -// It executes the same request but uses ctx for cancellation and deadlines. -// SendPhotoWithContext is the context-aware variant of SendPhoto. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendphoto func (u *Uploader) SendPhotoWithContext(ctx context.Context, params UploadPhoto, file UploaderFile) (Message, error) { @@ -45,6 +46,7 @@ func (u *Uploader) SendPhotoWithContext(ctx context.Context, params UploadPhoto, } // UploadAudio holds parameters for uploading an audio file using the Uploader. +// Since: Bot API 1.2 // See https://core.telegram.org/bots/api#sendaudio type UploadAudio struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -71,6 +73,7 @@ type UploadAudio struct { } // SendAudio uploads an audio file via multipart and sends it as a message. +// Since: Bot API 1.2 // files are the audio file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendaudio func (u *Uploader) SendAudio(params UploadAudio, files ...UploaderFile) (Message, error) { @@ -79,8 +82,7 @@ func (u *Uploader) SendAudio(params UploadAudio, files ...UploaderFile) (Message } // SendAudioWithContext is the context-aware variant of SendAudio. -// It executes the same request but uses ctx for cancellation and deadlines. -// SendAudioWithContext is the context-aware variant of SendAudio. +// Since: Bot API 1.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendaudio func (u *Uploader) SendAudioWithContext(ctx context.Context, params UploadAudio, files ...UploaderFile) (Message, error) { @@ -89,6 +91,7 @@ func (u *Uploader) SendAudioWithContext(ctx context.Context, params UploadAudio, } // UploadDocument holds parameters for uploading a document using the Uploader. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#senddocument type UploadDocument struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -112,6 +115,7 @@ type UploadDocument struct { } // SendDocument uploads a document via multipart and sends it as a message. +// Since: Bot API 1.0 // files are the document file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#senddocument func (u *Uploader) SendDocument(params UploadDocument, files ...UploaderFile) (Message, error) { @@ -120,8 +124,7 @@ func (u *Uploader) SendDocument(params UploadDocument, files ...UploaderFile) (M } // SendDocumentWithContext is the context-aware variant of SendDocument. -// It executes the same request but uses ctx for cancellation and deadlines. -// SendDocumentWithContext is the context-aware variant of SendDocument. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#senddocument func (u *Uploader) SendDocumentWithContext(ctx context.Context, params UploadDocument, files ...UploaderFile) (Message, error) { @@ -130,6 +133,7 @@ func (u *Uploader) SendDocumentWithContext(ctx context.Context, params UploadDoc } // UploadVideo holds parameters for uploading a video using the Uploader. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#sendvideo type UploadVideo struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -160,6 +164,7 @@ type UploadVideo struct { } // SendVideo uploads a video via multipart and sends it as a message. +// Since: Bot API 1.0 // files are the video file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvideo func (u *Uploader) SendVideo(params UploadVideo, files ...UploaderFile) (Message, error) { @@ -168,8 +173,7 @@ func (u *Uploader) SendVideo(params UploadVideo, files ...UploaderFile) (Message } // SendVideoWithContext is the context-aware variant of SendVideo. -// It executes the same request but uses ctx for cancellation and deadlines. -// SendVideoWithContext is the context-aware variant of SendVideo. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvideo func (u *Uploader) SendVideoWithContext(ctx context.Context, params UploadVideo, files ...UploaderFile) (Message, error) { @@ -178,6 +182,7 @@ func (u *Uploader) SendVideoWithContext(ctx context.Context, params UploadVideo, } // UploadAnimation holds parameters for uploading an animation using the Uploader. +// Since: Bot API 4.0 // See https://core.telegram.org/bots/api#sendanimation type UploadAnimation struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -206,6 +211,7 @@ type UploadAnimation struct { } // SendAnimation uploads an animation via multipart and sends it as a message. +// Since: Bot API 4.0 // files are the animation file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendanimation func (u *Uploader) SendAnimation(params UploadAnimation, files ...UploaderFile) (Message, error) { @@ -214,8 +220,7 @@ func (u *Uploader) SendAnimation(params UploadAnimation, files ...UploaderFile) } // SendAnimationWithContext is the context-aware variant of SendAnimation. -// It executes the same request but uses ctx for cancellation and deadlines. -// SendAnimationWithContext is the context-aware variant of SendAnimation. +// Since: Bot API 4.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendanimation func (u *Uploader) SendAnimationWithContext(ctx context.Context, params UploadAnimation, files ...UploaderFile) (Message, error) { @@ -224,6 +229,7 @@ func (u *Uploader) SendAnimationWithContext(ctx context.Context, params UploadAn } // UploadVoice holds parameters for uploading a voice note using the Uploader. +// Since: Bot API 1.2 // See https://core.telegram.org/bots/api#sendvoice type UploadVoice struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -247,6 +253,7 @@ type UploadVoice struct { } // SendVoice uploads a voice note via multipart and sends it as a message. +// Since: Bot API 1.2 // files are the voice file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvoice func (u *Uploader) SendVoice(params UploadVoice, files ...UploaderFile) (Message, error) { @@ -255,8 +262,7 @@ func (u *Uploader) SendVoice(params UploadVoice, files ...UploaderFile) (Message } // SendVoiceWithContext is the context-aware variant of SendVoice. -// It executes the same request but uses ctx for cancellation and deadlines. -// SendVoiceWithContext is the context-aware variant of SendVoice. +// Since: Bot API 1.2 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvoice func (u *Uploader) SendVoiceWithContext(ctx context.Context, params UploadVoice, files ...UploaderFile) (Message, error) { @@ -265,6 +271,7 @@ func (u *Uploader) SendVoiceWithContext(ctx context.Context, params UploadVoice, } // UploadVideoNote holds parameters for uploading a video note (rounded video) using the Uploader. +// Since: Bot API 3.0 // See https://core.telegram.org/bots/api#sendvideonote type UploadVideoNote struct { BusinessConnectionID string `json:"business_connection_id,omitempty"` @@ -286,6 +293,7 @@ type UploadVideoNote struct { } // SendVideoNote uploads a video note via multipart and sends it as a message. +// Since: Bot API 3.0 // files are the video note file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvideonote func (u *Uploader) SendVideoNote(params UploadVideoNote, files ...UploaderFile) (Message, error) { @@ -294,8 +302,7 @@ func (u *Uploader) SendVideoNote(params UploadVideoNote, files ...UploaderFile) } // SendVideoNoteWithContext is the context-aware variant of SendVideoNote. -// It executes the same request but uses ctx for cancellation and deadlines. -// SendVideoNoteWithContext is the context-aware variant of SendVideoNote. +// Since: Bot API 3.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#sendvideonote func (u *Uploader) SendVideoNoteWithContext(ctx context.Context, params UploadVideoNote, files ...UploaderFile) (Message, error) { @@ -304,12 +311,14 @@ func (u *Uploader) SendVideoNoteWithContext(ctx context.Context, params UploadVi } // UploadChatPhoto holds parameters for uploading a chat photo using the Uploader. +// Since: Bot API 3.1 // See https://core.telegram.org/bots/api#setchatphoto type UploadChatPhoto struct { ChatID int64 `json:"chat_id"` } // SetChatPhoto uploads a new chat photo. +// Since: Bot API 3.1 // photo is the photo file to upload. // See https://core.telegram.org/bots/api#setchatphoto func (u *Uploader) SetChatPhoto(params UploadChatPhoto, photo UploaderFile) (bool, error) { @@ -318,8 +327,7 @@ func (u *Uploader) SetChatPhoto(params UploadChatPhoto, photo UploaderFile) (boo } // SetChatPhotoWithContext is the context-aware variant of SetChatPhoto. -// It executes the same request but uses ctx for cancellation and deadlines. -// SetChatPhotoWithContext is the context-aware variant of SetChatPhoto. +// Since: Bot API 3.1 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setchatphoto func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadChatPhoto, photo UploaderFile) (bool, error) { @@ -328,6 +336,7 @@ func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadCha } // UploadSetWebhook holds multipart parameters for the setWebhook method. +// Since: Bot API 1.0 // Use this type when uploading a self-signed certificate file. // See https://core.telegram.org/bots/api#setwebhook type UploadSetWebhook struct { @@ -340,7 +349,8 @@ type UploadSetWebhook struct { } // SetWebhook uploads a certificate and sets a webhook URL. -// certificate maps to the multipart field \"certificate\". +// Since: Bot API 1.0 +// certificate maps to the multipart field "certificate". // See https://core.telegram.org/bots/api#setwebhook func (u *Uploader) SetWebhook(params UploadSetWebhook, certificate UploaderFile) (bool, error) { req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType)) @@ -348,9 +358,53 @@ func (u *Uploader) SetWebhook(params UploadSetWebhook, certificate UploaderFile) } // SetWebhookWithContext is the context-aware variant of SetWebhook. +// Since: Bot API 1.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setwebhook func (u *Uploader) SetWebhookWithContext(ctx context.Context, params UploadSetWebhook, certificate UploaderFile) (bool, error) { req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType)) return req.DoWithContext(ctx, u) } + +// UploadLivePhoto holds parameters for uploading a live photo using the Uploader. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#sendlivephoto +type UploadLivePhoto struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + + Caption string `json:"caption,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` + + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` + HasSpoiler bool `json:"has_spoiler,omitempty"` + DisableNotification bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + + SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` + ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` + ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` +} + +// SendLivePhoto uploads a live photo via multipart and sends it as a message. +// Since: Bot API 10.0 +// file is the live photo file to upload. +// See https://core.telegram.org/bots/api#sendlivephoto +func (u *Uploader) SendLivePhoto(params UploadLivePhoto, file UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendLivePhoto", params, params.ChatID, file.SetType(UploaderLivePhotoType)) + return req.Do(u) +} + +// SendLivePhotoWithContext is the context-aware variant of SendLivePhoto. +// Since: Bot API 10.0 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#sendlivephoto +func (u *Uploader) SendLivePhotoWithContext(ctx context.Context, params UploadLivePhoto, file UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendLivePhoto", params, params.ChatID, file.SetType(UploaderLivePhotoType)) + return req.DoWithContext(ctx, u) +} diff --git a/tgapi/users_methods.go b/tgapi/users_methods.go index a8be7b3..6dfba55 100644 --- a/tgapi/users_methods.go +++ b/tgapi/users_methods.go @@ -3,6 +3,7 @@ package tgapi import "context" // GetUserProfilePhotos holds parameters for the GetUserProfilePhotos method. +// Since: Bot API 1.4 // See https://core.telegram.org/bots/api#getuserprofilephotos type GetUserProfilePhotos struct { UserID int64 `json:"user_id"` @@ -11,6 +12,7 @@ type GetUserProfilePhotos struct { } // GetUserProfilePhotos returns a list of profile pictures for a user. +// Since: Bot API 1.4 // See https://core.telegram.org/bots/api#getuserprofilephotos func (api *API) GetUserProfilePhotos(params GetUserProfilePhotos) (UserProfilePhotos, error) { req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params) @@ -18,6 +20,7 @@ func (api *API) GetUserProfilePhotos(params GetUserProfilePhotos) (UserProfilePh } // GetUserProfilePhotosWithContext is the context-aware variant of GetUserProfilePhotos. +// Since: Bot API 1.4 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getuserprofilephotos func (api *API) GetUserProfilePhotosWithContext(ctx context.Context, params GetUserProfilePhotos) (UserProfilePhotos, error) { @@ -26,6 +29,7 @@ func (api *API) GetUserProfilePhotosWithContext(ctx context.Context, params GetU } // GetUserProfileAudios holds parameters for the GetUserProfileAudios method. +// Since: Bot API 9.3 // See https://core.telegram.org/bots/api#getuserprofileaudios type GetUserProfileAudios struct { UserID int64 `json:"user_id"` @@ -34,6 +38,7 @@ type GetUserProfileAudios struct { } // GetUserProfileAudios returns a list of profile audios for a user. +// Since: Bot API 9.3 // See https://core.telegram.org/bots/api#getuserprofileaudios func (api *API) GetUserProfileAudios(params GetUserProfileAudios) (UserProfileAudios, error) { req := NewRequest[UserProfileAudios]("getUserProfileAudios", params) @@ -41,6 +46,7 @@ func (api *API) GetUserProfileAudios(params GetUserProfileAudios) (UserProfileAu } // GetUserProfileAudiosWithContext is the context-aware variant of GetUserProfileAudios. +// Since: Bot API 9.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getuserprofileaudios func (api *API) GetUserProfileAudiosWithContext(ctx context.Context, params GetUserProfileAudios) (UserProfileAudios, error) { @@ -49,6 +55,7 @@ func (api *API) GetUserProfileAudiosWithContext(ctx context.Context, params GetU } // SetUserEmojiStatus holds parameters for the SetUserEmojiStatus method. +// Since: Bot API 8.0 // See https://core.telegram.org/bots/api#setuseremojistatus type SetUserEmojiStatus struct { UserID int64 `json:"user_id"` @@ -57,6 +64,7 @@ type SetUserEmojiStatus struct { } // SetUserEmojiStatus sets a custom emoji status for a user. +// Since: Bot API 8.0 // Returns true on success. // See https://core.telegram.org/bots/api#setuseremojistatus func (api *API) SetUserEmojiStatus(params SetUserEmojiStatus) (bool, error) { @@ -65,6 +73,7 @@ func (api *API) SetUserEmojiStatus(params SetUserEmojiStatus) (bool, error) { } // SetUserEmojiStatusWithContext is the context-aware variant of SetUserEmojiStatus. +// Since: Bot API 8.0 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#setuseremojistatus func (api *API) SetUserEmojiStatusWithContext(ctx context.Context, params SetUserEmojiStatus) (bool, error) { @@ -73,6 +82,7 @@ func (api *API) SetUserEmojiStatusWithContext(ctx context.Context, params SetUse } // GetUserGifts holds parameters for the GetUserGifts method. +// Since: Bot API 9.3 // See https://core.telegram.org/bots/api#getusergifts type GetUserGifts struct { UserID int64 `json:"user_id"` @@ -87,6 +97,7 @@ type GetUserGifts struct { } // GetUserGifts returns gifts owned by a user. +// Since: Bot API 9.3 // See https://core.telegram.org/bots/api#getusergifts func (api *API) GetUserGifts(params GetUserGifts) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getUserGifts", params) @@ -94,9 +105,36 @@ func (api *API) GetUserGifts(params GetUserGifts) (OwnedGifts, error) { } // GetUserGiftsWithContext is the context-aware variant of GetUserGifts. +// Since: Bot API 9.3 // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getusergifts func (api *API) GetUserGiftsWithContext(ctx context.Context, params GetUserGifts) (OwnedGifts, error) { req := NewRequest[OwnedGifts]("getUserGifts", params) return req.DoWithContext(ctx, api) } + +// GetUserPersonalChatMessages holds parameters for the getUserPersonalChatMessages method. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#getuserpersonalchatmessages +type GetUserPersonalChatMessages struct { + UserID int64 `json:"user_id"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// GetUserPersonalChatMessages returns messages from the personal chat of the user with the bot. +// Since: Bot API 10.0 +// See https://core.telegram.org/bots/api#getuserpersonalchatmessages +func (api *API) GetUserPersonalChatMessages(params GetUserPersonalChatMessages) ([]Message, error) { + req := NewRequest[[]Message]("getUserPersonalChatMessages", params) + return req.Do(api) +} + +// GetUserPersonalChatMessagesWithContext is the context-aware variant of GetUserPersonalChatMessages. +// Since: Bot API 10.0 +// It executes the same request but uses ctx for cancellation and deadlines. +// See https://core.telegram.org/bots/api#getuserpersonalchatmessages +func (api *API) GetUserPersonalChatMessagesWithContext(ctx context.Context, params GetUserPersonalChatMessages) ([]Message, error) { + req := NewRequest[[]Message]("getUserPersonalChatMessages", params) + return req.DoWithContext(ctx, api) +} diff --git a/tgapi/users_types.go b/tgapi/users_types.go index 2079797..fb624d5 100644 --- a/tgapi/users_types.go +++ b/tgapi/users_types.go @@ -1,27 +1,31 @@ package tgapi // User represents a Telegram user or bot. +// Since: Bot API 1.0 // See https://core.telegram.org/bots/api#user type User struct { - ID int64 `json:"id"` - IsBot bool `json:"is_bot"` - FirstName string `json:"first_name"` - LastName *string `json:"last_name,omitempty"` - Username *string `json:"username,omitempty"` - LanguageCode *string `json:"language_code,omitempty"` - IsPremium *bool `json:"is_premium,omitempty"` - AddedToAttachmentMenu *bool `json:"added_to_attachment_menu,omitempty"` - CanJoinGroups *bool `json:"can_join_groups,omitempty"` - CanReadAllGroupMessages *bool `json:"can_read_all_group_messages,omitempty"` - CanManageBots *bool `json:"can_manage_bots,omitempty"` - SupportsInlineQueries *bool `json:"supports_inline_queries,omitempty"` - CanConnectToBusiness *bool `json:"can_connect_to_business,omitempty"` - HasMainWebApp *bool `json:"has_main_web_app,omitempty"` - HasTopicsEnabled *bool `json:"has_topics_enabled,omitempty"` - AllowsUsersToCreateTopics *bool `json:"allows_users_to_create_topics,omitempty"` + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName *string `json:"last_name,omitempty"` + Username *string `json:"username,omitempty"` + + IsBot bool `json:"is_bot"` // Since: Bot API 3.3 + LanguageCode *string `json:"language_code,omitempty"` // Since: Bot API 3.0 + IsPremium *bool `json:"is_premium,omitempty"` // Since: Bot API 6.1 + AddedToAttachmentMenu *bool `json:"added_to_attachment_menu,omitempty"` // Since: Bot API 6.1 + CanJoinGroups *bool `json:"can_join_groups,omitempty"` // Since: Bot API 4.6 + CanReadAllGroupMessages *bool `json:"can_read_all_group_messages,omitempty"` // Since: Bot API 4.6 + SupportsInlineQueries *bool `json:"supports_inline_queries,omitempty"` // Since: Bot API 4.6 + CanConnectToBusiness *bool `json:"can_connect_to_business,omitempty"` // Since: Bot API 7.2 + HasMainWebApp *bool `json:"has_main_web_app,omitempty"` // Since: Bot API 7.8 + HasTopicsEnabled *bool `json:"has_topics_enabled,omitempty"` // Since: Bot API 9.3 + AllowsUsersToCreateTopics *bool `json:"allows_users_to_create_topics,omitempty"` // Since: Bot API 9.4 + CanManageBots *bool `json:"can_manage_bots,omitempty"` // Since: Bot API 9.6 + SupportsGuestQueries *bool `json:"supports_guest_queries,omitempty"` // Since: Bot API 10.0 } // UserProfilePhotos represents a user's profile photos. +// Since: Bot API 1.4 // See https://core.telegram.org/bots/api#userprofilephotos type UserProfilePhotos struct { TotalCount int `json:"total_count"` @@ -29,6 +33,7 @@ type UserProfilePhotos struct { } // UserProfileAudios represents a user's profile audios. +// Since: Bot API 9.3 // See https://core.telegram.org/bots/api#userprofileaudios type UserProfileAudios struct { TotalCount int `json:"total_count"` @@ -36,6 +41,7 @@ type UserProfileAudios struct { } // UserRating represents a user's rating with level progression. +// Since: Bot API 9.3 // See https://core.telegram.org/bots/api#userrating type UserRating struct { Level int `json:"level"` @@ -45,6 +51,7 @@ type UserRating struct { } // Birthdate represents a user's birthdate. +// Since: Bot API 7.2 // See https://core.telegram.org/bots/api#birthdate type Birthdate struct { Day int `json:"day"` -- 2.54.0 From 61d0b1ebb824eacd32c991a37691c32ac22f54b7 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 20 May 2026 12:06:59 +0300 Subject: [PATCH 96/99] (fix): webhook secret constant-time compare (fix): observer rename, panic emits ErrorEvent (refactor): Runner.Once removed, Scene.PluginName unexported (doc): godoc hygiene pre-1.0.0 --- TODO.md | 49 +++++++++++++++++++++++++++++++++++++++++++ bot.go | 6 ++++-- bot_config.go | 7 +++++-- bot_opts_loader.go | 3 +++ bot_scene.go | 6 +++--- bot_test.go | 4 ++-- bot_webhook.go | 28 ++++++++++++++----------- bot_webhook_test.go | 10 ++++----- cmd_generator.go | 4 ---- commands.go | 4 ---- drafts.go | 7 ++----- errors.go | 15 +++++++++++++ handler.go | 12 +++++++++++ handler_test.go | 4 ++-- keyboard.go | 3 +-- methods.go | 16 ++++++++------ msg_context.go | 10 +-------- msg_handler.go | 7 ++++--- observer.go | 8 +++---- plugins.go | 14 +++++-------- runners.go | 31 ++++++++++----------------- runners_test.go | 4 ++-- scene.go | 42 +++++++++++++++++-------------------- scene_handler.go | 12 +++++------ scene_test.go | 6 +++--- tgapi/api.go | 2 -- tgapi/types.go | 1 + tgapi/uploader_api.go | 10 +++++---- utils/limiter.go | 4 ---- 29 files changed, 191 insertions(+), 138 deletions(-) diff --git a/TODO.md b/TODO.md index d5bc7d9..63099cf 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,54 @@ # TODO +## v1.0.0 pre-release review + +Findings from the full-repo review against `AGENTS.md` priorities. Build, vet, tests, and lint are clean; items below are public-API and godoc hygiene before the stable tag. + +### Major — close before 1.0.0 tag + +- [X] **M1. `BotPayloadType*` are `var`, must be `const`** — `bot.go:50-59`. Public sentinels are user-mutable globals. `KeyboardButtonStyle*` in `keyboard.go:10-17` already uses `const`; match the pattern. +- [X] **M2. `Observer` method naming asymmetry** — `observer.go:147-157`. `OnReceiveUpdate` → `OnUpdateReceived`; `OnHandledUpdate` → `OnUpdateHandled` to match `UpdateReceivedEvent` / `UpdateHandledEvent` and the rest of the `OnX` pattern. Breaking after 1.0. +- [X] **M3. Uploader returns ad-hoc error string instead of `*ResponseError`** — `tgapi/uploader_api.go:183`. `tgapi/api.go:258-292` returns `*ResponseError`; uploader must do the same so `errors.As(err, &tgapi.ResponseError{})` works for upload paths too. +- [X] **M4. `BotOptsFileJSON` is missing `PollTimeout`** — `bot_opts_loader.go:35-46`, plus `FromBytes`/`ToBytes` mapping. File round-trip silently drops `PollTimeout`. +- [X] **M5. Stale `Bot.Updates` godoc** — `methods.go:11-44`. Claims "30-second timeout" and "empty slice if none"; in reality timeout is `bot.pollTimeout` and the function returns `nil` on error. +- [X] **M6. Self-contradicting `NewRandomDraftProvider` godoc** — `drafts.go:50-59`. Says "cryptographically secure random numbers" but uses `math/rand/v2` (the underlying generator type correctly notes it is not crypto-secure). +- [X] **M7. `Draft.Delete` godoc says "internal method"** — `drafts.go:190-201`. Method is exported; either rewrite the godoc with a public-intent description or unexport. +- [X] **M8. Russian comments in production code** + - `msg_handler.go:28` — "Ищем команду по точному совпадению" + - `tgapi/uploader_api.go:181` — "Повторяем запрос" +- [X] **M9. `MessageContext.Error` godoc references unexported helper** — `msg_context.go:540`. "Error is an alias for error()" — rewrite to describe the centralized handler error path and `IsUserError` gating. +- [X] **M10. `Scene` and `SceneSession` mix exported fields with setters** + - `Scene` exports `Name/Scope/Entry/PluginName` and also has `SetScope/SetEntry`; `PluginName` is framework-assigned but publicly mutable. + - `SceneSession` exports `Data []byte` and also has `Set/Get/HasData/ClearData/BindData/SaveData`. + - Pick one model per type before 1.0.0. +- [X] **M11. Constant-time compare for webhook secret** — `bot_webhook.go:296` (update handler) and `bot_webhook.go:341` (`/status`). Use `subtle.ConstantTimeCompare`. + +### Minor — can slip to 1.0.x + +- [X] Strip `// Internal helper …` godoc from unexported funcs (~23 occurrences in repo); `AGENTS.md` explicitly forbids godoc-style comments on unexported declarations without a strong reason. +- [X] `Plugin.AddCommand` godoc references unexported field `.command` — `plugins.go:48-49`. +- [X] `Runner` builder naming: `runner.Once(true)`, `runner.Async(true)` read awkwardly; consider `SetOnce`/`SetAsync` to match `Set*` on other types, or zero-arg `Once()` + paired `Repeat(every)`. +- [X] Typo in webhook error string: `bot_webhook.go:143` — "MaxConnections must between 1 and 100" (missing `be`). +- [X] `RunWebhookWithContext` uses inline `errors.New(...)` instead of `Err*` sentinels (`bot_webhook.go:131-156`); rest of the package uses sentinels from `errors.go`. +- [X] `tgapi.UpdateTypeManagedBot` (`tgapi/types.go:61`) has no godoc. +- [X] `Bot.GetAPI`, `Bot.GetUploader`, `InlineKeyboard.GetMaxRow` have no godoc. +- [X] `Bot.L10n` godoc says "Returns empty string if translation not found"; actually returns the key (`l10n.go:48-59`). +- [X] `Bot.handle` panic recovery only logs — emit `ErrorEvent` so observers see panics (`handler.go:18-23`). +- [X] `handleCallback` vs `handleMessage` differ in plugin-logger assignment: callback assigns unconditionally then falls back to bot logger (`msg_handler.go:209-212`); message only assigns if non-nil (`msg_handler.go:35-37`). Align. +- [X] `SetCallbackData` godoc says "default payload type is JSON" — actually the zero `BotPayloadType` falls through to the `default` branch (which happens to be JSON). Either document the zero-value behavior explicitly or initialize the builder with the bot's default (`keyboard.go:106-122`). +- [X] `commands.go:62-66` — empty `case CommandValueAny:` next to `default: regex = nil` looks like an incomplete switch. Merge or add a one-line comment. +- [X] `Bot.SetDebug` does not call `configMutable` unlike sibling setters; if intentional, note it in godoc. + +### Tests to add after the fixes + +- `BotOptsFileJSON` round-trip for `PollTimeout` (after M4). +- Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3). +- `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix). +- Webhook `/status` with wrong `SecretToken` returns 403 / `403`-equivalent (after M11), incl. a constant-time-compare smoke. +- Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior. + +--- + The framework backlog has moved to the wiki. Primary page: diff --git a/bot.go b/bot.go index 6ee6e16..75dc0c4 100644 --- a/bot.go +++ b/bot.go @@ -47,7 +47,7 @@ type AppDataLogger[T AppData] func(data T) sneklog.LoggerWriter // BotPayloadType defines the serialization format for callback data payloads. type BotPayloadType string -var ( +const ( // BotPayloadBase64 encodes callback data as a Base64 string. BotPayloadBase64 BotPayloadType = "base64" // BotPayloadJSON encodes callback data as a JSON string. @@ -275,8 +275,10 @@ func (bot *Bot[T]) SetWebhookLogger(l *sneklog.Logger) *Bot[T] { return bot } +// GetAPI returns the underlying Telegram Bot API client. func (bot *Bot[T]) GetAPI() *tgapi.API { return bot.api } +// GetUploader returns the underlying file uploader client. func (bot *Bot[T]) GetUploader() *tgapi.Uploader { return bot.uploader } // Close gracefully shuts down bot-owned resources. @@ -385,7 +387,7 @@ func (bot *Bot[T]) GetLoggerLevel() sneklog.LogLevel { } // L10n translates a key in the given language. -// Returns empty string if translation not found. +// Returns key if translation not found. func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } diff --git a/bot_config.go b/bot_config.go index e5c329e..a87fcbc 100644 --- a/bot_config.go +++ b/bot_config.go @@ -125,7 +125,7 @@ func (bot *Bot[T]) GetAppData() T { return bot.appData } // SetUpdateTypes sets the list of update types the bot will request from Telegram. // Overwrites any previously set types. func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] { - if !bot.configMutable("UpdateTypes") { + if !bot.configMutable("SetUpdateTypes") { return bot } bot.updateTypes = make([]tgapi.UpdateType, 0) @@ -177,7 +177,7 @@ func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] { // Use "%s" to insert the error message. // Example: "❌ Error: %s" → "❌ Error: Command not found". func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] { - if !bot.configMutable("ErrorTemplate") { + if !bot.configMutable("SetErrorTemplate") { return bot } bot.errorTemplate = s @@ -186,6 +186,9 @@ func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] { // SetDebug enables or disables debug logging. func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { + if !bot.configMutable("SetDebug") { + return bot + } bot.debug = debug level := sneklog.FATAL if debug { diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 608a5a3..b3cf7cc 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -28,6 +28,7 @@ type botOptsFileJSONAPI struct { UseTestServer bool `json:"use_test_server"` APIURL string `json:"url"` RateLimit int `json:"rate_limit"` + PollTimeout int `json:"poll_timeout"` DropRLOverflow bool `json:"drop_overflow"` } @@ -73,6 +74,7 @@ func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) { UseTestServer: fileOpts.API.UseTestServer, APIURL: fileOpts.API.APIURL, RateLimit: fileOpts.API.RateLimit, + PollTimeout: fileOpts.API.PollTimeout, DropRateLimitOverflow: fileOpts.API.DropRLOverflow, StrictPayloadType: fileOpts.StrictPayloadType, @@ -102,6 +104,7 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) { UseTestServer: opts.UseTestServer, APIURL: opts.APIURL, RateLimit: opts.RateLimit, + PollTimeout: opts.PollTimeout, DropRLOverflow: opts.DropRateLimitOverflow, }, StrictPayloadType: opts.StrictPayloadType, diff --git a/bot_scene.go b/bot_scene.go index db71fad..e39b8c2 100644 --- a/bot_scene.go +++ b/bot_scene.go @@ -24,9 +24,9 @@ func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) { } return &sceneMeta{ - Name: scene.Name, - Scope: scene.Scope, - Entry: scene.Entry, + Name: scene.name, + Scope: scene.scope, + Entry: scene.entry, Steps: steps, }, true } diff --git a/bot_test.go b/bot_test.go index ade1071..baddfac 100644 --- a/bot_test.go +++ b/bot_test.go @@ -37,8 +37,8 @@ func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRet type testObserver struct{} -func (testObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {} -func (testObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {} +func (testObserver) OnUpdateReceived(context.Context, UpdateReceivedEvent) {} +func (testObserver) OnUpdateHandled(context.Context, UpdateHandledEvent) {} func (testObserver) OnHandlerStarted(context.Context, HandlerStartedEvent) {} func (testObserver) OnHandlerFinished(context.Context, HandlerFinishedEvent) { } diff --git a/bot_webhook.go b/bot_webhook.go index 1106686..5e0281f 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -2,6 +2,7 @@ package laniakea import ( "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -128,7 +129,7 @@ func (opts *BotWebhookOpts) SetSecretToken(secretToken string) *BotWebhookOpts { // argument order. func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOpts, tlsFiles ...string) error { if opts == nil { - return errors.New("nil BotWebhookOpts") + return ErrNilBotWebhookOpts } if len(bot.prefixes) == 0 { return ErrNoPrefixes @@ -137,28 +138,28 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp return ErrNoPlugins } if opts.URL == "" { - return errors.New("empty BotWebhookOpts.URL") + return ErrNoBotWebhookOptsURL } if opts.MaxConnections > 100 || opts.MaxConnections <= 0 { - return errors.New("BotWebhookOpts.MaxConnections must between 1 and 100") + return ErrBotWebhookOptsMaxConnectionsRange } if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { return err } if opts.UseStatusPath && opts.SecretToken == "" { - return errors.New("BotWebhookOpts.SecretToken required when status path is enabled") + return ErrStatusPathSecretRequired } if err := validateWebhookTLSFiles(tlsFiles); err != nil { return err } if opts.Certificate != nil && bot.uploader == nil { - return errors.New("bot uploader nil, but certificate set") + return ErrBotUploaderWhenCertificate } return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { if opts.SecretToken == "" { - bot.webhookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") + bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Anyone can simulate Telegram requests.") } i, err := bot.api.GetWebhookInfoWithContext(runCtx) @@ -284,7 +285,7 @@ func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Conte return runErr } -func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.HandlerFunc { +func updateHandler[T any](ctx context.Context, bot *Bot[T], secret []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { defer func() { _ = r.Body.Close() @@ -293,7 +294,9 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http. w.WriteHeader(http.StatusMethodNotAllowed) return } - if secret != "" && r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != secret { + provided := []byte(r.Header.Get("X-Telegram-Bot-Api-Secret-Token")) + + if len(secret) > 0 && subtle.ConstantTimeCompare(secret, provided) != 1 { w.WriteHeader(http.StatusForbidden) return } @@ -330,7 +333,7 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http. } } -func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc { +func statusHandler[T any](bot *Bot[T], secret []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { auth := "" if r.Header.Get("Authorization") != "" { @@ -338,7 +341,7 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc { } else if r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != "" { auth = r.Header.Get("X-Telegram-Bot-Api-Secret-Token") } - if auth != opts.SecretToken { + if len(secret) > 0 && subtle.ConstantTimeCompare(secret, []byte(auth)) != 1 { w.WriteHeader(http.StatusNotFound) return } @@ -363,11 +366,12 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc { } func (bot *Bot[T]) newWebhookMux(ctx context.Context, opts *BotWebhookOpts) *http.ServeMux { + token := []byte(opts.SecretToken) r := http.NewServeMux() if opts.UseStatusPath { - r.HandleFunc("/status", statusHandler(bot, opts)) + r.HandleFunc("/status", statusHandler(bot, token)) } - r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken)) + r.HandleFunc(opts.Path, updateHandler(ctx, bot, token)) return r } func (bot *Bot[T]) baseRunWebhook(ctx context.Context, opts *BotWebhookOpts, runFunc func(*http.Server, chan error)) error { diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 49d2019..88e90d1 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -46,7 +46,7 @@ func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "secret") rec := httptest.NewRecorder() - updateHandler(context.Background(), bot, "secret").ServeHTTP(rec, req) + updateHandler(context.Background(), bot, []byte("secret")).ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusOK { t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) @@ -94,7 +94,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { NewRunner("runner", func(bot *Bot[NoData]) error { calls.Add(1) return nil - }).Once(true).Async(false), + }).Async(false), }, } t.Cleanup(func() { @@ -157,7 +157,7 @@ func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":9,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"from":{"id":2,"is_bot":false,"first_name":"Test"},"text":"/start"}}`)) rec := httptest.NewRecorder() - updateHandler(ctx, bot, "").ServeHTTP(rec, req) + updateHandler(ctx, bot, []byte("")).ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusOK { t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) } @@ -267,7 +267,7 @@ func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1))) rec := httptest.NewRecorder() - updateHandler(context.Background(), bot, "").ServeHTTP(rec, req) + updateHandler(context.Background(), bot, []byte("")).ServeHTTP(rec, req) if rec.Result().StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusRequestEntityTooLarge) @@ -301,7 +301,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { _ = bot.webhookLogger.Close() }) - handler := statusHandler(bot, &BotWebhookOpts{SecretToken: "secret"}) + handler := statusHandler(bot, []byte("secret")) tests := []struct { name string diff --git a/cmd_generator.go b/cmd_generator.go index 3008d50..5cc3494 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -21,7 +21,6 @@ var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$") // bot initialization. var ErrTooManyCommands = errors.New("too many commands. max 100") -// Internal helper to build a BotCommand description with generated usage text. func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { desc := "" if len(cmd.description) > 0 { @@ -45,10 +44,8 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { return tgapi.BotCommand{Command: cmd.command, Description: usage} } -// Internal helper to validate Telegram command names. func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) } -// Internal helper to collect non-skipped, valid commands from one plugin. func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) names := make([]string, 0, len(pl.commands)) @@ -70,7 +67,6 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { return commands } -// Internal helper to collect all auto-generated commands from registered plugins. func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, pl := range bot.plugins { diff --git a/commands.go b/commands.go index 7e86ee2..c333b3b 100644 --- a/commands.go +++ b/commands.go @@ -66,9 +66,6 @@ func (c CommandArg) SetValueType(t CommandValueType) CommandArg { regex = CommandRegexBool case CommandValueString: regex = CommandRegexString - case CommandValueAny: - default: - regex = nil // Skip validation } c.valueType = t c.regex = regex @@ -133,7 +130,6 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] { return c } -// Internal helper that validates provided command arguments. func (c *Command[T]) validateArgs(args []string) error { for i := range c.args.Len() { if i >= len(args) && c.args.Get(i).required { diff --git a/drafts.go b/drafts.go index 7c43826..d0a9638 100644 --- a/drafts.go +++ b/drafts.go @@ -49,7 +49,7 @@ type DraftProvider struct { // NewRandomDraftProvider creates a new DraftProvider using random draft IDs. // -// The provider will use cryptographically secure random numbers for draft IDs. +// The provider will use random numbers for draft IDs. // All drafts created via this provider will have unpredictable, unique IDs. func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { return &DraftProvider{ @@ -189,8 +189,7 @@ func (d *Draft) Clear() { // Delete removes the draft from its provider and clears its content. // -// This is an internal method used by Flush(). You may call it manually if you -// want to cancel a draft without sending it. +// You may call it manually if you want to cancel a draft without sending it. func (d *Draft) Delete() { if d.provider != nil { d.provider.mu.Lock() @@ -241,8 +240,6 @@ func (d *Draft) Flush() error { return err } -// Internal helper for Push that updates the server-side draft. -// // The candidate Message (current content + new text) is validated before any // mutation, so a validation failure leaves the draft unchanged. After the // validation passes, Message is committed locally regardless of whether the diff --git a/errors.go b/errors.go index 3132155..c214aa1 100644 --- a/errors.go +++ b/errors.go @@ -38,6 +38,8 @@ var ( ErrAPIIsNil = errors.New("api is nil") // ErrMessageIDZero reports that an operation requires a non-zero message ID. ErrMessageIDZero = errors.New("message ID is zero") +) +var ( // ErrBindArgsTargetNotPointer reports that BindArgs received a nil or non-pointer destination. ErrBindArgsTargetNotPointer = errors.New("bind args: dst must be a non-nil pointer") // ErrBindArgsTargetNotStruct reports that BindArgs received a pointer to a non-struct value. @@ -60,6 +62,19 @@ var ( ErrSceneRuntimeNil = errors.New("scene runtime is nil") ) +var ( + // ErrNilBotWebhookOpts reports that a nil BotWebhookOpts was passed. + ErrNilBotWebhookOpts = errors.New("nil BotWebhookOpts") + // ErrNoBotWebhookOptsURL reports that BotWebhookOpts.URL is empty. + ErrNoBotWebhookOptsURL = errors.New("empty BotWebhookOpts.URL") + // ErrBotWebhookOptsMaxConnectionsRange reports that BotWebhookOpts.MaxConnections is out of range. + ErrBotWebhookOptsMaxConnectionsRange = errors.New("BotWebhookOpts.MaxConnections must be between 1 and 100") + // ErrBotUploaderWhenCertificate reports that a certificate was set without an uploader. + ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set") + // ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set. + ErrStatusPathSecretRequired = errors.New("SecretToken required when UseStatusPath is enabled") +) + func validateMessageText(text string) error { length := utf8.RuneCountInString(text) switch { diff --git a/handler.go b/handler.go index 7d37a77..ce9012a 100644 --- a/handler.go +++ b/handler.go @@ -19,6 +19,18 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { defer func() { if r := recover(); r != nil { bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) + + var err error + var ok bool + if err, ok = r.(error); !ok { + err = fmt.Errorf("%v", r) + } + bot.safeEmitEvent(parentCtx, ErrorEvent{ + UpdateID: u.UpdateID, + UpdateType: u.Type, + Err: err, + UserFacing: false, + }) } }() startTime := time.Now() diff --git a/handler_test.go b/handler_test.go index ae4b963..97c093c 100644 --- a/handler_test.go +++ b/handler_test.go @@ -20,10 +20,10 @@ type recordingObserver struct { retries []PollingRetryEvent } -func (o *recordingObserver) OnReceiveUpdate(_ context.Context, ev UpdateReceivedEvent) { +func (o *recordingObserver) OnUpdateReceived(_ context.Context, ev UpdateReceivedEvent) { o.received = append(o.received, ev) } -func (o *recordingObserver) OnHandledUpdate(_ context.Context, ev UpdateHandledEvent) { +func (o *recordingObserver) OnUpdateHandled(_ context.Context, ev UpdateHandledEvent) { o.handled = append(o.handled, ev) } func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) { diff --git a/keyboard.go b/keyboard.go index 52a03a7..58e9d2d 100644 --- a/keyboard.go +++ b/keyboard.go @@ -121,7 +121,6 @@ func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) In return b } -// Internal helper that converts the builder state into a Telegram button. func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton { return tgapi.InlineKeyboardButton{ Text: b.text, @@ -203,9 +202,9 @@ func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard { return in } +// GetMaxRow returns the maximum number of buttons per row. func (in *InlineKeyboard) GetMaxRow() int { return in.maxRow } -// Internal helper that appends a button and auto-flushes a full row. func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { if in.CurrentLine.Len() == in.maxRow { in.AddLine() diff --git a/methods.go b/methods.go index e970d8b..fd20dcb 100644 --- a/methods.go +++ b/methods.go @@ -22,15 +22,15 @@ import ( // // Behavior: // 1. Uses the bot's current update offset (via GetUpdateOffset) -// 2. Requests updates with 30-second timeout +// 2. Requests updates with the timeout configured via PollTimeout // 3. Filters updates by types specified in bot.GetUpdateTypes() // 4. Logs raw update JSON if RequestLogger is configured // 5. Automatically updates the offset to the last received update ID + 1 // 6. Returns all received updates (empty slice if none) // -// Note: This is a blocking call that waits up to 30 seconds for new updates, -// unless ctx is canceled earlier. For non-blocking behavior, consider using -// webhooks instead. +// Note: This is a blocking call that waits up to the configured PollTimeout +// for new updates, unless ctx is canceled earlier. For non-blocking behavior, +// consider using webhooks instead. // // Example: // @@ -50,9 +50,10 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { AllowedUpdates: bot.GetUpdateTypes(), } + zero := make([]tgapi.Update, 0) updates, err := bot.api.GetUpdatesWithContext(ctx, params) if err != nil { - return nil, err + return zero, err } if bot.requestLogger != nil { @@ -67,7 +68,10 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { if len(updates) > 0 { bot.SetUpdateOffset(updates[len(updates)-1].UpdateID + 1) } - return updates, err + if updates == nil { + return zero, nil + } + return updates, nil } // UpdatesIter fetches updates once and yields each update in order. diff --git a/msg_context.go b/msg_context.go index 6a25968..ea6cfcc 100644 --- a/msg_context.go +++ b/msg_context.go @@ -95,7 +95,6 @@ type AnswerMessage struct { ctx *MessageContext // internal back-reference } -// Internal helper for text edits with optional keyboard and parse mode. func (ctx *MessageContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateMessageText(text); err != nil { ctx.Logger.Errorln(err) @@ -146,7 +145,6 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2) } -// Internal helper for editing callback-linked messages. func (ctx *MessageContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" { ctx.Logger.Errorln(ErrCallbackMessageMissing) @@ -179,7 +177,6 @@ func (ctx *MessageContext) EditCallbackfMarkdown(format string, keyboard *Inline return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2) } -// Internal helper for media-caption edits. func (ctx *MessageContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateCaptionText(text); err != nil { ctx.Logger.Errorln(err) @@ -241,7 +238,6 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2) } -// Internal helper for message replies with optional keyboard and parse mode. func (ctx *MessageContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) @@ -371,7 +367,6 @@ func (ctx *MessageContext) answerLong(text string, keyboard *InlineKeyboard, par return messages } -// Internal helper for photo replies with optional caption and keyboard. func (ctx *MessageContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageContextNil) @@ -443,7 +438,6 @@ func (ctx *MessageContext) AnswerPhotofMarkdown(photoID, template string, args . return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) } -// Internal helper that deletes a message by ID. func (ctx *MessageContext) delete(messageID int) { if messageID == 0 { ctx.Logger.Errorln(ErrMessageIDZero) @@ -474,7 +468,6 @@ func (ctx *MessageContext) CallbackDelete() { ctx.delete(ctx.CallbackMsgID) } -// Internal helper that answers a callback query with optional text, alert, or URL. func (ctx *MessageContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryID) == 0 { return @@ -518,7 +511,6 @@ func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) { } } -// Internal helper that formats, sends, and logs an error. func (ctx *MessageContext) error(err error) { if err == nil { return @@ -536,7 +528,7 @@ func (ctx *MessageContext) error(err error) { } } -// Error is an alias for error(). +// Error routes err through the centralized handler error path… func (ctx *MessageContext) Error(err error) { ctx.error(err) } func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft { diff --git a/msg_handler.go b/msg_handler.go index 9baad5e..7a9b3b7 100644 --- a/msg_handler.go +++ b/msg_handler.go @@ -25,15 +25,16 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MessageContext) bool cmd = cmd[:len(cmd)-len("@"+botUsername)] // remove @botname } } - // Ищем команду по точному совпадению + for _, plugin := range bot.plugins { if _, exists := plugin.commands[cmd]; exists { ctx.Text = args ctx.Args = strings.Fields(args) + ctx.Logger = plugin.logger - if plugin.logger != nil { - ctx.Logger = plugin.logger + if ctx.Logger == nil { + ctx.Logger = bot.logger } if !plugin.executeMiddlewares(ctx, bot.appData) { return false diff --git a/observer.go b/observer.go index 205ccfb..4ea52e1 100644 --- a/observer.go +++ b/observer.go @@ -145,8 +145,8 @@ func (ErrorEvent) isEvent() {} // Observer receives best-effort runtime instrumentation events. type Observer interface { - OnReceiveUpdate(ctx context.Context, event UpdateReceivedEvent) - OnHandledUpdate(ctx context.Context, event UpdateHandledEvent) + OnUpdateReceived(ctx context.Context, event UpdateReceivedEvent) + OnUpdateHandled(ctx context.Context, event UpdateHandledEvent) OnHandlerStarted(ctx context.Context, event HandlerStartedEvent) OnHandlerFinished(ctx context.Context, event HandlerFinishedEvent) OnSceneTransition(ctx context.Context, event SceneTransitionEvent) @@ -167,9 +167,9 @@ func (bot *Bot[T]) safeEmitEvent(ctx context.Context, event Event) { }() switch e := event.(type) { case UpdateReceivedEvent: - bot.observer.OnReceiveUpdate(ctx, e) + bot.observer.OnUpdateReceived(ctx, e) case UpdateHandledEvent: - bot.observer.OnHandledUpdate(ctx, e) + bot.observer.OnUpdateHandled(ctx, e) case HandlerStartedEvent: bot.observer.OnHandlerStarted(ctx, e) case HandlerFinishedEvent: diff --git a/plugins.go b/plugins.go index a7074f6..95f477f 100644 --- a/plugins.go +++ b/plugins.go @@ -46,7 +46,6 @@ func NewPlugin[T AppData](name string) *Plugin[T] { } // AddCommand registers a command in the plugin. -// The command's .command field is used as the key. func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { if command == nil { if p.logger != nil { @@ -55,7 +54,7 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { return p } if _, exists := p.commands[command.command]; exists && p.logger != nil { - p.logger.Warnf("command '%s' is already registered in plugin '%s'; overwriting", command.command, p.name) + p.logger.Warnf("command '%s' already registered in plugin '%s'; overwriting", command.command, p.name) } p.commands[command.command] = command return p @@ -106,12 +105,12 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { if scene == nil { return p } - scene.PluginName = p.name + scene.pluginName = p.name scene.setPluginName(p.name) - if _, exists := p.scenes[scene.Name]; exists && p.logger != nil { - p.logger.Warnf("scene '%s' is already registered in plugin '%s'; overwriting", scene.Name, p.name) + if _, exists := p.scenes[scene.name]; exists && p.logger != nil { + p.logger.Warnf("scene '%s' is already registered in plugin '%s'; overwriting", scene.name, p.name) } - p.scenes[scene.Name] = scene + p.scenes[scene.name] = scene return p } @@ -238,7 +237,6 @@ func (p *Plugin[T]) Close() error { return errors.Join(e...) } -// Internal helper that validates and executes a command handler. func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error { command, exists := p.commands[cmd] if !exists { @@ -260,7 +258,6 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error { return command.exec(ctx, db) } -// Internal helper that validates and executes a payload handler. func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) error { command, exists := p.payloads[payload] if !exists { @@ -282,7 +279,6 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) er return command.exec(ctx, db) } -// Internal helper that runs plugin middlewares in order. func (p *Plugin[T]) executeMiddlewares(ctx *MessageContext, db T) bool { for _, m := range p.middlewares { if !m.Execute(ctx, db) { diff --git a/runners.go b/runners.go index 883280b..b45ea16 100644 --- a/runners.go +++ b/runners.go @@ -16,20 +16,19 @@ type RunnerFn[T AppData] func(*Bot[T]) error // Once Execute() is called, the Runner should not be modified. // // Execution semantics: -// - once=true, async=false: Run once synchronously (blocks). -// - once=true, async=true: Run once in a goroutine (non-blocking). -// - once=false, async=true: Run repeatedly in a goroutine with timeout. -// - once=false, async=false: Invalid configuration — ignored with warning. +// - every=0, async=false: Run once synchronously (blocks). +// - every=0, async=true: Run once in a goroutine (non-blocking). +// - every>0, async=true: Run repeatedly in a goroutine with timeout. +// - every>0, async=false: Invalid configuration — ignored with warning. type Runner[T AppData] struct { name string // Human-readable name for logging - once bool // If true, runs once; if false, runs periodically async bool // If true, runs in a goroutine; else, runs synchronously every time.Duration // Duration to wait between periodic executions (ignored if once=true) fn RunnerFn[T] // The function to execute } // NewRunner creates a new Runner with the given name and function. -// By default, the Runner is configured as async=true (non-blocking). +// By default, the Runner is configured as async=true (non-blocking), once=true/mo // // Builder methods (Once, Async, Every) can be chained to customize behavior. // DO NOT call builder methods concurrently or after Execute(). @@ -38,18 +37,10 @@ func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { name: name, fn: fn, async: true, // Default: run asynchronously - every: 0, // Default: no timeout (ignored if once=true) + every: 0, // Default: 0 - one time } } -// Once sets whether the runner executes once or repeatedly. -// If true, the runner runs only once. -// If false, the runner runs in a loop with the configured timeout. -func (r Runner[T]) Once(once bool) Runner[T] { - r.once = once - return r -} - // Async sets whether the runner executes synchronously or asynchronously. // If true, the runner runs in a goroutine (non-blocking). // If false, the runner blocks the caller during execution. @@ -94,16 +85,16 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { // Validate configuration - if !runner.once && !runner.async { + if runner.every > 0 && !runner.async { bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name) continue } - if !runner.once && runner.async && runner.every == 0 { + if runner.every > 0 && runner.async && runner.every == 0 { bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name) continue } - if runner.once && runner.async { + if runner.every == 0 && runner.async { // One-time async: fire and forget bot.runnerOnceWG.Add(1) go func(r Runner[T]) { @@ -126,7 +117,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } }(runner) - } else if runner.once && !runner.async { + } else if runner.every == 0 && !runner.async { // One-time sync: block until done t := time.Now() err := runner.fn(bot) @@ -149,7 +140,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { if elapsed > time.Second*2 { bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) } - } else if !runner.once && runner.async { + } else if runner.every > 0 && runner.async { // Background loop: periodic execution with graceful shutdown bot.runnerBgWG.Add(1) go func(r Runner[T]) { diff --git a/runners_test.go b/runners_test.go index 28a8b30..496df4d 100644 --- a/runners_test.go +++ b/runners_test.go @@ -22,7 +22,7 @@ func TestExecRunnersRunsOnceSyncRunner(t *testing.T) { NewRunner("sync-once", func(*Bot[NoData]) error { calls.Add(1) return nil - }).Once(true).Async(false), + }).Async(false), }, } @@ -76,7 +76,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) { runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { return wantErr - }).Once(true).Async(false), + }).Async(false), }, } diff --git a/scene.go b/scene.go index c083253..1bd17a4 100644 --- a/scene.go +++ b/scene.go @@ -11,14 +11,10 @@ type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error) // Scene defines a multi-step conversational flow. type Scene[T any] struct { - // Name identifies the scene in plugin registration and session state. - Name string - // Scope controls how active scene sessions are keyed and shared. - Scope SceneScope - // Entry names the first step used by MessageContext.EnterScene. - Entry string - // PluginName stores the owning plugin name for scene resolution. - PluginName string + name string + scope SceneScope + entry string + pluginName string steps map[string]SceneHandler[T] commands map[string]SceneHandler[T] @@ -29,9 +25,9 @@ type Scene[T any] struct { // NewScene creates a new scene with user-chat scope by default. func NewScene[T any](name string) *Scene[T] { return &Scene[T]{ - Name: name, - Scope: SceneScopeUserChat, - Entry: "", + name: name, + scope: SceneScopeUserChat, + entry: "", steps: make(map[string]SceneHandler[T]), commands: make(map[string]SceneHandler[T]), payloads: make(map[string]SceneHandler[T]), @@ -41,18 +37,18 @@ func NewScene[T any](name string) *Scene[T] { // SetScope changes how scene sessions are keyed and shared. func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { - s.Scope = scope + s.scope = scope return s } // SetEntry sets the initial step entered by MessageContext.EnterScene. func (s *Scene[T]) SetEntry(step string) *Scene[T] { - s.Entry = step + s.entry = step return s } func (s *Scene[T]) setPluginName(name string) *Scene[T] { - s.PluginName = name + s.pluginName = name return s } @@ -135,36 +131,36 @@ type SceneSession struct { Scene string // Step is the current step name inside the active scene. Step string - // Data stores opaque session payload bytes, typically JSON. - Data []byte + // data stores opaque session payload bytes, typically JSON. + data []byte } // SetData stores arbitrary opaque session data. func (s *SceneSession) SetData(data []byte) { - s.Data = data + s.data = data } // GetData returns the raw session data payload. func (s *SceneSession) GetData() []byte { - return s.Data + return s.data } // HasData reports whether the session has a non-empty data payload. func (s *SceneSession) HasData() bool { - return len(s.Data) > 0 + return len(s.data) > 0 } // ClearData removes any stored session data. func (s *SceneSession) ClearData() { - s.Data = nil + s.data = nil } // BindData unmarshals the stored JSON payload into v. func (s *SceneSession) BindData(v any) error { - if len(s.Data) == 0 { + if len(s.data) == 0 { return nil } - return json.Unmarshal(s.Data, v) + return json.Unmarshal(s.data, v) } // SaveData marshals v as JSON and stores it in the session. @@ -173,7 +169,7 @@ func (s *SceneSession) SaveData(v any) error { if err != nil { return err } - s.Data = data + s.data = data return nil } diff --git a/scene_handler.go b/scene_handler.go index fb22545..65ebdf9 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -24,7 +24,7 @@ func (bot *Bot[T]) tryHandleScene(ctx *MessageContext) (bool, error) { if !ok { continue } - if scene.PluginName != "" && scene.PluginName != plugin.name { + if scene.pluginName != "" && scene.pluginName != plugin.name { continue } if !plugin.executeMiddlewares(ctx, bot.appData) { @@ -176,7 +176,7 @@ func (bot *Bot[T]) emitSceneStarted(ctx *SceneContext, scene *Scene[T], kind Han bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{ UpdateID: ctx.Update.UpdateID, UpdateType: ctx.Update.Type, - Plugin: scene.PluginName, + Plugin: scene.pluginName, HandlerKind: kind, HandlerName: name, FromID: ctx.FromID, @@ -188,7 +188,7 @@ func (bot *Bot[T]) emitSceneFinished(ctx *SceneContext, scene *Scene[T], kind Ha bot.safeEmitEvent(ctx.Context(), HandlerFinishedEvent{ UpdateID: ctx.Update.UpdateID, UpdateType: ctx.Update.Type, - Plugin: scene.PluginName, + Plugin: scene.pluginName, HandlerKind: kind, HandlerName: name, FromID: ctx.FromID, @@ -203,7 +203,7 @@ func (bot *Bot[T]) emitSceneError(ctx *SceneContext, scene *Scene[T], kind Handl bot.safeEmitEvent(ctx.Context(), ErrorEvent{ UpdateID: ctx.Update.UpdateID, UpdateType: ctx.Update.Type, - Plugin: scene.PluginName, + Plugin: scene.pluginName, HandlerKind: kind, HandlerName: name, FromID: ctx.FromID, @@ -229,8 +229,8 @@ func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from } bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{ - Plugin: scene.PluginName, - Scene: scene.Name, + Plugin: scene.pluginName, + Scene: scene.name, From: from, To: to, Action: result.Action, diff --git a/scene_test.go b/scene_test.go index a641f03..db34f82 100644 --- a/scene_test.go +++ b/scene_test.go @@ -36,8 +36,8 @@ func TestPluginAddSceneRegistersScene(t *testing.T) { if got, ok := plugin.scenes["signup"]; !ok || got != scene { t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene) } - if scene.PluginName != "wizard" { - t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.PluginName, "wizard") + if scene.pluginName != "wizard" { + t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.pluginName, "wizard") } } @@ -401,7 +401,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { if !ok { t.Fatal("expected scene key to be built") } - if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.Name}); err != nil { + if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.name}); err != nil { t.Fatalf("failed to seed scene session: %v", err) } diff --git a/tgapi/api.go b/tgapi/api.go index 73db648..78a9e0c 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -328,13 +328,11 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) { return r.DoWithContext(context.Background(), api) } -// Internal helper that reads and caps a Telegram response body. func readBody(body io.ReadCloser) ([]byte, error) { reader := io.LimitReader(body, 10<<20) // 10 MB return io.ReadAll(reader) } -// Internal helper that parses a typed Telegram API response body. func parseBody[R any](data []byte) (TelegramResponse[R], error) { var resp TelegramResponse[R] err := json.Unmarshal(data, &resp) diff --git a/tgapi/types.go b/tgapi/types.go index 901ece6..291fa2d 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -58,6 +58,7 @@ const ( // UpdateTypeRemovedChatBoost is a removed chat boost update. UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost" + // UpdateTypeManagedBot is a managed bot update. UpdateTypeManagedBot UpdateType = "managed_bot" // UpdateTypeGuestMessage is a guest message update. diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 17eb6a4..e02b3e5 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -177,10 +177,14 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, case <-ctx.Done(): return zero, ctx.Err() case <-time.After(time.Duration(after) * time.Second): - continue // Повторяем запрос + continue } } - return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) + return zero, &ResponseError{ + Code: response.ErrorCode, + Description: response.Description, + Parameters: response.Parameters, + } } return response.Result, nil } @@ -218,7 +222,6 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) { return r.DoWithContext(context.Background(), up) } -// Internal helper that builds a finalized multipart body from files and params. func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { buf := bytes.NewBuffer(nil) w := multipart.NewWriter(buf) @@ -251,7 +254,6 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str return buf, w.FormDataContentType(), nil } -// Internal helper that infers an upload field name from a file extension. func uploaderTypeByExt(filename string) UploaderFileType { ext := strings.ToLower(filepath.Ext(filename)) switch ext { diff --git a/utils/limiter.go b/utils/limiter.go index 698d692..c38c1a5 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -134,7 +134,6 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { return chatLimiter.Wait(ctx) } -// Internal helper that returns the global limiter under read lock. func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter { rl.globalMu.RLock() defer rl.globalMu.RUnlock() @@ -222,7 +221,6 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6 return nil } -// Internal helper that waits for the global cooldown to expire. func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { rl.globalMu.RLock() until := rl.globalLockUntil @@ -240,7 +238,6 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { } } -// Internal helper that waits for a chat-specific cooldown to expire. func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { rl.chatMu.RLock() until, ok := rl.chatLocks[chatID] @@ -258,7 +255,6 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro } } -// Internal helper that returns or creates a per-chat limiter. // Updates chatLastSeen so Cleanup can evict idle entries. func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { now := time.Now() -- 2.54.0 From 8a3f2cedf24b1dea6520ee384f8cb44e87b720d9 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 20 May 2026 12:30:44 +0300 Subject: [PATCH 97/99] =?UTF-8?q?(new):=20webhook=20+=20payload=20error=20?= =?UTF-8?q?sentinels=20=20=20(fix):=20polling=20panic=20ErrorEvent,=20nil-?= =?UTF-8?q?logger=20guard=20=20=20(refactor):=20inline=20webhook=20errors?= =?UTF-8?q?=20=E2=86=92=20sentinels,=20dead=20Runner=20branch=20=20=20(doc?= =?UTF-8?q?):=20Runner=20godoc,=20Error=20godoc,=20drafts=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.go | 11 ++++++++ bot_webhook.go | 16 +++++------ drafts.go | 2 -- errors.go | 16 +++++++++++ handler.go | 15 +++++++---- msg_context.go | 9 +++++-- plugins.go | 3 +-- runners.go | 62 +++++++++++++++++-------------------------- tgapi/uploader_api.go | 6 ++--- 9 files changed, 81 insertions(+), 59 deletions(-) diff --git a/bot.go b/bot.go index 75dc0c4..31f2177 100644 --- a/bot.go +++ b/bot.go @@ -449,6 +449,17 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { defer func() { if r := recover(); r != nil { bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r)) + err, ok := r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + bot.safeEmitEvent(ctx, ErrorEvent{ + Plugin: "bot", + HandlerKind: HandlerPollingKind, + HandlerName: "getUpdates", + Err: err, + UserFacing: false, + }) } close(bot.updateQueue) }() diff --git a/bot_webhook.go b/bot_webhook.go index 5e0281f..74231ec 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -202,7 +202,7 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp return err } if !ok { - return errors.New("failed to set webhook") + return ErrSetWebhookFailed } if len(tlsFiles) == 2 { @@ -227,7 +227,7 @@ func (bot *Bot[T]) RunWebhook(opts *BotWebhookOpts, tlsFiles ...string) error { func (bot *Bot[T]) CloseWebhook() error { var e []error if bot.api == nil { - e = append(e, errors.New("bot api nil")) + e = append(e, ErrBotAPINil) } else { if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil { if bot.webhookLogger != nil { @@ -423,16 +423,16 @@ func (bot *Bot[T]) runWebhookTLS(ctx context.Context, opts *BotWebhookOpts, key, } func validateWebhookPath(path string, useStatusPath bool) error { if path == "" { - return errors.New("empty BotWebhookOpts.Path") + return ErrBotWebhookOptsEmptyPath } if !strings.HasPrefix(path, "/") { - return errors.New("BotWebhookOpts.Path must start with '/'") + return ErrBotWebhookOptsPathNoSlash } if strings.Contains(path, "?") || strings.Contains(path, "#") { - return errors.New("BotWebhookOpts.Path must not contain query or fragment") + return ErrBotWebhookOptsPathHasQueryOrFragment } if useStatusPath && path == "/status" { - return errors.New("BotWebhookOpts.Path must not be '/status' when status path is enabled") + return ErrBotWebhookOptsPathCollidesStatus } return nil } @@ -442,8 +442,8 @@ func validateWebhookTLSFiles(tlsFiles []string) error { case 0, 2: return nil case 1: - return errors.New("you must specify both private and public keys") + return ErrBotWebhookTLSFilesIncomplete default: - return errors.New("too many files; you must specify only private and public keys") + return ErrBotWebhookTLSFilesTooMany } } diff --git a/drafts.go b/drafts.go index d0a9638..af1fe57 100644 --- a/drafts.go +++ b/drafts.go @@ -8,9 +8,7 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -// Interface for generating unique draft IDs. type draftIDGenerator interface { - // Next returns the next unique draft ID. Next() uint64 } diff --git a/errors.go b/errors.go index c214aa1..8077c7f 100644 --- a/errors.go +++ b/errors.go @@ -73,6 +73,22 @@ var ( ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set") // ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set. ErrStatusPathSecretRequired = errors.New("SecretToken required when UseStatusPath is enabled") + // ErrSetWebhookFailed reports that Telegram rejected the setWebhook request. + ErrSetWebhookFailed = errors.New("failed to set webhook") + // ErrBotAPINil reports that an operation requires an API client but none is set. + ErrBotAPINil = errors.New("bot api is nil") + // ErrBotWebhookOptsEmptyPath reports that BotWebhookOpts.Path is empty. + ErrBotWebhookOptsEmptyPath = errors.New("empty BotWebhookOpts.Path") + // ErrBotWebhookOptsPathNoSlash reports that BotWebhookOpts.Path does not start with '/'. + ErrBotWebhookOptsPathNoSlash = errors.New("BotWebhookOpts.Path must start with '/'") + // ErrBotWebhookOptsPathHasQueryOrFragment reports that BotWebhookOpts.Path contains a query or fragment. + ErrBotWebhookOptsPathHasQueryOrFragment = errors.New("BotWebhookOpts.Path must not contain query or fragment") + // ErrBotWebhookOptsPathCollidesStatus reports that BotWebhookOpts.Path collides with the reserved /status endpoint. + ErrBotWebhookOptsPathCollidesStatus = errors.New("BotWebhookOpts.Path must not be '/status' when status path is enabled") + // ErrBotWebhookTLSFilesIncomplete reports that only one of the two TLS files was provided. + ErrBotWebhookTLSFilesIncomplete = errors.New("you must specify both private and public keys") + // ErrBotWebhookTLSFilesTooMany reports that more than two TLS files were provided. + ErrBotWebhookTLSFilesTooMany = errors.New("too many files; you must specify only private and public keys") ) func validateMessageText(text string) error { diff --git a/handler.go b/handler.go index ce9012a..eefee82 100644 --- a/handler.go +++ b/handler.go @@ -15,14 +15,19 @@ import ( // ErrInvalidPayloadType is returned when callback payload encoding type is unknown. var ErrInvalidPayloadType = errors.New("invalid payload type") +// ErrInvalidPayload reports that a callback payload could not be decoded under the +// expected encoding (e.g. the compact format separator is missing). +var ErrInvalidPayload = errors.New("invalid payload") + func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { defer func() { if r := recover(); r != nil { - bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) + if bot.logger != nil { + bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) + } - var err error - var ok bool - if err, ok = r.(error); !ok { + err, ok := r.(error) + if !ok { err = fmt.Errorf("%v", r) } bot.safeEmitEvent(parentCtx, ErrorEvent{ @@ -232,7 +237,7 @@ func decodeCompactPayload(s string) (CallbackData, error) { } } if sepIdx == -1 { - return CallbackData{}, errors.New("invalid payload") + return CallbackData{}, ErrInvalidPayload } cmd := decodeCompactPart(s[:sepIdx]) argsRaw := s[sepIdx+1:] diff --git a/msg_context.go b/msg_context.go index ea6cfcc..e95dc2b 100644 --- a/msg_context.go +++ b/msg_context.go @@ -496,7 +496,7 @@ func (ctx *MessageContext) AnswerCallbackURL(u string) { ctx.answerCallbackQuery // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) { if ctx.Msg == nil { - ctx.Logger.Errorln("Can't send action without chat message context") + ctx.Logger.Errorln(ErrMessageContextNil) return } params := tgapi.SendChatAction{ @@ -528,7 +528,12 @@ func (ctx *MessageContext) error(err error) { } } -// Error routes err through the centralized handler error path… +// Error routes err through the centralized handler error path. +// +// The error is logged via ctx.Logger. When IsUserError(err) is true, the +// formatted error template is delivered to the user — through an answer +// to the active callback query when one exists, otherwise as a chat reply. +// Internal errors are logged but not surfaced to the user. func (ctx *MessageContext) Error(err error) { ctx.error(err) } func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft { diff --git a/plugins.go b/plugins.go index 95f477f..122a137 100644 --- a/plugins.go +++ b/plugins.go @@ -106,9 +106,8 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { return p } scene.pluginName = p.name - scene.setPluginName(p.name) if _, exists := p.scenes[scene.name]; exists && p.logger != nil { - p.logger.Warnf("scene '%s' is already registered in plugin '%s'; overwriting", scene.name, p.name) + p.logger.Warnf("scene '%s' already registered in plugin '%s'; overwriting", scene.name, p.name) } p.scenes[scene.name] = scene return p diff --git a/runners.go b/runners.go index b45ea16..f6ec4a1 100644 --- a/runners.go +++ b/runners.go @@ -12,32 +12,33 @@ type RunnerFn[T AppData] func(*Bot[T]) error // Runner represents a configurable background or one-time task to be // executed by a Bot. // -// Runners are configured using builder methods: Once(), Async(), Every(). -// Once Execute() is called, the Runner should not be modified. +// Runners are configured using builder methods Async and Every. Once the +// bot's runtime has started executing the runner, it should not be modified. // // Execution semantics: -// - every=0, async=false: Run once synchronously (blocks). -// - every=0, async=true: Run once in a goroutine (non-blocking). -// - every>0, async=true: Run repeatedly in a goroutine with timeout. -// - every>0, async=false: Invalid configuration — ignored with warning. +// - every=0, async=true: Run once in a goroutine (non-blocking, default). +// - every=0, async=false: Run once synchronously (blocks runtime startup). +// - every>0, async=true: Run repeatedly in a goroutine with the given interval. +// - every>0, async=false: Invalid configuration — skipped with a warning. type Runner[T AppData] struct { name string // Human-readable name for logging async bool // If true, runs in a goroutine; else, runs synchronously - every time.Duration // Duration to wait between periodic executions (ignored if once=true) + every time.Duration // Interval between periodic executions; zero means one-shot fn RunnerFn[T] // The function to execute } // NewRunner creates a new Runner with the given name and function. -// By default, the Runner is configured as async=true (non-blocking), once=true/mo // -// Builder methods (Once, Async, Every) can be chained to customize behavior. -// DO NOT call builder methods concurrently or after Execute(). +// The default configuration is async=true and every=0, i.e. a one-shot +// goroutine that fires once when the bot runtime starts. Use Async and Every +// to customize this. Do not call builder methods concurrently or after the +// bot runtime has begun executing runners. func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { return Runner[T]{ name: name, fn: fn, - async: true, // Default: run asynchronously - every: 0, // Default: 0 - one time + async: true, + every: 0, } } @@ -45,21 +46,18 @@ func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { // If true, the runner runs in a goroutine (non-blocking). // If false, the runner blocks the caller during execution. // -// Note: If once=false and async=false, the runner will be skipped with a warning. +// Note: periodic runners (Every > 0) require async=true and are skipped with +// a warning when async=false. func (r Runner[T]) Async(async bool) Runner[T] { r.async = async return r } -// Every sets the duration to wait between repeated executions for -// non-once runners. +// Every sets the interval between repeated executions of a periodic runner. // -// If once=true, this value is ignored. -// If once=false and async=true, this timeout determines the sleep interval -// between loop iterations. -// -// A zero value (time.Duration(0)) is allowed but may trigger a warning -// if used with a background (non-once) async runner. +// A zero value (the default) keeps the runner one-shot. A positive value +// schedules the runner to fire repeatedly with the given interval and +// requires async=true; periodic sync runners are skipped with a warning. func (r Runner[T]) Every(timeout time.Duration) Runner[T] { r.every = timeout return r @@ -67,15 +65,11 @@ func (r Runner[T]) Every(timeout time.Duration) Runner[T] { // ExecRunners executes all runners registered on the Bot with context-based lifecycle management. // -// It logs warnings for misconfigured runners: -// - Sync, non-once runners are skipped (invalid configuration). -// - Background (non-once, async) runners without a timeout trigger a warning. -// -// Execution logic: -// - once + async: Runs once in a goroutine. -// - once + sync: Runs once synchronously; warns if slower than 2 seconds. -// - !once + async: Runs in a loop with timeout between iterations until ctx.Done(). -// - !once + sync: Skipped with warning. +// Execution semantics by configuration: +// - every=0, async=true: Runs once in a goroutine (fire and forget). +// - every=0, async=false: Runs once synchronously; warns if slower than 2 seconds. +// - every>0, async=true: Runs in a loop with the configured interval until ctx.Done(). +// - every>0, async=false: Skipped with a warning (invalid configuration). // // Background runners listen for ctx.Done() and gracefully shut down when the context is canceled. // @@ -84,13 +78,8 @@ func (r Runner[T]) Every(timeout time.Duration) Runner[T] { func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { - // Validate configuration if runner.every > 0 && !runner.async { - bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name) - continue - } - if runner.every > 0 && runner.async && runner.every == 0 { - bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name) + bot.logger.Warnf("Runner %q is periodic but sync; skipping (use Async(true))\n", runner.name) continue } @@ -180,6 +169,5 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { } }(runner) } - // Note: !once && !async is already skipped above } } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index e02b3e5..cb93a41 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -229,7 +229,7 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str for _, file := range files { fw, err := w.CreateFormFile(string(file.field), file.filename) if err != nil { - _ = w.Close() // Закрываем, чтобы не было утечки + _ = w.Close() return nil, "", err } @@ -240,13 +240,13 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str } } - err := utils.Encode(w, params) // Предполагается, что это записывает в w + err := utils.Encode(w, params) if err != nil { _ = w.Close() return nil, "", err } - err = w.Close() // ✅ ОБЯЗАТЕЛЬНО вызвать в конце — иначе запрос битый! + err = w.Close() if err != nil { return nil, "", err } -- 2.54.0 From 950ce6b88c3552ccb63dfbb90dbe099026c81afe Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 20 May 2026 12:42:36 +0300 Subject: [PATCH 98/99] (tests): PollTimeout round-trip, uploader ResponseError, panic ErrorEvent, status secret compare, parseCommand + botname (doc): TODO test checklist closed --- TODO.md | 10 +-- bot_opts_loader_test.go | 1 + bot_webhook_test.go | 3 + handler_test.go | 155 +++++++++++++++++++++++++++++++++++++ plugins.go | 2 +- tgapi/uploader_api_test.go | 52 +++++++++++++ 6 files changed, 217 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 63099cf..1e67ab4 100644 --- a/TODO.md +++ b/TODO.md @@ -41,11 +41,11 @@ Findings from the full-repo review against `AGENTS.md` priorities. Build, vet, t ### Tests to add after the fixes -- `BotOptsFileJSON` round-trip for `PollTimeout` (after M4). -- Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3). -- `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix). -- Webhook `/status` with wrong `SecretToken` returns 403 / `403`-equivalent (after M11), incl. a constant-time-compare smoke. -- Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior. +- [X] `BotOptsFileJSON` round-trip for `PollTimeout` (after M4). +- [X] Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3). +- [X] `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix). +- [X] Webhook `/status` with wrong `SecretToken` returns 403 / `403`-equivalent (after M11), incl. a constant-time-compare smoke. +- [X] Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior. --- diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go index 63419ca..7655366 100644 --- a/bot_opts_loader_test.go +++ b/bot_opts_loader_test.go @@ -24,6 +24,7 @@ func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) { UseTestServer: true, APIURL: "https://api.example.invalid", RateLimit: 42, + PollTimeout: 7, DropRateLimitOverflow: true, StrictPayloadType: true, MaxWorkers: 64, diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 88e90d1..6e4ec52 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -311,6 +311,9 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { }{ {name: "missing auth", wantStatus: http.StatusNotFound}, {name: "wrong auth", headerName: "Authorization", headerVal: "wrong", wantStatus: http.StatusNotFound}, + {name: "matching length wrong content", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secres", wantStatus: http.StatusNotFound}, + {name: "shared prefix shorter", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secre", wantStatus: http.StatusNotFound}, + {name: "shared prefix longer", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secretxx", wantStatus: http.StatusNotFound}, {name: "matching telegram header", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secret", wantStatus: http.StatusOK}, } diff --git a/handler_test.go b/handler_test.go index 97c093c..f754741 100644 --- a/handler_test.go +++ b/handler_test.go @@ -1220,6 +1220,161 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { } } +func TestParseCommandTable(t *testing.T) { + bot := &Bot[NoData]{prefixes: []string{"/", "!"}} + + tests := []struct { + name string + text string + wantPrefix string + wantCmd string + wantArgs string + }{ + {name: "plain text", text: "hello", wantPrefix: "", wantCmd: "", wantArgs: ""}, + {name: "command no args", text: "/start", wantPrefix: "/", wantCmd: "start", wantArgs: ""}, + {name: "command with args", text: "/ban 42 reason", wantPrefix: "/", wantCmd: "ban", wantArgs: "42 reason"}, + {name: "alternate prefix", text: "!ping", wantPrefix: "!", wantCmd: "ping", wantArgs: ""}, + {name: "leading space after prefix", text: "/ start now", wantPrefix: "/", wantCmd: "start", wantArgs: "now"}, + {name: "command with botname", text: "/start@mybot extra", wantPrefix: "/", wantCmd: "start@mybot", wantArgs: "extra"}, + {name: "trailing whitespace", text: "/start ", wantPrefix: "/", wantCmd: "start", wantArgs: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix, cmd, args := bot.parseCommand(tt.text) + if prefix != tt.wantPrefix { + t.Fatalf("unexpected prefix: got %q want %q", prefix, tt.wantPrefix) + } + if cmd != tt.wantCmd { + t.Fatalf("unexpected cmd: got %q want %q", cmd, tt.wantCmd) + } + if args != tt.wantArgs { + t.Fatalf("unexpected args: got %q want %q", args, tt.wantArgs) + } + }) + } +} + +func TestHandleMessageStripsBotUsernameSuffix(t *testing.T) { + tests := []struct { + name string + botUsername string + text string + wantCalled bool + }{ + {name: "matching botname", botUsername: "mybot", text: "/start@mybot hello", wantCalled: true}, + {name: "matching botname no args", botUsername: "mybot", text: "/start@mybot", wantCalled: true}, + {name: "other botname", botUsername: "mybot", text: "/start@otherbot hello", wantCalled: false}, + {name: "no botname", botUsername: "mybot", text: "/start hello", wantCalled: true}, + {name: "bot has no username", botUsername: "", text: "/start@mybot hello", wantCalled: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + called := false + plugin := NewPlugin[NoData]("test") + plugin.Command("start", func(ctx *MessageContext, db NoData) error { + called = true + return nil + }) + + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + prefixes: []string{"/"}, + username: tt.botUsername, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 200, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: tt.text, + From: &tgapi.User{ID: 1}, + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, + }, + }) + + if called != tt.wantCalled { + t.Fatalf("unexpected handler invocation: got called=%v want %v", called, tt.wantCalled) + } + }) + } +} + +func TestHandlePanicEmitsErrorEvent(t *testing.T) { + tests := []struct { + name string + panicWith any + matchErr func(error) bool + }{ + { + name: "error value", + panicWith: errors.New("boom"), + matchErr: func(err error) bool { + return err != nil && err.Error() == "boom" + }, + }, + { + name: "string value", + panicWith: "kaboom", + matchErr: func(err error) bool { + return err != nil && err.Error() == "kaboom" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + observer := &recordingObserver{} + plugin := NewPlugin[NoData]("test") + plugin.Command("boom", func(ctx *MessageContext, db NoData) error { + panic(tt.panicWith) + }) + + bot := &Bot[NoData]{ + logger: sneklog.NewLogger(), + prefixes: []string{"/"}, + plugins: []Plugin[NoData]{clonePlugin(plugin)}, + observer: observer, + } + + bot.handle(context.Background(), &tgapi.Update{ + UpdateID: 100, + Type: tgapi.UpdateTypeMessage, + Message: &tgapi.Message{ + MessageID: 1, + Text: "/boom", + From: &tgapi.User{ID: 1}, + Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, + }, + }) + + panicEvent := (*ErrorEvent)(nil) + for i := range observer.errors { + ev := observer.errors[i] + if ev.Plugin == "" && ev.HandlerKind == "" && ev.UpdateID == 100 { + panicEvent = &ev + break + } + } + if panicEvent == nil { + t.Fatalf("expected ErrorEvent from panic recovery, got events: %#v", observer.errors) + } + if panicEvent.UpdateType != tgapi.UpdateTypeMessage { + t.Fatalf("unexpected UpdateType: %q", panicEvent.UpdateType) + } + if panicEvent.UserFacing { + t.Fatal("panic ErrorEvent must not be marked user-facing") + } + if !tt.matchErr(panicEvent.Err) { + t.Fatalf("unexpected panic Err: %v", panicEvent.Err) + } + }) + } +} + func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { observer := &recordingObserver{} bot := &Bot[NoData]{ diff --git a/plugins.go b/plugins.go index 122a137..2cfc800 100644 --- a/plugins.go +++ b/plugins.go @@ -107,7 +107,7 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { } scene.pluginName = p.name if _, exists := p.scenes[scene.name]; exists && p.logger != nil { - p.logger.Warnf("scene '%s' already registered in plugin '%s'; overwriting", scene.name, p.name) + p.logger.Warnf("scene '%s'да already registered in plugin '%s'; overwriting", scene.name, p.name) } p.scenes[scene.name] = scene return p diff --git a/tgapi/uploader_api_test.go b/tgapi/uploader_api_test.go index f5a2915..170a359 100644 --- a/tgapi/uploader_api_test.go +++ b/tgapi/uploader_api_test.go @@ -1,6 +1,7 @@ package tgapi import ( + "errors" "fmt" "io" "mime" @@ -104,6 +105,57 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test } } +func TestUploaderSurfacesResponseErrorForTelegramFailure(t *testing.T) { + const responseBody = `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}` + + client := &http.Client{ + Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + }), + } + + api := NewAPI( + NewAPIOpts("token"). + SetAPIURL("https://example.test"). + SetHTTPClient(client), + ) + defer func() { + if err := api.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + uploader := NewUploader(api) + defer func() { + if err := uploader.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }() + + _, err := uploader.SendPhoto( + UploadPhoto{ChatID: 42}, + NewUploaderFile("photo.jpg", []byte("img")), + ) + if err == nil { + t.Fatal("expected error, got nil") + } + + var respErr *ResponseError + if !errors.As(err, &respErr) { + t.Fatalf("expected *ResponseError, got %T: %v", err, err) + } + if respErr.Code != 400 { + t.Fatalf("unexpected ResponseError.Code: got %d want 400", respErr.Code) + } + if !strings.Contains(respErr.Description, "chat not found") { + t.Fatalf("unexpected ResponseError.Description: %q", respErr.Description) + } +} + func TestNewUploaderFileDetectsFileTypeCaseInsensitively(t *testing.T) { tests := []struct { name string -- 2.54.0 From 5514665625d94a2486945dc11f4159d2038361fe Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 20 May 2026 13:23:31 +0300 Subject: [PATCH 99/99] (doc): CHANGELOG v1.0.0, README MessageContext + Runners, TODO wiki done (fix): typo in scene overwrite warning --- CHANGELOG.md | 21 ++++++++++++++++++- README.md | 57 +++++++++++++++++++++++++++++++++++++++++----------- README_RU.md | 57 +++++++++++++++++++++++++++++++++++++++++----------- TODO.md | 47 ------------------------------------------- plugins.go | 2 +- 5 files changed, 111 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ede2df1..3769cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,15 @@ - Renamed final public APIs to idiomatic names before the stable release: `RunWebhookWithContext(...)`, `RunWebhook(...)`, `CloseWebhook()`, `BotWebhookOpts`, `NewBotWebhookOpts()`, `SetWebhookLogger(...)`, and `GetWebhookLogger()`. - Renamed plugin builder helpers from `NewCommand(...)` and `NewScene(...)` to `Command(...)` and `Scene(...)`; the surviving `NewCommand(...)` takes the command string before the executor. - Renamed command argument value constants to `CommandValueString`, `CommandValueInt`, `CommandValueBool`, and `CommandValueAny`; `NewCommandArg(...)` now defaults to unvalidated `CommandValueAny`. -- Renamed runner builders from `Onetime(...)` and `Timeout(...)` to `Once(...)` and `Every(...)`. +- Renamed runner builders from `Onetime(...)` and `Timeout(...)` to `Every(...)` and `Async(...)`; `Runner.Once()` is removed. Use the default configuration (every=0, async=true) for a fire-and-forget goroutine, or `Async(false)` for a synchronous blocking one-shot. - Renamed remaining public acronym/casing outliers including `AnswerCallback...`, `ParseMarkdownV2`, `ParseMarkdown`, `GetChatMemberCount`, `DropRateLimitOverflow`, `SetDropRateLimitOverflow`, and inline keyboard builder APIs. +- Renamed `Observer` event delivery methods `OnReceiveUpdate` → `OnUpdateReceived` and `OnHandledUpdate` → `OnUpdateHandled` to match the `UpdateReceivedEvent`/`UpdateHandledEvent` names and the `OnX` pattern of all other observer methods. +- `Scene.PluginName` is now unexported; it is assigned by the framework during plugin registration and must not be set by callers. +- `SceneSession.Data` is now unexported; use the `Set`/`Get`/`HasData`/`ClearData`/`BindData`/`SaveData` helpers instead. +- `BotPayloadType*` sentinels are now `const` instead of `var`; code that assigned to them will no longer compile. + +### Bot API 10.0 +- Added full support for Telegram Bot API 10.0 types, methods, and update kinds. ### Added - Added `MessageContext.IsCallback()` and `MessageContext.HasPhoto()` helpers for callback-aware handler code. @@ -24,6 +31,9 @@ - Added `RateLimiter.Cleanup(idleThreshold)` to evict per-chat limiter state and expired chat cooldowns; the limiter now tracks per-chat last-seen time so long-running bots can bound memory through a periodic runner. - Added cached bot identity (`Bot.userID`) populated at `NewBot` so chat-admin policies and similar lookups reuse it instead of issuing a fresh `GetMe` request. - Added `tgapi.ResponseError` so Telegram API error codes, descriptions, and response parameters remain inspectable through returned errors. +- Added nine exported webhook error sentinels — `ErrSetWebhookFailed`, `ErrBotAPINil`, `ErrBotWebhookOptsEmptyPath`, `ErrBotWebhookOptsPathNoSlash`, `ErrBotWebhookOptsPathHasQueryOrFragment`, `ErrBotWebhookOptsPathCollidesStatus`, `ErrBotWebhookTLSFilesIncomplete`, `ErrBotWebhookTLSFilesTooMany`, and `ErrStatusPathSecretRequired` — replacing the previous inline `errors.New(...)` calls so callers can match webhook startup errors with `errors.Is`. +- Added `ErrInvalidPayload` for compact payload decoding failures so callers can distinguish malformed payload bytes from other decode errors. +- Panics inside `Bot.handle` and the polling goroutine now emit an `ErrorEvent` through the observer so instrumentation sees runtime panics in addition to normal handler errors. ### Changed - Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`. @@ -47,6 +57,10 @@ - Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized. - Fixed webhook startup so a logger configured through `SetWebhookLogger(...)` is preserved. - Fixed long-polling 429 handling so `getUpdates` retries use Telegram `retry_after` directly and do not inflate later transient-error backoff. +- Fixed `BotOptsFileJSON` silently dropping `PollTimeout` on round-trip; the field is now encoded and decoded correctly. +- Fixed the `tgapi.Uploader` returning an ad-hoc error string on Telegram API failures; it now returns `*tgapi.ResponseError` matching the JSON API client, so `errors.As(err, &respErr)` works consistently for both upload and JSON paths. +- Fixed webhook secret validation to use `subtle.ConstantTimeCompare` instead of a plain string equality check, removing the timing side-channel. +- Fixed the `/status` handler returning HTTP 403 for a wrong secret, which disclosed endpoint existence; it now returns 404 uniformly for any unauthenticated request. ### Tests - Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle. @@ -61,6 +75,11 @@ - Added regression coverage for `Draft.Push` preserving the existing message when validation rejects the candidate. - Added regression coverage for `RateLimiter.Cleanup` evicting idle chat limiters and expired chat locks while leaving active state in place. - Updated `MessageContext.Error` tests so unclassified errors stay internal-only and only `AsUserError` reaches the user. +- Added regression coverage for `BotOptsFileJSON` `PollTimeout` round-trip. +- Added regression coverage proving the `tgapi.Uploader` surfaces `*tgapi.ResponseError` for Telegram 4xx responses. +- Added regression coverage proving a panic inside `Bot.handle` emits an `ErrorEvent` through the observer. +- Added regression coverage for the webhook `/status` endpoint rejecting wrong and same-length-but-different secrets with HTTP 404, and accepting the correct secret. +- Added table-driven regression coverage for `parseCommand` with `/cmd@botname` stripping, bare commands, commands with arguments, and empty input. ## v1.0.0-rc.16 diff --git a/README.md b/README.md index 91cf0c1..286a26e 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ import ( // It receives two parameters: // - ctx: the message context (contains info about the message, sender, chat, etc.) // - data: your shared application data (here we use NoData, a placeholder for no shared data) -func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error { +func echo(ctx *laniakea.MessageContext, data laniakea.NoData) error { // Answer the user with the text they sent, without any command prefix. // ctx.Text contains the user's message with the command part stripped off. ctx.Answer(ctx.Text) // User input WITHOUT command @@ -85,7 +85,7 @@ func main() { // 5. Add another command using an anonymous function (closure). // This command simply replies "Pong" when the user sends "/ping". - p.Command("ping", func(ctx *laniakea.MsgContext, data laniakea.NoData) error { + p.Command("ping", func(ctx *laniakea.MessageContext, data laniakea.NoData) error { ctx.Answer("Pong") return nil }) @@ -112,8 +112,8 @@ func main() { 1. `BotOpts`: Holds configuration like the API token. 2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData if you don't need it. 3. `NewPlugin`: Creates a logical group for commands and middlewares. -4. `Command`: Creates and registers a command. The first argument is the command name without the slash, the second is the handler function (`func(*MsgContext, T) error`). -5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling. +4. `Command`: Creates and registers a command. The first argument is the command name without the slash, the second is the handler function (`func(*MessageContext, T) error`). +5. **Handler Functions**: Receive *MessageContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling. 6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails. @@ -181,14 +181,14 @@ bot.AddPlugins(plugin) A command is a function that handles a specific bot command (e.g., /start). ```go -func myHandler(ctx *laniakea.MsgContext, db *MyDB) error { +func myHandler(ctx *laniakea.MessageContext, db *MyDB) error { // Access command arguments via ctx.Args ([]string) // Reply to the user: ctx.Answer("some text") return nil } ``` -### MsgContext +### MessageContext Provides access to the incoming message and useful reply methods: @@ -200,8 +200,8 @@ Provides access to the incoming message and useful reply methods: - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. - `AnswerPhoto(photoID, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. - `AnswerPhotoMarkdown(photoID, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). -- `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button. -- `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button. +- `EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage`: Edits message with parse_mode none after clicking inline button. +- `EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button. - `SendAction(action tgapi.ChatActionType)`: Sends a “typing”, “uploading photo”, etc., action. - Fields: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgID`, `CallbackQueryID`, etc. - And more methods and fields! @@ -268,6 +268,39 @@ plugin.Scene("signup"). - Use `SceneContext.SaveData(...)` and `SceneContext.BindData(...)` for JSON session state. - Use `SceneScopeUser`, `SceneScopeChat`, or `SceneScopeUserChat` depending on how widely a conversation should be shared. +## ⏱️ Runners + +Runners are background tasks that execute alongside the bot runtime. They are registered before the bot starts and launched automatically when the bot starts. + +```go +import "time" + +// One-shot runner — fires once in a goroutine when the bot starts (default). +bot.AddRunner( + laniakea.NewRunner("seed-cache", func(b *laniakea.Bot[*MyDB]) error { + return b.GetAppData().SeedCache() + }), +) + +// Periodic runner — fires every 10 minutes in a goroutine. +bot.AddRunner( + laniakea.NewRunner("refresh-stats", func(b *laniakea.Bot[*MyDB]) error { + return b.GetAppData().RefreshStats() + }).Every(10 * time.Minute), +) + +// Synchronous one-shot — blocks runtime startup until it completes. +bot.AddRunner( + laniakea.NewRunner("migrate", func(b *laniakea.Bot[*MyDB]) error { + return b.GetAppData().Migrate() + }).Async(false), +) +``` + +Builder methods: +- `Async(bool) *Runner[T]` — if `true` (default), runs in a goroutine; if `false`, blocks runtime startup. +- `Every(time.Duration) *Runner[T]` — sets the repeat interval. Zero (default) means run once; positive value repeats. Periodic runners require `Async(true)`. + ## 🧩 Middleware Middleware are functions that run before a command handler. They are perfect for cross-cutting concerns like logging, access control, rate limiting, or modifying the context. @@ -275,7 +308,7 @@ Middleware are functions that run before a command handler. They are perfect for A middleware function has the same signature as a command handler, but it must return a bool: ```go -func(ctx *MsgContext, db T) bool +func(ctx *MessageContext, db T) bool ``` - If it returns true, the next middleware (or the command) will be executed. @@ -295,7 +328,7 @@ plugin.Command("ban", banUser) 1. Logging Middleware – logs every command execution. ```go -func loggingMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { +func loggingMiddleware(ctx *laniakea.MessageContext, db *MyDB) bool { log.Printf("User %d executed command: %s", ctx.FromID, ctx.Msg.Text) return true // continue to next middleware/command } @@ -303,7 +336,7 @@ func loggingMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { 2. Admin-Only Middleware – restricts access to users with a specific role. ```go -func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { +func adminOnlyMiddleware(ctx *laniakea.MessageContext, db *MyDB) bool { if !db.IsAdmin(ctx.FromID) { // assume db has IsAdmin method ctx.Answer("⛔ Access denied. Admins only.") return false // stop execution @@ -313,7 +346,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { ``` ### Important Notes -- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. +- Middleware can modify the MessageContext (e.g., add custom fields) before the command runs. ## ⚙️ Advanced Configuration - **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJSON`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard. diff --git a/README_RU.md b/README_RU.md index 8e22937..1f91bc7 100644 --- a/README_RU.md +++ b/README_RU.md @@ -56,7 +56,7 @@ import ( // Она получает два параметра: // - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) // - data: ваши общие данные приложения (здесь мы используем NoData — заглушку без общих зависимостей) -func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error { +func echo(ctx *laniakea.MessageContext, data laniakea.NoData) error { // Отвечаем пользователю текстом, который он прислал, без префикса команды. // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды @@ -86,7 +86,7 @@ func main() { // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". - p.Command("ping", func(ctx *laniakea.MsgContext, data laniakea.NoData) error { + p.Command("ping", func(ctx *laniakea.MessageContext, data laniakea.NoData) error { ctx.Answer("Pong") return nil }) @@ -113,8 +113,8 @@ func main() { 1. `BotOpts`: Содержит конфигурацию, например, токен API. 2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. -4. `Command`: Создаёт и регистрирует команду. Первый аргумент — имя команды без слеша, второй — функция-обработчик (`func(*MsgContext, T) error`). -5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки. +4. `Command`: Создаёт и регистрирует команду. Первый аргумент — имя команды без слеша, второй — функция-обработчик (`func(*MessageContext, T) error`). +5. **Функции-обработчики**: Получают *MessageContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки. 6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно. @@ -182,14 +182,14 @@ bot.AddPlugins(plugin) Команда — это функция, которая обрабатывает конкретную команду бота (например, /start). ```go -func myHandler(ctx *laniakea.MsgContext, db *MyDB) error { +func myHandler(ctx *laniakea.MessageContext, db *MyDB) error { // Доступ к аргументам команды через ctx.Args ([]string) // Ответ пользователю: ctx.Answer("какой-то текст") return nil } ``` -### Контекст сообщения (MsgContext) +### Контекст сообщения (MessageContext) Предоставляет доступ к входящему сообщению и полезные методы для ответа: - `Answer(text string)`: Отправляет сообщение с parse_mode none. @@ -200,8 +200,8 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) error { - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. - `AnswerPhoto(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. - `AnswerPhotoMarkdown(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). -- `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки. -- `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки. +- `EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки. +- `EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки. - `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д. - Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgID`, `CallbackQueryID` и другие. - И много других методов и полей! @@ -256,6 +256,39 @@ plugin.Scene("signup"). - Для JSON-состояния сцены используйте `SceneContext.SaveData(...)` и `SceneContext.BindData(...)`. - Выбирайте `SceneScopeUser`, `SceneScopeChat` или `SceneScopeUserChat` в зависимости от того, насколько широко должен разделяться диалог. +## ⏱️ Раннеры (Runners) + +Раннеры — фоновые задачи, которые выполняются вместе с bot runtime. Они регистрируются до запуска бота и автоматически запускаются при старте. + +```go +import "time" + +// Одноразовый раннер — запускается один раз в горутине при старте (по умолчанию). +bot.AddRunner( + laniakea.NewRunner("seed-cache", func(b *laniakea.Bot[*MyDB]) error { + return b.GetAppData().SeedCache() + }), +) + +// Периодический раннер — запускается каждые 10 минут в горутине. +bot.AddRunner( + laniakea.NewRunner("refresh-stats", func(b *laniakea.Bot[*MyDB]) error { + return b.GetAppData().RefreshStats() + }).Every(10 * time.Minute), +) + +// Синхронный одноразовый — блокирует запуск runtime до завершения. +bot.AddRunner( + laniakea.NewRunner("migrate", func(b *laniakea.Bot[*MyDB]) error { + return b.GetAppData().Migrate() + }).Async(false), +) +``` + +Методы builder: +- `Async(bool) *Runner[T]` — если `true` (по умолчанию), запускается в горутине; если `false`, блокирует запуск runtime. +- `Every(time.Duration) *Runner[T]` — задаёт интервал повторного запуска. Ноль (по умолчанию) означает одноразовый запуск; положительное значение — периодический. Периодические раннеры требуют `Async(true)`. + ### tgapi: API и Uploader В `tgapi` есть два клиента: @@ -272,7 +305,7 @@ Middleware — это функции, которые выполняются пе Функция middleware имеет ту же сигнатуру, что и обработчик команды, но должна возвращать bool: ```go -func(ctx *MsgContext, db T) bool +func(ctx *MessageContext, db T) bool ``` - Если возвращается true, выполняется следующий middleware (или сама команда). @@ -292,7 +325,7 @@ plugin.Command("ban", banUser) 1. Логирующий middleware – логирует каждое выполнение команды. ```go -func loggingMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { +func loggingMiddleware(ctx *laniakea.MessageContext, db *MyDB) bool { log.Printf("Пользователь %d выполнил команду: %s", ctx.FromID, ctx.Msg.Text) return true // продолжаем к следующему middleware/команде } @@ -300,7 +333,7 @@ func loggingMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { 2. Middleware только для администраторов – ограничивает доступ пользователям с определённой ролью. ```go -func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { +func adminOnlyMiddleware(ctx *laniakea.MessageContext, db *MyDB) bool { if !db.IsAdmin(ctx.FromID) { // предполагается, что db имеет метод IsAdmin ctx.Answer("⛔ Доступ запрещён. Только для администраторов.") return false // останавливаем выполнение @@ -310,7 +343,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { ``` ### Важные замечания -- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. +- Middleware может изменять MessageContext (например, добавлять пользовательские поля) перед запуском команды. ## ⚙️ Расширенная настройка - **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJSON`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры. diff --git a/TODO.md b/TODO.md index 1e67ab4..4c422a1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,52 +1,5 @@ # TODO -## v1.0.0 pre-release review - -Findings from the full-repo review against `AGENTS.md` priorities. Build, vet, tests, and lint are clean; items below are public-API and godoc hygiene before the stable tag. - -### Major — close before 1.0.0 tag - -- [X] **M1. `BotPayloadType*` are `var`, must be `const`** — `bot.go:50-59`. Public sentinels are user-mutable globals. `KeyboardButtonStyle*` in `keyboard.go:10-17` already uses `const`; match the pattern. -- [X] **M2. `Observer` method naming asymmetry** — `observer.go:147-157`. `OnReceiveUpdate` → `OnUpdateReceived`; `OnHandledUpdate` → `OnUpdateHandled` to match `UpdateReceivedEvent` / `UpdateHandledEvent` and the rest of the `OnX` pattern. Breaking after 1.0. -- [X] **M3. Uploader returns ad-hoc error string instead of `*ResponseError`** — `tgapi/uploader_api.go:183`. `tgapi/api.go:258-292` returns `*ResponseError`; uploader must do the same so `errors.As(err, &tgapi.ResponseError{})` works for upload paths too. -- [X] **M4. `BotOptsFileJSON` is missing `PollTimeout`** — `bot_opts_loader.go:35-46`, plus `FromBytes`/`ToBytes` mapping. File round-trip silently drops `PollTimeout`. -- [X] **M5. Stale `Bot.Updates` godoc** — `methods.go:11-44`. Claims "30-second timeout" and "empty slice if none"; in reality timeout is `bot.pollTimeout` and the function returns `nil` on error. -- [X] **M6. Self-contradicting `NewRandomDraftProvider` godoc** — `drafts.go:50-59`. Says "cryptographically secure random numbers" but uses `math/rand/v2` (the underlying generator type correctly notes it is not crypto-secure). -- [X] **M7. `Draft.Delete` godoc says "internal method"** — `drafts.go:190-201`. Method is exported; either rewrite the godoc with a public-intent description or unexport. -- [X] **M8. Russian comments in production code** - - `msg_handler.go:28` — "Ищем команду по точному совпадению" - - `tgapi/uploader_api.go:181` — "Повторяем запрос" -- [X] **M9. `MessageContext.Error` godoc references unexported helper** — `msg_context.go:540`. "Error is an alias for error()" — rewrite to describe the centralized handler error path and `IsUserError` gating. -- [X] **M10. `Scene` and `SceneSession` mix exported fields with setters** - - `Scene` exports `Name/Scope/Entry/PluginName` and also has `SetScope/SetEntry`; `PluginName` is framework-assigned but publicly mutable. - - `SceneSession` exports `Data []byte` and also has `Set/Get/HasData/ClearData/BindData/SaveData`. - - Pick one model per type before 1.0.0. -- [X] **M11. Constant-time compare for webhook secret** — `bot_webhook.go:296` (update handler) and `bot_webhook.go:341` (`/status`). Use `subtle.ConstantTimeCompare`. - -### Minor — can slip to 1.0.x - -- [X] Strip `// Internal helper …` godoc from unexported funcs (~23 occurrences in repo); `AGENTS.md` explicitly forbids godoc-style comments on unexported declarations without a strong reason. -- [X] `Plugin.AddCommand` godoc references unexported field `.command` — `plugins.go:48-49`. -- [X] `Runner` builder naming: `runner.Once(true)`, `runner.Async(true)` read awkwardly; consider `SetOnce`/`SetAsync` to match `Set*` on other types, or zero-arg `Once()` + paired `Repeat(every)`. -- [X] Typo in webhook error string: `bot_webhook.go:143` — "MaxConnections must between 1 and 100" (missing `be`). -- [X] `RunWebhookWithContext` uses inline `errors.New(...)` instead of `Err*` sentinels (`bot_webhook.go:131-156`); rest of the package uses sentinels from `errors.go`. -- [X] `tgapi.UpdateTypeManagedBot` (`tgapi/types.go:61`) has no godoc. -- [X] `Bot.GetAPI`, `Bot.GetUploader`, `InlineKeyboard.GetMaxRow` have no godoc. -- [X] `Bot.L10n` godoc says "Returns empty string if translation not found"; actually returns the key (`l10n.go:48-59`). -- [X] `Bot.handle` panic recovery only logs — emit `ErrorEvent` so observers see panics (`handler.go:18-23`). -- [X] `handleCallback` vs `handleMessage` differ in plugin-logger assignment: callback assigns unconditionally then falls back to bot logger (`msg_handler.go:209-212`); message only assigns if non-nil (`msg_handler.go:35-37`). Align. -- [X] `SetCallbackData` godoc says "default payload type is JSON" — actually the zero `BotPayloadType` falls through to the `default` branch (which happens to be JSON). Either document the zero-value behavior explicitly or initialize the builder with the bot's default (`keyboard.go:106-122`). -- [X] `commands.go:62-66` — empty `case CommandValueAny:` next to `default: regex = nil` looks like an incomplete switch. Merge or add a one-line comment. -- [X] `Bot.SetDebug` does not call `configMutable` unlike sibling setters; if intentional, note it in godoc. - -### Tests to add after the fixes - -- [X] `BotOptsFileJSON` round-trip for `PollTimeout` (after M4). -- [X] Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3). -- [X] `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix). -- [X] Webhook `/status` with wrong `SecretToken` returns 403 / `403`-equivalent (after M11), incl. a constant-time-compare smoke. -- [X] Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior. - --- The framework backlog has moved to the wiki. diff --git a/plugins.go b/plugins.go index 2cfc800..122a137 100644 --- a/plugins.go +++ b/plugins.go @@ -107,7 +107,7 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { } scene.pluginName = p.name if _, exists := p.scenes[scene.name]; exists && p.logger != nil { - p.logger.Warnf("scene '%s'да already registered in plugin '%s'; overwriting", scene.name, p.name) + p.logger.Warnf("scene '%s' already registered in plugin '%s'; overwriting", scene.name, p.name) } p.scenes[scene.name] = scene return p -- 2.54.0