From ff29716429ecbe16a961d7ee6a826fab878d0bf5 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Thu, 9 Dec 2021 14:36:38 +0300 Subject: [PATCH 01/29] chore: use go-faster org repos --- botdoc/oas.go | 4 ++-- cmd/botapi/main.go | 2 +- cmd/gotd-bot-oas/main.go | 2 +- {pool => internal/pool}/pool.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename {pool => internal/pool}/pool.go (99%) diff --git a/botdoc/oas.go b/botdoc/oas.go index c281552..ed4d4dd 100644 --- a/botdoc/oas.go +++ b/botdoc/oas.go @@ -7,8 +7,8 @@ import ( "strconv" "strings" - "github.com/ogen-go/errors" - "github.com/ogen-go/jx" + "github.com/go-faster/errors" + "github.com/go-faster/jx" "github.com/ogen-go/ogen" ) diff --git a/cmd/botapi/main.go b/cmd/botapi/main.go index be603a5..590e920 100644 --- a/cmd/botapi/main.go +++ b/cmd/botapi/main.go @@ -13,7 +13,7 @@ import ( "github.com/gotd/td/telegram" - "github.com/gotd/botapi/pool" + "github.com/gotd/botapi/internal/pool" ) type handleContext struct { diff --git a/cmd/gotd-bot-oas/main.go b/cmd/gotd-bot-oas/main.go index bdfa378..3c40a78 100644 --- a/cmd/gotd-bot-oas/main.go +++ b/cmd/gotd-bot-oas/main.go @@ -13,7 +13,7 @@ import ( "path/filepath" "github.com/PuerkitoBio/goquery" - "github.com/ogen-go/errors" + "github.com/go-faster/errors" "github.com/gotd/botapi/botdoc" ) diff --git a/pool/pool.go b/internal/pool/pool.go similarity index 99% rename from pool/pool.go rename to internal/pool/pool.go index c7bf7d5..721979c 100644 --- a/pool/pool.go +++ b/internal/pool/pool.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "github.com/ogen-go/errors" + "github.com/go-faster/errors" "go.uber.org/zap" "github.com/gotd/td/session" From a8c2a0cfa055ae475e843626f635a7716a3b0dec Mon Sep 17 00:00:00 2001 From: tdakkota Date: Thu, 9 Dec 2021 16:03:49 +0300 Subject: [PATCH 02/29] refactor: use generated OAS Server --- cmd/botapi/get_me.go | 55 ------------ cmd/botapi/main.go | 52 ++--------- internal/botapi/answer.go | 27 ++++++ internal/botapi/botapi.go | 33 +++++++ internal/botapi/chat.go | 67 ++++++++++++++ internal/botapi/chat_member.go | 52 +++++++++++ internal/botapi/chat_pin.go | 22 +++++ internal/botapi/command.go | 22 +++++ internal/botapi/do.go | 11 +++ internal/botapi/errors.go | 56 ++++++++++++ internal/botapi/file.go | 17 ++++ internal/botapi/game.go | 17 ++++ internal/botapi/invite_link.go | 27 ++++++ internal/botapi/live_location.go | 1 + internal/botapi/me.go | 59 ++++++++++++ internal/botapi/message.go | 57 ++++++++++++ internal/botapi/optional.go | 18 ++++ internal/botapi/result.go | 10 +++ internal/botapi/send.go | 97 ++++++++++++++++++++ internal/botapi/sticker.go | 37 ++++++++ internal/botapi/token.go | 21 +++++ internal/botapi/user.go | 12 +++ internal/botapi/webhook.go | 22 +++++ internal/pool/client.go | 36 ++++++++ internal/pool/pool.go | 149 +++---------------------------- internal/pool/storage.go | 88 ++++++++++++++++++ internal/pool/token.go | 37 ++++++++ 27 files changed, 869 insertions(+), 233 deletions(-) delete mode 100644 cmd/botapi/get_me.go create mode 100644 internal/botapi/answer.go create mode 100644 internal/botapi/botapi.go create mode 100644 internal/botapi/chat.go create mode 100644 internal/botapi/chat_member.go create mode 100644 internal/botapi/chat_pin.go create mode 100644 internal/botapi/command.go create mode 100644 internal/botapi/do.go create mode 100644 internal/botapi/errors.go create mode 100644 internal/botapi/file.go create mode 100644 internal/botapi/game.go create mode 100644 internal/botapi/invite_link.go create mode 100644 internal/botapi/live_location.go create mode 100644 internal/botapi/me.go create mode 100644 internal/botapi/message.go create mode 100644 internal/botapi/optional.go create mode 100644 internal/botapi/result.go create mode 100644 internal/botapi/send.go create mode 100644 internal/botapi/sticker.go create mode 100644 internal/botapi/token.go create mode 100644 internal/botapi/user.go create mode 100644 internal/botapi/webhook.go create mode 100644 internal/pool/client.go create mode 100644 internal/pool/storage.go create mode 100644 internal/pool/token.go diff --git a/cmd/botapi/get_me.go b/cmd/botapi/get_me.go deleted file mode 100644 index fdae6a5..0000000 --- a/cmd/botapi/get_me.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - - "github.com/gotd/td/tg" - - "github.com/gotd/botapi/api" -) - -func convertUser(res *tg.User) api.User { - // TDLib uses special flag USER_FLAG_IS_INLINE_BOT, which is not defined in schema. - // - // See links for reference. - // - // User object JSON encoding - // https://github.com/tdlib/telegram-bot-api/blob/81f298361cf80d1d6c70a074ff88534bd3d450b3/telegram-bot-api/Client.cpp#L335 - // - // TDLib API (td_api.tl) user constructor <-> BotAPI UserInfo structure conversion. - // https://github.com/tdlib/telegram-bot-api/blob/81f298361cf80d1d6c70a074ff88534bd3d450b3/telegram-bot-api/Client.cpp#L8211 - // - // TDLib User type <-> TDLib API (td_api.tl) user constructor conversion. - // https://github.com/tdlib/td/blob/c45535d607463adb0cd20fcadf43e8f793b1fb24/td/telegram/ContactsManager.cpp#L15782-L15783 - // - // Telegram API user constructor <-> TDLib User type conversion. - // https://github.com/tdlib/td/blob/c45535d607463adb0cd20fcadf43e8f793b1fb24/td/telegram/ContactsManager.cpp#L8156 - // - // USER_FLAG_IS_INLINE_BOT definition. - // https://github.com/tdlib/td/blob/c45535d607463adb0cd20fcadf43e8f793b1fb24/td/telegram/ContactsManager.h#L990 - isInlineBot := res.Flags.Has(19) - - return api.User{ - ID: res.ID, - FirstName: res.FirstName, - LastName: res.LastName, - Username: res.Username, - LanguageCode: res.LangCode, - IsBot: res.Bot, - CanJoinGroups: !res.BotNochats, - CanReadMessages: res.BotChatHistory, - SupportsInline: isInlineBot, - } -} - -func getMe(ctx context.Context, h handleContext) error { - res, err := h.Client.Self(ctx) - if err != nil { - return err - } - - return json.NewEncoder(h.Writer).Encode(api.Response{ - Result: convertUser(res), - }) -} diff --git a/cmd/botapi/main.go b/cmd/botapi/main.go index 590e920..95c9653 100644 --- a/cmd/botapi/main.go +++ b/cmd/botapi/main.go @@ -2,35 +2,18 @@ package main import ( - "context" "flag" "net/http" - "strings" "time" "github.com/go-chi/chi/v5" "go.uber.org/zap" - "github.com/gotd/td/telegram" - + "github.com/gotd/botapi/internal/botapi" + "github.com/gotd/botapi/internal/oas" "github.com/gotd/botapi/internal/pool" ) -type handleContext struct { - Method string - Client *telegram.Client - Writer http.ResponseWriter - Request *http.Request -} - -type handler struct { - handlers map[string]func(ctx context.Context, h handleContext) error -} - -func (h handler) On(method string, f func(ctx context.Context, h handleContext) error) { - h.handlers[strings.ToLower(method)] = f -} - func main() { var ( appID = flag.Int("api-id", 0, "The api_id of application") @@ -38,6 +21,7 @@ func main() { addr = flag.String("addr", "localhost:8081", "http listen addr") keepalive = flag.Duration("keepalive", time.Second*5, "client keepalive") statePath = flag.String("state", "", "path to state file (json)") + debug = flag.Bool("debug", false, "enables debug mode") ) flag.Parse() @@ -63,39 +47,21 @@ func main() { } go p.RunGC(*keepalive) - h := handler{ - handlers: map[string]func(ctx context.Context, h handleContext) error{}, - } - h.On("getMe", getMe) + handler := botapi.NewBotAPI(p, *debug) + server := oas.NewServer(handler) // https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/getMe r := chi.NewRouter() r.Post("/bot{token}/{method}", func(w http.ResponseWriter, r *http.Request) { token, err := pool.ParseToken(chi.URLParam(r, "token")) if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - method := strings.ToLower(chi.URLParam(r, "method")) - handler, ok := h.handlers[method] - if !ok { - w.WriteHeader(http.StatusNotFound) + botapi.NotFound(w, r) return } + r.WithContext(botapi.PropagateToken(r.Context(), token)) - ctx := r.Context() - if err := p.Do(ctx, token, func(client *telegram.Client) error { - return handler(ctx, handleContext{ - Method: method, - Client: client, - Writer: w, - Request: r, - }) - }); err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } + r.URL.Path = chi.URLParam(r, "method") + server.ServeHTTP(w, r) }) if err := http.ListenAndServe(*addr, r); err != nil { diff --git a/internal/botapi/answer.go b/internal/botapi/answer.go new file mode 100644 index 0000000..7b5fe51 --- /dev/null +++ b/internal/botapi/answer.go @@ -0,0 +1,27 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// AnswerCallbackQuery implements oas.Handler. +func (b *BotAPI) AnswerCallbackQuery(ctx context.Context, req oas.AnswerCallbackQuery) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// AnswerInlineQuery implements oas.Handler. +func (b *BotAPI) AnswerInlineQuery(ctx context.Context, req oas.AnswerInlineQuery) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// AnswerPreCheckoutQuery implements oas.Handler. +func (b *BotAPI) AnswerPreCheckoutQuery(ctx context.Context, req oas.AnswerPreCheckoutQuery) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// AnswerShippingQuery implements oas.Handler. +func (b *BotAPI) AnswerShippingQuery(ctx context.Context, req oas.AnswerShippingQuery) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/botapi/botapi.go b/internal/botapi/botapi.go new file mode 100644 index 0000000..32485ca --- /dev/null +++ b/internal/botapi/botapi.go @@ -0,0 +1,33 @@ +// Package botapi contains Telegram Bot API handlers implementation. +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" + "github.com/gotd/botapi/internal/pool" +) + +// BotAPI is Bot API implementation. +type BotAPI struct { + pool *pool.Pool + debug bool +} + +// NewBotAPI creates new BotAPI. +func NewBotAPI(pool *pool.Pool, debug bool) *BotAPI { + return &BotAPI{ + pool: pool, + debug: debug, + } +} + +// GetUpdates implements oas.Handler. +func (b *BotAPI) GetUpdates(ctx context.Context, req oas.GetUpdates) (oas.ResultArrayOfUpdate, error) { + return oas.ResultArrayOfUpdate{}, &NotImplementedError{} +} + +// SetPassportDataErrors implements oas.Handler. +func (b *BotAPI) SetPassportDataErrors(ctx context.Context, req oas.SetPassportDataErrors) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/botapi/chat.go b/internal/botapi/chat.go new file mode 100644 index 0000000..a15cc2d --- /dev/null +++ b/internal/botapi/chat.go @@ -0,0 +1,67 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// ApproveChatJoinRequest implements oas.Handler. +func (b *BotAPI) ApproveChatJoinRequest(ctx context.Context, req oas.ApproveChatJoinRequest) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// DeclineChatJoinRequest implements oas.Handler. +func (b *BotAPI) DeclineChatJoinRequest(ctx context.Context, req oas.DeclineChatJoinRequest) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// DeleteChatPhoto implements oas.Handler. +func (b *BotAPI) DeleteChatPhoto(ctx context.Context, req oas.DeleteChatPhoto) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// DeleteChatStickerSet implements oas.Handler. +func (b *BotAPI) DeleteChatStickerSet(ctx context.Context, req oas.DeleteChatStickerSet) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// GetChat implements oas.Handler. +func (b *BotAPI) GetChat(ctx context.Context, req oas.GetChat) (oas.ResultChat, error) { + return oas.ResultChat{}, &NotImplementedError{} +} + +// SetChatAdministratorCustomTitle implements oas.Handler. +func (b *BotAPI) SetChatAdministratorCustomTitle(ctx context.Context, req oas.SetChatAdministratorCustomTitle) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// SetChatDescription implements oas.Handler. +func (b *BotAPI) SetChatDescription(ctx context.Context, req oas.SetChatDescription) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// SetChatPermissions implements oas.Handler. +func (b *BotAPI) SetChatPermissions(ctx context.Context, req oas.SetChatPermissions) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// SetChatPhoto implements oas.Handler. +func (b *BotAPI) SetChatPhoto(ctx context.Context, req oas.SetChatPhoto) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// SetChatStickerSet implements oas.Handler. +func (b *BotAPI) SetChatStickerSet(ctx context.Context, req oas.SetChatStickerSet) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// SetChatTitle implements oas.Handler. +func (b *BotAPI) SetChatTitle(ctx context.Context, req oas.SetChatTitle) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// LeaveChat implements oas.Handler. +func (b *BotAPI) LeaveChat(ctx context.Context, req oas.LeaveChat) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/botapi/chat_member.go b/internal/botapi/chat_member.go new file mode 100644 index 0000000..300b2ae --- /dev/null +++ b/internal/botapi/chat_member.go @@ -0,0 +1,52 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// BanChatMember implements oas.Handler. +func (b *BotAPI) BanChatMember(ctx context.Context, req oas.BanChatMember) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// BanChatSenderChat implements oas.Handler. +func (b *BotAPI) BanChatSenderChat(ctx context.Context, req oas.BanChatSenderChat) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// GetChatAdministrators implements oas.Handler. +func (b *BotAPI) GetChatAdministrators(ctx context.Context, req oas.GetChatAdministrators) (oas.ResultArrayOfChatMember, error) { + return oas.ResultArrayOfChatMember{}, &NotImplementedError{} +} + +// GetChatMember implements oas.Handler. +func (b *BotAPI) GetChatMember(ctx context.Context, req oas.GetChatMember) (oas.ResultChatMember, error) { + return oas.ResultChatMember{}, &NotImplementedError{} +} + +// GetChatMemberCount implements oas.Handler. +func (b *BotAPI) GetChatMemberCount(ctx context.Context, req oas.GetChatMemberCount) (oas.ResultInt, error) { + return oas.ResultInt{}, &NotImplementedError{} +} + +// PromoteChatMember implements oas.Handler. +func (b *BotAPI) PromoteChatMember(ctx context.Context, req oas.PromoteChatMember) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// RestrictChatMember implements oas.Handler. +func (b *BotAPI) RestrictChatMember(ctx context.Context, req oas.RestrictChatMember) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// UnbanChatMember implements oas.Handler. +func (b *BotAPI) UnbanChatMember(ctx context.Context, req oas.UnbanChatMember) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// UnbanChatSenderChat implements oas.Handler. +func (b *BotAPI) UnbanChatSenderChat(ctx context.Context, req oas.UnbanChatSenderChat) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/botapi/chat_pin.go b/internal/botapi/chat_pin.go new file mode 100644 index 0000000..8a2bf60 --- /dev/null +++ b/internal/botapi/chat_pin.go @@ -0,0 +1,22 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// PinChatMessage implements oas.Handler. +func (b *BotAPI) PinChatMessage(ctx context.Context, req oas.PinChatMessage) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// UnpinAllChatMessages implements oas.Handler. +func (b *BotAPI) UnpinAllChatMessages(ctx context.Context, req oas.UnpinAllChatMessages) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// UnpinChatMessage implements oas.Handler. +func (b *BotAPI) UnpinChatMessage(ctx context.Context, req oas.UnpinChatMessage) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/botapi/command.go b/internal/botapi/command.go new file mode 100644 index 0000000..8abcee6 --- /dev/null +++ b/internal/botapi/command.go @@ -0,0 +1,22 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// GetMyCommands implements oas.Handler. +func (b *BotAPI) GetMyCommands(ctx context.Context, req oas.GetMyCommands) (oas.ResultArrayOfBotCommand, error) { + return oas.ResultArrayOfBotCommand{}, &NotImplementedError{} +} + +// SetMyCommands implements oas.Handler. +func (b *BotAPI) SetMyCommands(ctx context.Context, req oas.SetMyCommands) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// DeleteMyCommands implements oas.Handler. +func (b *BotAPI) DeleteMyCommands(ctx context.Context, req oas.DeleteMyCommands) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/botapi/do.go b/internal/botapi/do.go new file mode 100644 index 0000000..6504fa2 --- /dev/null +++ b/internal/botapi/do.go @@ -0,0 +1,11 @@ +package botapi + +import ( + "context" + + "github.com/gotd/td/telegram" +) + +func (b *BotAPI) do(ctx context.Context, cb func(client *telegram.Client) error) error { + return b.pool.Do(ctx, MustToken(ctx), cb) +} diff --git a/internal/botapi/errors.go b/internal/botapi/errors.go new file mode 100644 index 0000000..e4c0a94 --- /dev/null +++ b/internal/botapi/errors.go @@ -0,0 +1,56 @@ +package botapi + +import ( + "context" + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + + "github.com/gotd/botapi/internal/oas" +) + +// NotImplementedError is stub error for not implemented methods. +type NotImplementedError struct{} + +// Error implements error. +func (n *NotImplementedError) Error() string { + return "method not implemented yet" +} + +func errorOf(code int) oas.ErrorStatusCode { + return oas.ErrorStatusCode{ + StatusCode: code, + Response: oas.Error{ + ErrorCode: code, + Description: http.StatusText(code), + }, + } +} + +// NewError maps error to status code. +func (b BotAPI) NewError(ctx context.Context, err error) oas.ErrorStatusCode { + var ( + notImplemented *NotImplementedError + ) + if errors.As(err, ¬Implemented) { + return errorOf(http.StatusNotImplemented) + } + + resp := errorOf(http.StatusInternalServerError) + if b.debug && err != nil { + resp.Response.Description = err.Error() + } + return resp +} + +// NotFound is default not found handler. +func NotFound(w http.ResponseWriter, _ *http.Request) { + apiError := errorOf(http.StatusNotFound) + + e := jx.GetEncoder() + defer jx.PutEncoder(e) + + apiError.Encode(e) + _, _ = e.WriteTo(w) +} diff --git a/internal/botapi/file.go b/internal/botapi/file.go new file mode 100644 index 0000000..709e55e --- /dev/null +++ b/internal/botapi/file.go @@ -0,0 +1,17 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// GetFile implements oas.Handler. +func (b *BotAPI) GetFile(ctx context.Context, req oas.GetFile) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// UploadStickerFile implements oas.Handler. +func (b *BotAPI) UploadStickerFile(ctx context.Context, req oas.UploadStickerFile) (oas.ResultFile, error) { + return oas.ResultFile{}, &NotImplementedError{} +} diff --git a/internal/botapi/game.go b/internal/botapi/game.go new file mode 100644 index 0000000..030cd06 --- /dev/null +++ b/internal/botapi/game.go @@ -0,0 +1,17 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// GetGameHighScores implements oas.Handler. +func (b *BotAPI) GetGameHighScores(ctx context.Context, req oas.GetGameHighScores) (oas.ResultArrayOfGameHighScore, error) { + return oas.ResultArrayOfGameHighScore{}, &NotImplementedError{} +} + +// SetGameScore implements oas.Handler. +func (b *BotAPI) SetGameScore(ctx context.Context, req oas.SetGameScore) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/botapi/invite_link.go b/internal/botapi/invite_link.go new file mode 100644 index 0000000..9e22449 --- /dev/null +++ b/internal/botapi/invite_link.go @@ -0,0 +1,27 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// CreateChatInviteLink implements oas.Handler. +func (b *BotAPI) CreateChatInviteLink(ctx context.Context, req oas.CreateChatInviteLink) (oas.ResultChatInviteLink, error) { + return oas.ResultChatInviteLink{}, &NotImplementedError{} +} + +// EditChatInviteLink implements oas.Handler. +func (b *BotAPI) EditChatInviteLink(ctx context.Context, req oas.EditChatInviteLink) (oas.ResultChatInviteLink, error) { + return oas.ResultChatInviteLink{}, &NotImplementedError{} +} + +// ExportChatInviteLink implements oas.Handler. +func (b *BotAPI) ExportChatInviteLink(ctx context.Context, req oas.ExportChatInviteLink) (oas.ResultString, error) { + return oas.ResultString{}, &NotImplementedError{} +} + +// RevokeChatInviteLink implements oas.Handler. +func (b *BotAPI) RevokeChatInviteLink(ctx context.Context, req oas.RevokeChatInviteLink) (oas.ResultChatInviteLink, error) { + return oas.ResultChatInviteLink{}, &NotImplementedError{} +} diff --git a/internal/botapi/live_location.go b/internal/botapi/live_location.go new file mode 100644 index 0000000..d3aa314 --- /dev/null +++ b/internal/botapi/live_location.go @@ -0,0 +1 @@ +package botapi diff --git a/internal/botapi/me.go b/internal/botapi/me.go new file mode 100644 index 0000000..3ca676a --- /dev/null +++ b/internal/botapi/me.go @@ -0,0 +1,59 @@ +package botapi + +import ( + "context" + + "github.com/gotd/td/telegram" + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/oas" +) + +func convertUser(user *tg.User) oas.User { + return oas.User{ + ID: int(user.ID), + IsBot: user.Bot, + FirstName: user.FirstName, + LastName: optString(user.GetLastName), + Username: optString(user.GetUsername), + LanguageCode: optString(user.GetLangCode), + CanJoinGroups: optBool(user.BotNochats), + CanReadAllGroupMessages: optBool(user.BotChatHistory), + SupportsInlineQueries: optBool(user.BotInlinePlaceholder == ""), + } +} + +// GetMe implements oas.Handler. +func (b *BotAPI) GetMe(ctx context.Context) (oas.ResultUser, error) { + var self *tg.User + if err := b.do(ctx, func(client *telegram.Client) (err error) { + self, err = client.Self(ctx) + return err + }); err != nil { + return oas.ResultUser{}, err + } + + return oas.ResultUser{ + Result: oas.NewOptUser(convertUser(self)), + Ok: true, + }, nil +} + +// Close implements oas.Handler. +func (b *BotAPI) Close(ctx context.Context) (oas.Result, error) { + b.pool.Kill(MustToken(ctx)) + return resultOK(true), nil +} + +// LogOut implements oas.Handler. +func (b *BotAPI) LogOut(ctx context.Context) (oas.Result, error) { + var r bool + if err := b.do(ctx, func(client *telegram.Client) (err error) { + r, err = client.API().AuthLogOut(ctx) + return err + }); err != nil { + return oas.Result{}, err + } + + return resultOK(r), &NotImplementedError{} +} diff --git a/internal/botapi/message.go b/internal/botapi/message.go new file mode 100644 index 0000000..90684fe --- /dev/null +++ b/internal/botapi/message.go @@ -0,0 +1,57 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// CopyMessage implements oas.Handler. +func (b *BotAPI) CopyMessage(ctx context.Context, req oas.CopyMessage) (oas.ResultMessageId, error) { + return oas.ResultMessageId{}, &NotImplementedError{} +} + +// DeleteMessage implements oas.Handler. +func (b *BotAPI) DeleteMessage(ctx context.Context, req oas.DeleteMessage) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// EditMessageCaption implements oas.Handler. +func (b *BotAPI) EditMessageCaption(ctx context.Context, req oas.EditMessageCaption) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// EditMessageLiveLocation implements oas.Handler. +func (b *BotAPI) EditMessageLiveLocation(ctx context.Context, req oas.EditMessageLiveLocation) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// EditMessageMedia implements oas.Handler. +func (b *BotAPI) EditMessageMedia(ctx context.Context, req oas.EditMessageMedia) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// EditMessageReplyMarkup implements oas.Handler. +func (b *BotAPI) EditMessageReplyMarkup(ctx context.Context, req oas.EditMessageReplyMarkup) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// EditMessageText implements oas.Handler. +func (b *BotAPI) EditMessageText(ctx context.Context, req oas.EditMessageText) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// ForwardMessage implements oas.Handler. +func (b *BotAPI) ForwardMessage(ctx context.Context, req oas.ForwardMessage) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// StopMessageLiveLocation implements oas.Handler. +func (b *BotAPI) StopMessageLiveLocation(ctx context.Context, req oas.StopMessageLiveLocation) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// StopPoll implements oas.Handler. +func (b *BotAPI) StopPoll(ctx context.Context, req oas.StopPoll) (oas.ResultPoll, error) { + return oas.ResultPoll{}, &NotImplementedError{} +} diff --git a/internal/botapi/optional.go b/internal/botapi/optional.go new file mode 100644 index 0000000..3429be8 --- /dev/null +++ b/internal/botapi/optional.go @@ -0,0 +1,18 @@ +package botapi + +import "github.com/gotd/botapi/internal/oas" + +func optString(getter func() (string, bool)) oas.OptString { + v, ok := getter() + if !ok { + return oas.OptString{} + } + return oas.NewOptString(v) +} + +func optBool(v bool) oas.OptBool { + return oas.OptBool{ + Value: v, + Set: v, + } +} diff --git a/internal/botapi/result.go b/internal/botapi/result.go new file mode 100644 index 0000000..a43ddfb --- /dev/null +++ b/internal/botapi/result.go @@ -0,0 +1,10 @@ +package botapi + +import "github.com/gotd/botapi/internal/oas" + +func resultOK(v bool) oas.Result { + return oas.Result{ + Result: optBool(v), + Ok: true, + } +} diff --git a/internal/botapi/send.go b/internal/botapi/send.go new file mode 100644 index 0000000..01b63b0 --- /dev/null +++ b/internal/botapi/send.go @@ -0,0 +1,97 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// SendAnimation implements oas.Handler. +func (b *BotAPI) SendAnimation(ctx context.Context, req oas.SendAnimation) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendAudio implements oas.Handler. +func (b *BotAPI) SendAudio(ctx context.Context, req oas.SendAudio) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendChatAction implements oas.Handler. +func (b *BotAPI) SendChatAction(ctx context.Context, req oas.SendChatAction) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// SendContact implements oas.Handler. +func (b *BotAPI) SendContact(ctx context.Context, req oas.SendContact) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendDice implements oas.Handler. +func (b *BotAPI) SendDice(ctx context.Context, req oas.SendDice) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendDocument implements oas.Handler. +func (b *BotAPI) SendDocument(ctx context.Context, req oas.SendDocument) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendGame implements oas.Handler. +func (b *BotAPI) SendGame(ctx context.Context, req oas.SendGame) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendInvoice implements oas.Handler. +func (b *BotAPI) SendInvoice(ctx context.Context, req oas.SendInvoice) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendLocation implements oas.Handler. +func (b *BotAPI) SendLocation(ctx context.Context, req oas.SendLocation) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendMediaGroup implements oas.Handler. +func (b *BotAPI) SendMediaGroup(ctx context.Context, req oas.SendMediaGroup) (oas.ResultArrayOfMessage, error) { + return oas.ResultArrayOfMessage{}, &NotImplementedError{} +} + +// SendMessage implements oas.Handler. +func (b *BotAPI) SendMessage(ctx context.Context, req oas.SendMessage) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendPhoto implements oas.Handler. +func (b *BotAPI) SendPhoto(ctx context.Context, req oas.SendPhoto) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendPoll implements oas.Handler. +func (b *BotAPI) SendPoll(ctx context.Context, req oas.SendPoll) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendSticker implements oas.Handler. +func (b *BotAPI) SendSticker(ctx context.Context, req oas.SendSticker) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendVenue implements oas.Handler. +func (b *BotAPI) SendVenue(ctx context.Context, req oas.SendVenue) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendVideo implements oas.Handler. +func (b *BotAPI) SendVideo(ctx context.Context, req oas.SendVideo) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendVideoNote implements oas.Handler. +func (b *BotAPI) SendVideoNote(ctx context.Context, req oas.SendVideoNote) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendVoice implements oas.Handler. +func (b *BotAPI) SendVoice(ctx context.Context, req oas.SendVoice) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} diff --git a/internal/botapi/sticker.go b/internal/botapi/sticker.go new file mode 100644 index 0000000..cf90fca --- /dev/null +++ b/internal/botapi/sticker.go @@ -0,0 +1,37 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// AddStickerToSet implements oas.Handler. +func (b *BotAPI) AddStickerToSet(ctx context.Context, req oas.AddStickerToSet) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// CreateNewStickerSet implements oas.Handler. +func (b *BotAPI) CreateNewStickerSet(ctx context.Context, req oas.CreateNewStickerSet) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// DeleteStickerFromSet implements oas.Handler. +func (b *BotAPI) DeleteStickerFromSet(ctx context.Context, req oas.DeleteStickerFromSet) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// GetStickerSet implements oas.Handler. +func (b *BotAPI) GetStickerSet(ctx context.Context, req oas.GetStickerSet) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// SetStickerPositionInSet implements oas.Handler. +func (b *BotAPI) SetStickerPositionInSet(ctx context.Context, req oas.SetStickerPositionInSet) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// SetStickerSetThumb implements oas.Handler. +func (b *BotAPI) SetStickerSetThumb(ctx context.Context, req oas.SetStickerSetThumb) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/botapi/token.go b/internal/botapi/token.go new file mode 100644 index 0000000..1d8fb6c --- /dev/null +++ b/internal/botapi/token.go @@ -0,0 +1,21 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/pool" +) + +type tokenKey struct{} + +// PropagateToken adds given token to context. +func PropagateToken(ctx context.Context, token pool.Token) context.Context { + return context.WithValue(ctx, tokenKey{}, token) +} + +// MustToken gets pool.Token from context if any. +// +// Panics otherwise. +func MustToken(ctx context.Context) pool.Token { + return ctx.Value(tokenKey{}).(pool.Token) +} diff --git a/internal/botapi/user.go b/internal/botapi/user.go new file mode 100644 index 0000000..8f952c8 --- /dev/null +++ b/internal/botapi/user.go @@ -0,0 +1,12 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// GetUserProfilePhotos implements oas.Handler. +func (b *BotAPI) GetUserProfilePhotos(ctx context.Context, req oas.GetUserProfilePhotos) (oas.ResultUserProfilePhotos, error) { + return oas.ResultUserProfilePhotos{}, &NotImplementedError{} +} diff --git a/internal/botapi/webhook.go b/internal/botapi/webhook.go new file mode 100644 index 0000000..8e2802d --- /dev/null +++ b/internal/botapi/webhook.go @@ -0,0 +1,22 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// DeleteWebhook implements oas.Handler. +func (b *BotAPI) DeleteWebhook(ctx context.Context, req oas.DeleteWebhook) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// GetWebhookInfo implements oas.Handler. +func (b *BotAPI) GetWebhookInfo(ctx context.Context) (oas.ResultWebhookInfo, error) { + return oas.ResultWebhookInfo{}, &NotImplementedError{} +} + +// SetWebhook implements oas.Handler. +func (b *BotAPI) SetWebhook(ctx context.Context, req oas.SetWebhook) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/pool/client.go b/internal/pool/client.go new file mode 100644 index 0000000..e74134b --- /dev/null +++ b/internal/pool/client.go @@ -0,0 +1,36 @@ +package pool + +import ( + "context" + "sync" + "time" + + "github.com/gotd/td/telegram" +) + +type client struct { + ctx context.Context + cancel context.CancelFunc + + mux sync.Mutex + telegram *telegram.Client + token Token + lastUsed time.Time +} + +func (c *client) Kill() { + c.cancel() +} + +func (c *client) Deadline(deadline time.Time) bool { + c.mux.Lock() + defer c.mux.Unlock() + + return c.lastUsed.Before(deadline) +} + +func (c *client) Use(t time.Time) { + c.mux.Lock() + c.lastUsed = t + c.mux.Unlock() +} diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 721979c..08d4164 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -4,149 +4,15 @@ package pool import ( "context" "crypto/sha256" - "encoding/json" "fmt" - "os" - "strconv" - "strings" "sync" "time" - "github.com/go-faster/errors" "go.uber.org/zap" - "github.com/gotd/td/session" "github.com/gotd/td/telegram" ) -type fileStorage struct { - path string - mux sync.Mutex -} - -type sessionFile struct { - Data map[string][]byte `json:"data"` -} - -func (s *fileStorage) Store(ctx context.Context, id string, data []byte) error { - s.mux.Lock() - defer s.mux.Unlock() - - var decoded sessionFile - - b, err := os.ReadFile(s.path) - if os.IsNotExist(err) || len(b) == 0 { - // Blank initial session. - } else if err == nil { - if err := json.Unmarshal(b, &decoded); err != nil { - return errors.Wrap(err, "unmarshal session file") - } - } - if decoded.Data == nil { - decoded.Data = map[string][]byte{} - } - - decoded.Data[id] = data - - if b, err = json.Marshal(&decoded); err != nil { - return err - } - - return os.WriteFile(s.path, b, 0600) -} - -func (s *fileStorage) Load(ctx context.Context, id string) ([]byte, error) { - s.mux.Lock() - defer s.mux.Unlock() - - data, err := os.ReadFile(s.path) - if os.IsNotExist(err) || len(data) == 0 { - return nil, session.ErrNotFound - } - - var decoded sessionFile - if err := json.Unmarshal(data, &decoded); err != nil { - return nil, err - } - - if len(decoded.Data) == 0 { - return nil, session.ErrNotFound - } - - return decoded.Data[id], nil -} - -type clientStorage struct { - storage StateStorage - id string -} - -func (c clientStorage) LoadSession(ctx context.Context) ([]byte, error) { - data, err := c.storage.Load(ctx, c.id) - if err != nil { - return nil, err - } - if len(data) == 0 { - return nil, session.ErrNotFound - } - return data, nil -} - -func (c clientStorage) StoreSession(ctx context.Context, data []byte) error { - return c.storage.Store(ctx, c.id, data) -} - -// Token represents bot token, like 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 -type Token struct { - ID int // 123456 - Secret string // ABC-DEF1234ghIkl-zyx57W2v1u123ew11 -} - -func ParseToken(s string) (Token, error) { - if s == "" { - return Token{}, errors.New("blank") - } - parts := strings.Split(s, ":") - if len(parts) != 2 { - return Token{}, errors.New("invalid token") - } - id, err := strconv.Atoi(parts[0]) - if err != nil { - return Token{}, err - } - return Token{ - ID: id, - Secret: parts[1], - }, err -} - -func (t Token) String() string { - return fmt.Sprintf("%d:%s", t.ID, t.Secret) -} - -type client struct { - ctx context.Context - cancel context.CancelFunc - - mux sync.Mutex - telegram *telegram.Client - token Token - lastUsed time.Time -} - -func (c *client) Deadline(deadline time.Time) bool { - c.mux.Lock() - defer c.mux.Unlock() - - return c.lastUsed.Before(deadline) -} - -func (c *client) Use(t time.Time) { - c.mux.Lock() - c.lastUsed = t - c.mux.Unlock() -} - // Pool of clients. type Pool struct { appID int @@ -164,7 +30,7 @@ func (p *Pool) tick(deadline time.Time) { for token, c := range p.clients { if c.Deadline(deadline) { toRemove = append(toRemove, token) - c.cancel() + c.Kill() } } for _, token := range toRemove { @@ -177,6 +43,19 @@ func (p *Pool) now() time.Time { return time.Now() } +// Kill shutdowns client by token. +func (p *Pool) Kill(token Token) { + p.clientsMux.Lock() + defer p.clientsMux.Unlock() + + c, ok := p.clients[token] + if !ok { + return + } + c.Kill() + delete(p.clients, token) +} + // Do acquires telegram client by token. // // Returns error if token is invalid. Block until client is available, diff --git a/internal/pool/storage.go b/internal/pool/storage.go new file mode 100644 index 0000000..e1ddae4 --- /dev/null +++ b/internal/pool/storage.go @@ -0,0 +1,88 @@ +package pool + +import ( + "context" + "encoding/json" + "os" + "sync" + + "github.com/go-faster/errors" + "github.com/gotd/td/session" +) + +type fileStorage struct { + path string + mux sync.Mutex +} + +type sessionFile struct { + Data map[string][]byte `json:"data"` +} + +func (s *fileStorage) Store(ctx context.Context, id string, data []byte) error { + s.mux.Lock() + defer s.mux.Unlock() + + var decoded sessionFile + + b, err := os.ReadFile(s.path) + if os.IsNotExist(err) || len(b) == 0 { + // Blank initial session. + } else if err == nil { + if err := json.Unmarshal(b, &decoded); err != nil { + return errors.Wrap(err, "unmarshal session file") + } + } + if decoded.Data == nil { + decoded.Data = map[string][]byte{} + } + + decoded.Data[id] = data + + if b, err = json.Marshal(&decoded); err != nil { + return err + } + + return os.WriteFile(s.path, b, 0600) +} + +func (s *fileStorage) Load(ctx context.Context, id string) ([]byte, error) { + s.mux.Lock() + defer s.mux.Unlock() + + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) || len(data) == 0 { + return nil, session.ErrNotFound + } + + var decoded sessionFile + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, err + } + + if len(decoded.Data) == 0 { + return nil, session.ErrNotFound + } + + return decoded.Data[id], nil +} + +type clientStorage struct { + storage StateStorage + id string +} + +func (c clientStorage) LoadSession(ctx context.Context) ([]byte, error) { + data, err := c.storage.Load(ctx, c.id) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, session.ErrNotFound + } + return data, nil +} + +func (c clientStorage) StoreSession(ctx context.Context, data []byte) error { + return c.storage.Store(ctx, c.id, data) +} diff --git a/internal/pool/token.go b/internal/pool/token.go new file mode 100644 index 0000000..7108a6f --- /dev/null +++ b/internal/pool/token.go @@ -0,0 +1,37 @@ +package pool + +import ( + "fmt" + "strconv" + "strings" + + "github.com/go-faster/errors" +) + +// Token represents bot token, like 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 +type Token struct { + ID int // 123456 + Secret string // ABC-DEF1234ghIkl-zyx57W2v1u123ew11 +} + +func ParseToken(s string) (Token, error) { + if s == "" { + return Token{}, errors.New("blank") + } + parts := strings.Split(s, ":") + if len(parts) != 2 { + return Token{}, errors.New("invalid token") + } + id, err := strconv.Atoi(parts[0]) + if err != nil { + return Token{}, err + } + return Token{ + ID: id, + Secret: parts[1], + }, err +} + +func (t Token) String() string { + return fmt.Sprintf("%d:%s", t.ID, t.Secret) +} From 26de281102c8e79b01a3ab0837813fbd1c7a99e6 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Thu, 9 Dec 2021 17:14:29 +0300 Subject: [PATCH 03/29] fix(me): do not return NotImplementedError --- internal/botapi/me.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/botapi/me.go b/internal/botapi/me.go index 3ca676a..d36b3f3 100644 --- a/internal/botapi/me.go +++ b/internal/botapi/me.go @@ -9,7 +9,7 @@ import ( "github.com/gotd/botapi/internal/oas" ) -func convertUser(user *tg.User) oas.User { +func convertToUser(user *tg.User) oas.User { return oas.User{ ID: int(user.ID), IsBot: user.Bot, @@ -34,7 +34,7 @@ func (b *BotAPI) GetMe(ctx context.Context) (oas.ResultUser, error) { } return oas.ResultUser{ - Result: oas.NewOptUser(convertUser(self)), + Result: oas.NewOptUser(convertToUser(self)), Ok: true, }, nil } @@ -55,5 +55,5 @@ func (b *BotAPI) LogOut(ctx context.Context) (oas.Result, error) { return oas.Result{}, err } - return resultOK(r), &NotImplementedError{} + return resultOK(r), nil } From af7ba83ad2ba9f935df58c89ca6d1816e3756db4 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Fri, 10 Dec 2021 13:22:58 +0300 Subject: [PATCH 04/29] feat: initial botapi implementation --- cmd/botapi/main.go | 99 +++++++++++++++++++++---- internal/botapi/botapi.go | 30 ++++++-- internal/botapi/command.go | 120 ++++++++++++++++++++++++++++++- internal/botapi/do.go | 11 --- internal/botapi/errors.go | 20 +++++- internal/botapi/me.go | 18 ++--- internal/botapi/peers.go | 132 ++++++++++++++++++++++++++++++++++ internal/botapi/peers_test.go | 42 +++++++++++ internal/botapi/token.go | 21 ------ internal/peers/inmemory.go | 67 +++++++++++++++++ internal/peers/peers.go | 2 + internal/peers/storage.go | 72 +++++++++++++++++++ internal/pool/client.go | 4 +- internal/pool/pool.go | 34 +++++++-- internal/pool/storage.go | 1 + 15 files changed, 595 insertions(+), 78 deletions(-) delete mode 100644 internal/botapi/do.go create mode 100644 internal/botapi/peers.go create mode 100644 internal/botapi/peers_test.go delete mode 100644 internal/botapi/token.go create mode 100644 internal/peers/inmemory.go create mode 100644 internal/peers/peers.go create mode 100644 internal/peers/storage.go diff --git a/cmd/botapi/main.go b/cmd/botapi/main.go index 95c9653..23bab52 100644 --- a/cmd/botapi/main.go +++ b/cmd/botapi/main.go @@ -2,22 +2,78 @@ package main import ( + "context" "flag" + "fmt" + "net" "net/http" + "os" + "os/signal" "time" "github.com/go-chi/chi/v5" + "github.com/go-faster/errors" "go.uber.org/zap" + "golang.org/x/sync/errgroup" + + "github.com/gotd/td/constant" "github.com/gotd/botapi/internal/botapi" "github.com/gotd/botapi/internal/oas" "github.com/gotd/botapi/internal/pool" ) -func main() { +func listen(ctx context.Context, addr string, h http.Handler, logger *zap.Logger) error { + grp, ctx := errgroup.WithContext(ctx) + + listenCfg := net.ListenConfig{} + l, err := listenCfg.Listen(ctx, "tcp", addr) + if err != nil { + return errors.Errorf("bind %q: %w", addr, err) + } + logger.Info("Listen", + zap.String("addr", addr), + ) + + srv := &http.Server{ + Addr: addr, + Handler: h, + BaseContext: func(listener net.Listener) context.Context { + return ctx + }, + } + + grp.Go(func() error { + <-ctx.Done() + + // TODO: make it configurable + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return errors.Errorf("shutdown: %w", err) + } + + return nil + }) + grp.Go(func() error { + if err := srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) { + return errors.Errorf("serve %q: %w", l.Addr(), err) + } + return nil + }) + + if err := grp.Wait(); err != nil { + return fmt.Errorf("http: %w", err) + } + + return nil +} + +func run(ctx context.Context) error { var ( - appID = flag.Int("api-id", 0, "The api_id of application") - appHash = flag.String("api-hash", "", "The api_hash of application") + appID = flag.Int("api-id", constant.TestAppID, "The api_id of application") + appHash = flag.String("api-hash", constant.TestAppHash, "The api_hash of application") addr = flag.String("addr", "localhost:8081", "http listen addr") keepalive = flag.Duration("keepalive", time.Second*5, "client keepalive") statePath = flag.String("state", "", "path to state file (json)") @@ -27,29 +83,34 @@ func main() { log, err := zap.NewDevelopment(zap.IncreaseLevel(zap.InfoLevel)) if err != nil { - panic(err) + return errors.Errorf("create logger: %w", err) } + defer func() { + _ = log.Sync() + }() var storage pool.StateStorage if *statePath != "" { storage = pool.NewFileStorage(*statePath) } - log.Info("Start", zap.String("addr", *addr)) + log.Info("Creating pool", + zap.Duration("keep_alive", *keepalive), + zap.String("storage", *statePath), + zap.Bool("debug", *debug), + ) p, err := pool.NewPool(pool.Options{ AppID: *appID, AppHash: *appHash, Log: log.Named("pool"), Storage: storage, + Debug: *debug, }) if err != nil { panic(err) } go p.RunGC(*keepalive) - handler := botapi.NewBotAPI(p, *debug) - server := oas.NewServer(handler) - // https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/getMe r := chi.NewRouter() r.Post("/bot{token}/{method}", func(w http.ResponseWriter, r *http.Request) { @@ -58,13 +119,25 @@ func main() { botapi.NotFound(w, r) return } - r.WithContext(botapi.PropagateToken(r.Context(), token)) + method := chi.URLParam(r, "method") - r.URL.Path = chi.URLParam(r, "method") - server.ServeHTTP(w, r) + log.Info("New request", zap.Int("bot_id", token.ID), zap.String("method", method)) + _ = p.Do(r.Context(), token, func(client *botapi.BotAPI) error { + r.URL.Path = method + oas.NewServer(client).ServeHTTP(w, r) + return nil + }) }) - if err := http.ListenAndServe(*addr, r); err != nil { - panic(err) + return listen(ctx, *addr, r, log.Named("http")) +} + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + if err := run(ctx); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%+v\n", err) + os.Exit(2) } } diff --git a/internal/botapi/botapi.go b/internal/botapi/botapi.go index 32485ca..2d1d71d 100644 --- a/internal/botapi/botapi.go +++ b/internal/botapi/botapi.go @@ -4,24 +4,40 @@ package botapi import ( "context" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/message/peer" + "github.com/gotd/botapi/internal/oas" - "github.com/gotd/botapi/internal/pool" + "github.com/gotd/botapi/internal/peers" ) // BotAPI is Bot API implementation. type BotAPI struct { - pool *pool.Pool - debug bool + client *telegram.Client + resolver peer.Resolver + peers peers.Storage + debug bool } -// NewBotAPI creates new BotAPI. -func NewBotAPI(pool *pool.Pool, debug bool) *BotAPI { +// NewBotAPI creates new BotAPI instance. +func NewBotAPI( + client *telegram.Client, + peers peers.Storage, + debug bool, +) *BotAPI { return &BotAPI{ - pool: pool, - debug: debug, + client: client, + resolver: peer.SingleflightResolver(peer.Plain(client.API())), + peers: peers, + debug: debug, } } +// Client returns *telegram.Client used by this instance of BotAPI. +func (b *BotAPI) Client() *telegram.Client { + return b.client +} + // GetUpdates implements oas.Handler. func (b *BotAPI) GetUpdates(ctx context.Context, req oas.GetUpdates) (oas.ResultArrayOfUpdate, error) { return oas.ResultArrayOfUpdate{}, &NotImplementedError{} diff --git a/internal/botapi/command.go b/internal/botapi/command.go index 8abcee6..950b264 100644 --- a/internal/botapi/command.go +++ b/internal/botapi/command.go @@ -3,20 +3,134 @@ package botapi import ( "context" + "github.com/go-faster/errors" + + "github.com/gotd/td/tg" + "github.com/gotd/botapi/internal/oas" ) +func (b *BotAPI) convertFromBotCommandScopeClass( + ctx context.Context, + scope *oas.BotCommandScope, +) (tg.BotCommandScopeClass, error) { + if scope == nil { + return &tg.BotCommandScopeDefault{}, nil + } + switch scope.Type { + case oas.BotCommandScopeDefaultBotCommandScope: + return &tg.BotCommandScopeDefault{}, nil + case oas.BotCommandScopeAllPrivateChatsBotCommandScope: + return &tg.BotCommandScopeUsers{}, nil + case oas.BotCommandScopeAllGroupChatsBotCommandScope: + return &tg.BotCommandScopeChats{}, nil + case oas.BotCommandScopeAllChatAdministratorsBotCommandScope: + return &tg.BotCommandScopeChatAdmins{}, nil + case oas.BotCommandScopeChatBotCommandScope: + chatID := scope.BotCommandScopeChat.ChatID + p, err := b.resolveID(ctx, chatID) + if err != nil { + return nil, errors.Errorf("resolve chatID: %w", err) + } + return &tg.BotCommandScopePeer{Peer: p}, nil + case oas.BotCommandScopeChatAdministratorsBotCommandScope: + chatID := scope.BotCommandScopeChatAdministrators.ChatID + p, err := b.resolveID(ctx, chatID) + if err != nil { + return nil, errors.Errorf("resolve chatID: %w", err) + } + return &tg.BotCommandScopePeerAdmins{Peer: p}, nil + case oas.BotCommandScopeChatMemberBotCommandScope: + userID := scope.BotCommandScopeChatMember.UserID + user, err := b.resolveUserID(ctx, userID) + if err != nil { + return nil, errors.Errorf("resolve userID: %w", err) + } + + chatID := scope.BotCommandScopeChatMember.ChatID + p, err := b.resolveID(ctx, chatID) + if err != nil { + return nil, errors.Errorf("resolve chatID: %w", err) + } + return &tg.BotCommandScopePeerUser{ + Peer: p, + UserID: user, + }, nil + default: + return nil, errors.Errorf("unknown peer type %q", scope.Type) + } +} + // GetMyCommands implements oas.Handler. func (b *BotAPI) GetMyCommands(ctx context.Context, req oas.GetMyCommands) (oas.ResultArrayOfBotCommand, error) { - return oas.ResultArrayOfBotCommand{}, &NotImplementedError{} + scope, err := b.convertFromBotCommandScopeClass(ctx, req.Scope) + if err != nil { + return oas.ResultArrayOfBotCommand{}, errors.Errorf("convert scope: %w", err) + } + + cmds, err := b.client.API().BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{ + Scope: scope, + LangCode: req.LanguageCode.Value, + }) + if err != nil { + return oas.ResultArrayOfBotCommand{}, err + } + + r := make([]oas.BotCommand, len(cmds)) + for i, cmd := range cmds { + r[i] = oas.BotCommand{ + Command: cmd.Command, + Description: cmd.Description, + } + } + + return oas.ResultArrayOfBotCommand{ + Result: r, + Ok: true, + }, nil } // SetMyCommands implements oas.Handler. func (b *BotAPI) SetMyCommands(ctx context.Context, req oas.SetMyCommands) (oas.Result, error) { - return oas.Result{}, &NotImplementedError{} + scope, err := b.convertFromBotCommandScopeClass(ctx, req.Scope) + if err != nil { + return oas.Result{}, errors.Errorf("convert scope: %w", err) + } + + commands := make([]tg.BotCommand, len(req.Commands)) + for i, cmd := range req.Commands { + commands[i] = tg.BotCommand{ + Command: cmd.Command, + Description: cmd.Description, + } + } + + r, err := b.client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{ + Scope: scope, + LangCode: req.LanguageCode.Value, + Commands: commands, + }) + if err != nil { + return oas.Result{}, err + } + + return resultOK(r), nil } // DeleteMyCommands implements oas.Handler. func (b *BotAPI) DeleteMyCommands(ctx context.Context, req oas.DeleteMyCommands) (oas.Result, error) { - return oas.Result{}, &NotImplementedError{} + scope, err := b.convertFromBotCommandScopeClass(ctx, req.Scope) + if err != nil { + return oas.Result{}, errors.Errorf("convert scope: %w", err) + } + + r, err := b.client.API().BotsResetBotCommands(ctx, &tg.BotsResetBotCommandsRequest{ + Scope: scope, + LangCode: req.LanguageCode.Value, + }) + if err != nil { + return oas.Result{}, err + } + + return resultOK(r), nil } diff --git a/internal/botapi/do.go b/internal/botapi/do.go deleted file mode 100644 index 6504fa2..0000000 --- a/internal/botapi/do.go +++ /dev/null @@ -1,11 +0,0 @@ -package botapi - -import ( - "context" - - "github.com/gotd/td/telegram" -) - -func (b *BotAPI) do(ctx context.Context, cb func(client *telegram.Client) error) error { - return b.pool.Do(ctx, MustToken(ctx), cb) -} diff --git a/internal/botapi/errors.go b/internal/botapi/errors.go index e4c0a94..d242082 100644 --- a/internal/botapi/errors.go +++ b/internal/botapi/errors.go @@ -2,6 +2,7 @@ package botapi import ( "context" + "fmt" "net/http" "github.com/go-faster/errors" @@ -18,6 +19,19 @@ func (n *NotImplementedError) Error() string { return "method not implemented yet" } +// PeerNotFoundError reports that BotAPI cannot find this peer. +type PeerNotFoundError struct { + ID oas.ID +} + +// Error implements error. +func (p *PeerNotFoundError) Error() string { + if p.ID.IsString() { + return fmt.Sprintf("peer %q not found", p.ID.String) + } + return fmt.Sprintf("peer %d not found", p.ID.Int64) +} + func errorOf(code int) oas.ErrorStatusCode { return oas.ErrorStatusCode{ StatusCode: code, @@ -32,9 +46,13 @@ func errorOf(code int) oas.ErrorStatusCode { func (b BotAPI) NewError(ctx context.Context, err error) oas.ErrorStatusCode { var ( notImplemented *NotImplementedError + peerNotFound *PeerNotFoundError ) - if errors.As(err, ¬Implemented) { + switch { + case errors.As(err, ¬Implemented): return errorOf(http.StatusNotImplemented) + case errors.As(err, &peerNotFound): + return errorOf(http.StatusNotFound) } resp := errorOf(http.StatusInternalServerError) diff --git a/internal/botapi/me.go b/internal/botapi/me.go index d36b3f3..7539d98 100644 --- a/internal/botapi/me.go +++ b/internal/botapi/me.go @@ -3,7 +3,6 @@ package botapi import ( "context" - "github.com/gotd/td/telegram" "github.com/gotd/td/tg" "github.com/gotd/botapi/internal/oas" @@ -11,7 +10,7 @@ import ( func convertToUser(user *tg.User) oas.User { return oas.User{ - ID: int(user.ID), + ID: user.ID, IsBot: user.Bot, FirstName: user.FirstName, LastName: optString(user.GetLastName), @@ -25,11 +24,8 @@ func convertToUser(user *tg.User) oas.User { // GetMe implements oas.Handler. func (b *BotAPI) GetMe(ctx context.Context) (oas.ResultUser, error) { - var self *tg.User - if err := b.do(ctx, func(client *telegram.Client) (err error) { - self, err = client.Self(ctx) - return err - }); err != nil { + self, err := b.client.Self(ctx) + if err != nil { return oas.ResultUser{}, err } @@ -41,17 +37,13 @@ func (b *BotAPI) GetMe(ctx context.Context) (oas.ResultUser, error) { // Close implements oas.Handler. func (b *BotAPI) Close(ctx context.Context) (oas.Result, error) { - b.pool.Kill(MustToken(ctx)) return resultOK(true), nil } // LogOut implements oas.Handler. func (b *BotAPI) LogOut(ctx context.Context) (oas.Result, error) { - var r bool - if err := b.do(ctx, func(client *telegram.Client) (err error) { - r, err = client.API().AuthLogOut(ctx) - return err - }); err != nil { + r, err := b.client.API().AuthLogOut(ctx) + if err != nil { return oas.Result{}, err } diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go new file mode 100644 index 0000000..67c608c --- /dev/null +++ b/internal/botapi/peers.go @@ -0,0 +1,132 @@ +package botapi + +import ( + "context" + + "github.com/go-faster/errors" + + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/oas" +) + +const ( + // MaxTDLibChatID is maximum chat TDLib ID. + MaxTDLibChatID = 999999999999 + // MaxTDLibChannelID is maximum channel TDLib ID. + MaxTDLibChannelID = 1000000000000 - int64(1<<31) + // ZeroTDLibChannelID is minimum channel TDLib ID. + ZeroTDLibChannelID = -1000000000000 + // MaxTDLibUserID is maximum user TDLib ID. + MaxTDLibUserID = (1 << 40) - 1 +) + +func toTDLibID(p tg.InputPeerClass) int64 { + switch p := p.(type) { + case *tg.InputPeerUser: + return p.GetUserID() + case *tg.InputPeerChat: + return -p.GetChatID() + case *tg.InputPeerChannel: + return ZeroTDLibChannelID - p.GetChannelID() + default: + return 0 + } +} + +func fromTDLibID(id int64) int64 { + switch { + case IsUserTDLibID(id): + case IsChatTDLibID(id): + id = -id + case IsChannelTDLibID(id): + id += ZeroTDLibChannelID + } + return id +} + +// IsUserTDLibID whether that given ID is user ID. +func IsUserTDLibID(id int64) bool { + return id > 0 && id <= MaxTDLibUserID +} + +// IsChatTDLibID whether that given ID is chat ID. +func IsChatTDLibID(id int64) bool { + return id < 0 && -MaxTDLibChatID <= id +} + +// IsChannelTDLibID whether that given ID is channel ID. +func IsChannelTDLibID(id int64) bool { + return id < 0 && + id != ZeroTDLibChannelID && + !IsChatTDLibID(id) && + ZeroTDLibChannelID-MaxTDLibChannelID <= id + +} + +func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (tg.InputPeerClass, error) { + if id.IsInt64() { + return b.resolveIntID(ctx, id) + } + + username := id.String + if len(username) < 1 || username[0] != '@' { + return nil, &PeerNotFoundError{ID: id} + } + // Cut @. + username = username[1:] + + p, err := b.resolver.ResolveDomain(ctx, username) + if err != nil { + return nil, errors.Errorf("resolve: %w", err) + } + switch p.(type) { + case *tg.InputPeerChat, *tg.InputPeerChannel: + return p, nil + default: + return nil, &PeerNotFoundError{ID: id} + } +} + +func (b *BotAPI) resolveUserID(ctx context.Context, id int64) (*tg.InputUser, error) { + user, ok, err := b.peers.FindUser(ctx, id) + switch { + case err != nil: + return nil, errors.Errorf("find user: %d", id) + case !ok: + return nil, &PeerNotFoundError{ID: oas.NewInt64ID(id)} + } + return user.AsInput(), nil +} + +func (b *BotAPI) resolveIntID(ctx context.Context, chatID oas.ID) (tg.InputPeerClass, error) { + id := chatID.Int64 + cleanID := fromTDLibID(id) + + if IsUserTDLibID(id) { + user, ok, err := b.peers.FindUser(ctx, cleanID) + switch { + case err != nil: + return nil, errors.Errorf("find user: %d", id) + case !ok: + return nil, &PeerNotFoundError{ID: chatID} + } + return user.AsInputPeer(), nil + } + + chat, ok, err := b.peers.FindChat(ctx, cleanID) + switch { + case err != nil: + return nil, errors.Errorf("find chat: %d", id) + case !ok: + return nil, &PeerNotFoundError{ID: chatID} + } + switch chat := chat.(type) { + case *tg.Chat: + return chat.AsInputPeer(), nil + case *tg.Channel: + return chat.AsInputPeer(), nil + default: + return nil, &PeerNotFoundError{ID: chatID} + } +} diff --git a/internal/botapi/peers_test.go b/internal/botapi/peers_test.go new file mode 100644 index 0000000..9506d89 --- /dev/null +++ b/internal/botapi/peers_test.go @@ -0,0 +1,42 @@ +package botapi + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gotd/td/tg" +) + +func Test_toTDLibID(t *testing.T) { + tests := []struct { + name string + p tg.InputPeerClass + }{ + {"User", &tg.InputPeerUser{UserID: 309570373}}, + {"Bot", &tg.InputPeerUser{UserID: 140267078}}, + {"Chat", &tg.InputPeerChat{ChatID: 365219918}}, + {"Channel", &tg.InputPeerChat{ChatID: 1228418968}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := require.New(t) + + tdlibID := toTDLibID(tt.p) + var mtprotoID int64 + switch t := tt.p.(type) { + case *tg.InputPeerUser: + mtprotoID = t.UserID + a.True(IsUserTDLibID(tdlibID)) + case *tg.InputPeerChat: + mtprotoID = t.ChatID + a.True(IsChatTDLibID(tdlibID)) + case *tg.InputPeerChannel: + mtprotoID = t.ChannelID + a.True(IsChannelTDLibID(tdlibID)) + } + cleanID := fromTDLibID(tdlibID) + a.Equal(mtprotoID, cleanID) + }) + } +} diff --git a/internal/botapi/token.go b/internal/botapi/token.go deleted file mode 100644 index 1d8fb6c..0000000 --- a/internal/botapi/token.go +++ /dev/null @@ -1,21 +0,0 @@ -package botapi - -import ( - "context" - - "github.com/gotd/botapi/internal/pool" -) - -type tokenKey struct{} - -// PropagateToken adds given token to context. -func PropagateToken(ctx context.Context, token pool.Token) context.Context { - return context.WithValue(ctx, tokenKey{}, token) -} - -// MustToken gets pool.Token from context if any. -// -// Panics otherwise. -func MustToken(ctx context.Context) pool.Token { - return ctx.Value(tokenKey{}).(pool.Token) -} diff --git a/internal/peers/inmemory.go b/internal/peers/inmemory.go new file mode 100644 index 0000000..853bc92 --- /dev/null +++ b/internal/peers/inmemory.go @@ -0,0 +1,67 @@ +package peers + +import ( + "context" + "sync" + + "github.com/gotd/td/tg" +) + +// InmemoryStorage stores users and chats info in memory. +type InmemoryStorage struct { + chats map[int64]tg.FullChat + chatsMux sync.RWMutex + + usersMux sync.RWMutex + users map[int64]*tg.User +} + +// NewInmemoryStorage creates new InmemoryStorage. +func NewInmemoryStorage() *InmemoryStorage { + return &InmemoryStorage{ + chats: map[int64]tg.FullChat{}, + users: map[int64]*tg.User{}, + } +} + +// SaveUsers implements FileStorage. +func (f *InmemoryStorage) SaveUsers(ctx context.Context, users ...*tg.User) error { + f.usersMux.Lock() + defer f.usersMux.Unlock() + + for _, u := range users { + f.users[u.GetID()] = u + } + + return nil +} + +// SaveChats implements InmemoryStorage. +func (f *InmemoryStorage) SaveChats(ctx context.Context, chats ...tg.FullChat) error { + f.chatsMux.Lock() + defer f.chatsMux.Unlock() + + for _, u := range chats { + f.chats[u.GetID()] = u + } + + return nil +} + +// FindUser implements InmemoryStorage. +func (f *InmemoryStorage) FindUser(ctx context.Context, id int64) (*tg.User, bool, error) { + f.usersMux.RLock() + defer f.usersMux.RUnlock() + + v, ok := f.users[id] + return v, ok, nil +} + +// FindChat implements InmemoryStorage. +func (f *InmemoryStorage) FindChat(ctx context.Context, id int64) (tg.FullChat, bool, error) { + f.chatsMux.RLock() + defer f.chatsMux.RUnlock() + + v, ok := f.chats[id] + return v, ok, nil +} diff --git a/internal/peers/peers.go b/internal/peers/peers.go new file mode 100644 index 0000000..068ed3b --- /dev/null +++ b/internal/peers/peers.go @@ -0,0 +1,2 @@ +// Package peers contains some helpers to work with peers and store them. +package peers diff --git a/internal/peers/storage.go b/internal/peers/storage.go new file mode 100644 index 0000000..4ea648b --- /dev/null +++ b/internal/peers/storage.go @@ -0,0 +1,72 @@ +package peers + +import ( + "context" + + "go.uber.org/multierr" + + "github.com/gotd/td/telegram" + "github.com/gotd/td/tg" +) + +// Storage represents peer storage. +type Storage interface { + SaveUsers(ctx context.Context, users ...*tg.User) error + SaveChats(ctx context.Context, chats ...tg.FullChat) error + + FindUser(ctx context.Context, id int64) (*tg.User, bool, error) + FindChat(ctx context.Context, id int64) (tg.FullChat, bool, error) +} + +func save(ctx context.Context, s Storage, from interface { + MapChats() tg.ChatClassArray + MapUsers() tg.UserClassArray +}) error { + return multierr.Append( + s.SaveChats(ctx, from.MapChats().AppendOnlyFull(nil)...), + s.SaveUsers(ctx, from.MapUsers().AppendOnlyNotEmpty(nil)...), + ) +} + +// UpdateHook is update hook for Storage. +func UpdateHook(s Storage, next telegram.UpdateHandler) telegram.UpdateHandler { + return telegram.UpdateHandlerFunc(func(ctx context.Context, u tg.UpdatesClass) error { + var err error + switch v := u.(type) { + case *tg.UpdatesCombined: + err = save(ctx, s, v) + case *tg.Updates: + err = save(ctx, s, v) + } + return multierr.Append(err, next.Handle(ctx, u)) + }) +} + +// AccessHasher is implementation of updates.ChannelAccessHasher based on Storage. +type AccessHasher struct { + Storage Storage +} + +// SetChannelAccessHash implements updates.ChannelAccessHasher. +func (a AccessHasher) SetChannelAccessHash(userID, channelID, accessHash int64) error { + // TODO: update access hash? + return nil +} + +// GetChannelAccessHash implements updates.ChannelAccessHasher. +func (a AccessHasher) GetChannelAccessHash(userID, channelID int64) (accessHash int64, found bool, err error) { + v, ok, err := a.Storage.FindChat(context.TODO(), channelID) + if err != nil { + return 0, false, err + } + if !ok { + return 0, false, nil + } + nonForbidden, ok := v.(interface { + GetAccessHash() int64 + }) + if !ok { + return 0, false, nil + } + return nonForbidden.GetAccessHash(), true, nil +} diff --git a/internal/pool/client.go b/internal/pool/client.go index e74134b..a8c3cd2 100644 --- a/internal/pool/client.go +++ b/internal/pool/client.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/gotd/td/telegram" + "github.com/gotd/botapi/internal/botapi" ) type client struct { @@ -13,7 +13,7 @@ type client struct { cancel context.CancelFunc mux sync.Mutex - telegram *telegram.Client + api *botapi.BotAPI token Token lastUsed time.Time } diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 08d4164..ff67b57 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -11,12 +11,18 @@ import ( "go.uber.org/zap" "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/updates" + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/botapi" + "github.com/gotd/botapi/internal/peers" ) // Pool of clients. type Pool struct { appID int appHash string + debug bool log *zap.Logger storage StateStorage @@ -60,7 +66,7 @@ func (p *Pool) Kill(token Token) { // // Returns error if token is invalid. Block until client is available, // authentication error or context cancelled. -func (p *Pool) Do(ctx context.Context, token Token, fn func(client *telegram.Client) error) error { +func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAPI) error) error { p.clientsMux.Lock() c, ok := p.clients[token] p.clientsMux.Unlock() @@ -68,11 +74,23 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *telegram.Cli if ok { // Happy path. c.Use(p.now()) - return fn(c.telegram) + return fn(c.api) } - + log := p.log.Named("client").With(zap.Int("id", token.ID)) + + peerStorage := peers.NewInmemoryStorage() + gaps := updates.New(updates.Config{ + Handler: telegram.UpdateHandlerFunc(func(ctx context.Context, u tg.UpdatesClass) error { + return nil + }), + AccessHasher: peers.AccessHasher{ + Storage: peerStorage, + }, + Logger: log.Named("gaps"), + }) options := telegram.Options{ - Logger: p.log.Named("client").With(zap.Int("id", token.ID)), + Logger: log, + UpdateHandler: peers.UpdateHook(peerStorage, gaps), } if p.storage != nil { options.SessionStorage = clientStorage{ @@ -86,7 +104,7 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *telegram.Cli c = &client{ ctx: tgContext, cancel: tgCancel, - telegram: tgClient, + api: botapi.NewBotAPI(tgClient, peerStorage, p.debug), token: token, lastUsed: p.now(), } @@ -101,7 +119,7 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *telegram.Cli // Removing client from client list on close. p.clientsMux.Lock() c, ok := p.clients[token] - if ok && c.telegram == tgClient { + if ok && c.api.Client() == tgClient { delete(p.clients, token) } p.clientsMux.Unlock() @@ -155,7 +173,7 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *telegram.Cli } p.clientsMux.Unlock() - return fn(c.telegram) + return fn(c.api) case <-ctx.Done(): return ctx.Err() case <-tgContext.Done(): @@ -176,6 +194,7 @@ type Options struct { AppHash string Log *zap.Logger Storage StateStorage + Debug bool } type StateStorage interface { @@ -193,6 +212,7 @@ func NewPool(opt Options) (*Pool, error) { p := &Pool{ appID: opt.AppID, appHash: opt.AppHash, + debug: opt.Debug, log: opt.Log, clients: map[Token]*client{}, storage: opt.Storage, diff --git a/internal/pool/storage.go b/internal/pool/storage.go index e1ddae4..99d9816 100644 --- a/internal/pool/storage.go +++ b/internal/pool/storage.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/go-faster/errors" + "github.com/gotd/td/session" ) From 93343d9ceb73c01138ff40e4778abff47de54d09 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 11 Dec 2021 19:20:12 +0300 Subject: [PATCH 05/29] chore: go mod tidy --- go.mod | 6 ++---- go.sum | 4 ---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 3ea6fca..babe57c 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,6 @@ require ( github.com/go-chi/chi/v5 v5.0.7 github.com/google/uuid v1.3.0 github.com/gotd/td v0.53.0 - github.com/ogen-go/errors v0.4.0 - github.com/ogen-go/jx v0.13.3 github.com/ogen-go/ogen v0.0.0-20211211145630-e16dcf3319e7 github.com/stretchr/testify v1.7.0 go.opentelemetry.io/otel v1.3.0 @@ -23,6 +21,8 @@ require ( require ( github.com/go-faster/errors v0.5.0 github.com/go-faster/jx v0.25.0 + go.uber.org/multierr v1.7.0 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c ) require ( @@ -43,10 +43,8 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.7.0 // indirect golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 // indirect - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect nhooyr.io/websocket v1.8.7 // indirect diff --git a/go.sum b/go.sum index 4dd133c..6e3cf5b 100644 --- a/go.sum +++ b/go.sum @@ -104,10 +104,6 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/ogen-go/errors v0.4.0 h1:fHO9CV4AAekpt4jtLFNx1NTsMWLTF9SfT+L8PT1VL8M= -github.com/ogen-go/errors v0.4.0/go.mod h1:JRD2VpTPGlkV3ryzVKC6hnCL9b0BD2HMcn+1KgDy4QU= -github.com/ogen-go/jx v0.13.3 h1:xr5sdAPFmOUiLekuRASYiyeWtNUEebdbCV4x6CCksoM= -github.com/ogen-go/jx v0.13.3/go.mod h1:KpIek5rlHZ2WKcrhbCOaiJti28JZbLbMElrlF2sa6qg= github.com/ogen-go/ogen v0.0.0-20211211145630-e16dcf3319e7 h1:mlNw+mPfGr6UDw7atlKAE9sGL1KkqWPe/ZgBQmvqCno= github.com/ogen-go/ogen v0.0.0-20211211145630-e16dcf3319e7/go.mod h1:b30KYUTj30mV20YdJNI+jPc6f2SVFH+GpqiABuV5/Fc= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= From 130b010fb127cf8e1ab5c31c5b19734fe3cd63cb Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 12 Dec 2021 14:11:04 +0300 Subject: [PATCH 06/29] fix(botapi): increase keepalive --- cmd/botapi/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/botapi/main.go b/cmd/botapi/main.go index 23bab52..96f536f 100644 --- a/cmd/botapi/main.go +++ b/cmd/botapi/main.go @@ -75,7 +75,7 @@ func run(ctx context.Context) error { appID = flag.Int("api-id", constant.TestAppID, "The api_id of application") appHash = flag.String("api-hash", constant.TestAppHash, "The api_hash of application") addr = flag.String("addr", "localhost:8081", "http listen addr") - keepalive = flag.Duration("keepalive", time.Second*5, "client keepalive") + keepalive = flag.Duration("keepalive", 5*time.Minute, "client keepalive") statePath = flag.String("state", "", "path to state file (json)") debug = flag.Bool("debug", false, "enables debug mode") ) From aff6ad2849a4fe0a74f11f8c03f674c9883ee5fc Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 12 Dec 2021 14:12:00 +0300 Subject: [PATCH 07/29] feat(botapi): initial message sending support --- internal/botapi/botapi.go | 91 ++++- internal/botapi/command.go | 60 ++-- internal/botapi/convert_message.go | 529 +++++++++++++++++++++++++++++ internal/botapi/errors.go | 24 +- internal/botapi/file_id.go | 10 + internal/botapi/markup.go | 180 ++++++++++ internal/botapi/me.go | 14 +- internal/botapi/optional.go | 18 +- internal/botapi/options.go | 15 + internal/botapi/peers.go | 93 ++++- internal/botapi/result.go | 7 +- internal/botapi/send.go | 64 +++- internal/botapi/webhook.go | 2 +- internal/pool/pool.go | 25 +- 14 files changed, 1071 insertions(+), 61 deletions(-) create mode 100644 internal/botapi/convert_message.go create mode 100644 internal/botapi/file_id.go create mode 100644 internal/botapi/markup.go create mode 100644 internal/botapi/options.go diff --git a/internal/botapi/botapi.go b/internal/botapi/botapi.go index 2d1d71d..71595d0 100644 --- a/internal/botapi/botapi.go +++ b/internal/botapi/botapi.go @@ -3,9 +3,16 @@ package botapi import ( "context" + "sync" + "github.com/go-faster/errors" "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/message" "github.com/gotd/td/telegram/message/peer" + "github.com/gotd/td/telegram/updates" + "github.com/gotd/td/tg" + "go.uber.org/atomic" + "go.uber.org/zap" "github.com/gotd/botapi/internal/oas" "github.com/gotd/botapi/internal/peers" @@ -13,24 +20,96 @@ import ( // BotAPI is Bot API implementation. type BotAPI struct { - client *telegram.Client + client *telegram.Client + raw *tg.Client + gaps *updates.Manager + + sender *message.Sender resolver peer.Resolver peers peers.Storage - debug bool + + self *tg.User + selfID atomic.Int64 + selfMux sync.Mutex + + debug bool + logger *zap.Logger } // NewBotAPI creates new BotAPI instance. func NewBotAPI( client *telegram.Client, + gaps *updates.Manager, peers peers.Storage, - debug bool, + opts Options, ) *BotAPI { + opts.setDefaults() + + raw := client.API() + resolver := peer.SingleflightResolver(peer.Plain(raw)) return &BotAPI{ client: client, - resolver: peer.SingleflightResolver(peer.Plain(client.API())), + raw: raw, + gaps: gaps, + sender: message.NewSender(raw).WithResolver(resolver), + resolver: resolver, peers: peers, - debug: debug, + debug: opts.Debug, + logger: opts.Logger, + } +} + +// Init makes some initialization requests. +func (b *BotAPI) Init(ctx context.Context) error { + me, err := b.client.Self(ctx) + if err != nil { + return errors.Wrap(err, "self") + } + + if err := b.gaps.Auth(ctx, b.raw, me.ID, true, false); err != nil { + return errors.Wrap(err, "init gaps") } + + b.updateSelf(me) + return nil +} + +func (b *BotAPI) updateSelf(user *tg.User) { + b.selfMux.Lock() + b.self = user + b.selfID.Store(user.ID) + b.selfMux.Unlock() +} + +// UpdateHook is update hook to update some states. +func (b *BotAPI) UpdateHook(ctx context.Context, u tg.UpdatesClass) error { + type updateWithEntities interface { + tg.UpdatesClass + GetUsers() []tg.UserClass + } + + t, ok := u.(updateWithEntities) + if !ok { + return nil + } + + selfID := b.selfID.Load() + if selfID == 0 { + return nil + } + + for _, user := range t.GetUsers() { + nonEmpty, ok := user.AsNotEmpty() + if !ok { + continue + } + + if selfID == b.self.GetID() { + b.updateSelf(nonEmpty) + } + } + + return nil } // Client returns *telegram.Client used by this instance of BotAPI. @@ -39,7 +118,7 @@ func (b *BotAPI) Client() *telegram.Client { } // GetUpdates implements oas.Handler. -func (b *BotAPI) GetUpdates(ctx context.Context, req oas.GetUpdates) (oas.ResultArrayOfUpdate, error) { +func (b *BotAPI) GetUpdates(ctx context.Context, req oas.OptGetUpdates) (oas.ResultArrayOfUpdate, error) { return oas.ResultArrayOfUpdate{}, &NotImplementedError{} } diff --git a/internal/botapi/command.go b/internal/botapi/command.go index 950b264..39e637f 100644 --- a/internal/botapi/command.go +++ b/internal/botapi/command.go @@ -10,7 +10,7 @@ import ( "github.com/gotd/botapi/internal/oas" ) -func (b *BotAPI) convertFromBotCommandScopeClass( +func (b *BotAPI) convertToBotCommandScopeClass( ctx context.Context, scope *oas.BotCommandScope, ) (tg.BotCommandScopeClass, error) { @@ -30,31 +30,31 @@ func (b *BotAPI) convertFromBotCommandScopeClass( chatID := scope.BotCommandScopeChat.ChatID p, err := b.resolveID(ctx, chatID) if err != nil { - return nil, errors.Errorf("resolve chatID: %w", err) + return nil, errors.Wrap(err, "resolve chatID") } return &tg.BotCommandScopePeer{Peer: p}, nil case oas.BotCommandScopeChatAdministratorsBotCommandScope: chatID := scope.BotCommandScopeChatAdministrators.ChatID p, err := b.resolveID(ctx, chatID) if err != nil { - return nil, errors.Errorf("resolve chatID: %w", err) + return nil, errors.Wrap(err, "resolve chatID") } return &tg.BotCommandScopePeerAdmins{Peer: p}, nil case oas.BotCommandScopeChatMemberBotCommandScope: userID := scope.BotCommandScopeChatMember.UserID user, err := b.resolveUserID(ctx, userID) if err != nil { - return nil, errors.Errorf("resolve userID: %w", err) + return nil, errors.Wrap(err, "resolve userID") } chatID := scope.BotCommandScopeChatMember.ChatID p, err := b.resolveID(ctx, chatID) if err != nil { - return nil, errors.Errorf("resolve chatID: %w", err) + return nil, errors.Wrap(err, "resolve chatID") } return &tg.BotCommandScopePeerUser{ Peer: p, - UserID: user, + UserID: user.AsInput(), }, nil default: return nil, errors.Errorf("unknown peer type %q", scope.Type) @@ -62,15 +62,24 @@ func (b *BotAPI) convertFromBotCommandScopeClass( } // GetMyCommands implements oas.Handler. -func (b *BotAPI) GetMyCommands(ctx context.Context, req oas.GetMyCommands) (oas.ResultArrayOfBotCommand, error) { - scope, err := b.convertFromBotCommandScopeClass(ctx, req.Scope) - if err != nil { - return oas.ResultArrayOfBotCommand{}, errors.Errorf("convert scope: %w", err) +func (b *BotAPI) GetMyCommands(ctx context.Context, req oas.OptGetMyCommands) (oas.ResultArrayOfBotCommand, error) { + var ( + scope tg.BotCommandScopeClass = &tg.BotCommandScopeDefault{} + langCode string + ) + + if input, ok := req.Get(); ok { + s, err := b.convertToBotCommandScopeClass(ctx, input.Scope) + if err != nil { + return oas.ResultArrayOfBotCommand{}, errors.Wrap(err, "convert scope") + } + scope = s + langCode = input.LanguageCode.Value } - cmds, err := b.client.API().BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{ + cmds, err := b.raw.BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{ Scope: scope, - LangCode: req.LanguageCode.Value, + LangCode: langCode, }) if err != nil { return oas.ResultArrayOfBotCommand{}, err @@ -92,9 +101,9 @@ func (b *BotAPI) GetMyCommands(ctx context.Context, req oas.GetMyCommands) (oas. // SetMyCommands implements oas.Handler. func (b *BotAPI) SetMyCommands(ctx context.Context, req oas.SetMyCommands) (oas.Result, error) { - scope, err := b.convertFromBotCommandScopeClass(ctx, req.Scope) + scope, err := b.convertToBotCommandScopeClass(ctx, req.Scope) if err != nil { - return oas.Result{}, errors.Errorf("convert scope: %w", err) + return oas.Result{}, errors.Wrap(err, "convert scope") } commands := make([]tg.BotCommand, len(req.Commands)) @@ -105,7 +114,7 @@ func (b *BotAPI) SetMyCommands(ctx context.Context, req oas.SetMyCommands) (oas. } } - r, err := b.client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{ + r, err := b.raw.BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{ Scope: scope, LangCode: req.LanguageCode.Value, Commands: commands, @@ -118,15 +127,24 @@ func (b *BotAPI) SetMyCommands(ctx context.Context, req oas.SetMyCommands) (oas. } // DeleteMyCommands implements oas.Handler. -func (b *BotAPI) DeleteMyCommands(ctx context.Context, req oas.DeleteMyCommands) (oas.Result, error) { - scope, err := b.convertFromBotCommandScopeClass(ctx, req.Scope) - if err != nil { - return oas.Result{}, errors.Errorf("convert scope: %w", err) +func (b *BotAPI) DeleteMyCommands(ctx context.Context, req oas.OptDeleteMyCommands) (oas.Result, error) { + var ( + scope tg.BotCommandScopeClass = &tg.BotCommandScopeDefault{} + langCode string + ) + + if input, ok := req.Get(); ok { + s, err := b.convertToBotCommandScopeClass(ctx, input.Scope) + if err != nil { + return oas.Result{}, errors.Wrap(err, "convert scope") + } + scope = s + langCode = input.LanguageCode.Value } - r, err := b.client.API().BotsResetBotCommands(ctx, &tg.BotsResetBotCommandsRequest{ + r, err := b.raw.BotsResetBotCommands(ctx, &tg.BotsResetBotCommandsRequest{ Scope: scope, - LangCode: req.LanguageCode.Value, + LangCode: langCode, }) if err != nil { return oas.Result{}, err diff --git a/internal/botapi/convert_message.go b/internal/botapi/convert_message.go new file mode 100644 index 0000000..2d22ddc --- /dev/null +++ b/internal/botapi/convert_message.go @@ -0,0 +1,529 @@ +package botapi + +import ( + "context" + "net/url" + "strconv" + + "github.com/go-faster/errors" + "github.com/gotd/td/fileid" + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "github.com/gotd/botapi/internal/oas" +) + +var maskCoordsNames = []string{"forehead", "eyes", "mouth", "chin"} + +func (b *BotAPI) convertToBotAPIEntities( + ctx context.Context, + entities []tg.MessageEntityClass, +) (r []oas.MessageEntity, _ error) { + for _, entity := range entities { + e := oas.MessageEntity{ + Offset: entity.GetOffset(), + Length: entity.GetLength(), + } + + switch entity := entity.(type) { + case *tg.MessageEntityMention: + e.Type = oas.MessageEntityTypeMention + case *tg.MessageEntityHashtag: + e.Type = oas.MessageEntityTypeHashtag + case *tg.MessageEntityBotCommand: + e.Type = oas.MessageEntityTypeBotCommand + case *tg.MessageEntityURL: + e.Type = oas.MessageEntityTypeURL + case *tg.MessageEntityEmail: + e.Type = oas.MessageEntityTypeEmail + case *tg.MessageEntityBold: + e.Type = oas.MessageEntityTypeBold + case *tg.MessageEntityItalic: + e.Type = oas.MessageEntityTypeItalic + case *tg.MessageEntityCode: + e.Type = oas.MessageEntityTypeCode + case *tg.MessageEntityPre: + e.Type = oas.MessageEntityTypePre + e.Language.SetTo(entity.Language) + case *tg.MessageEntityTextURL: + e.Type = oas.MessageEntityTypeTextLink + u, _ := url.Parse(entity.URL) + if u == nil { + u = new(url.URL) + } + e.URL.SetTo(*u) + case *tg.MessageEntityMentionName: + e.Type = oas.MessageEntityTypeTextMention + user, ok, err := b.peers.FindUser(ctx, entity.UserID) + if err != nil { + return nil, errors.Wrapf(err, "find user: %d", entity.UserID) + } + if ok { + e.User.SetTo(convertToUser(user)) + } + case *tg.MessageEntityPhone: + e.Type = oas.MessageEntityTypePhoneNumber + case *tg.MessageEntityCashtag: + e.Type = oas.MessageEntityTypeCashtag + case *tg.MessageEntityUnderline: + e.Type = oas.MessageEntityTypeUnderline + case *tg.MessageEntityStrike: + e.Type = oas.MessageEntityTypeStrikethrough + } + r = append(r, e) + } + + return r, nil +} + +func (b *BotAPI) convertToBotAPIPhotoSizes(p tg.PhotoClass) (r []oas.PhotoSize) { + photo, ok := p.AsNotEmpty() + if !ok { + return nil + } + + type sizedPhoto interface { + GetW() int + GetH() int + GetType() string + } + for _, sz := range photo.Sizes { + size, ok := sz.(sizedPhoto) + if !ok { + continue + } + + t := size.GetType() + if len(t) < 1 { + continue + } + + fileID, fileUniqueID := b.encodeFileID(fileid.FromPhoto(photo, rune(t[0]))) + r = append(r, oas.PhotoSize{ + FileID: fileID, + FileUniqueID: fileUniqueID, + Width: size.GetW(), + Height: size.GetH(), + // TODO(tdakkota): get size from variant/compute if downloaded/etc + FileSize: oas.OptInt{}, + }) + } + + return r +} + +func convertToBotAPILocation(p *tg.GeoPoint) (r oas.Location) { + r = oas.Location{ + Longitude: p.Long, + Latitude: p.Lat, + } + if v, ok := p.GetAccuracyRadius(); ok { + r.HorizontalAccuracy.SetTo(float64(v)) + } + return r +} + +func (b *BotAPI) setDocumentAttachment(ctx context.Context, d *tg.Document, r *oas.Message) error { + f := fileid.FromDocument(d) + fileID, fileUniqueID := b.encodeFileID(f) + + var ( + mimeType = oas.OptString{ + Value: d.MimeType, + Set: d.MimeType != "", + } + fileName oas.OptString + // TODO(tdakkota): get thumb + thumb oas.OptPhotoSize + + width int + height int + duration int + + animated bool + ) + for _, attr := range d.Attributes { + switch attr := attr.(type) { + case *tg.DocumentAttributeFilename: + fileName.SetTo(attr.FileName) + case *tg.DocumentAttributeVideo: + width = attr.W + height = attr.H + duration = attr.Duration + case *tg.DocumentAttributeImageSize: + width = attr.W + height = attr.H + case *tg.DocumentAttributeAnimated: + animated = true + } + } + + for _, attr := range d.Attributes { + switch attr := attr.(type) { + case *tg.DocumentAttributeImageSize: + case *tg.DocumentAttributeAnimated: + r.Animation.SetTo(oas.Animation{ + FileID: fileID, + FileUniqueID: fileUniqueID, + Width: width, + Height: height, + Duration: duration, + Thumb: thumb, + FileName: fileName, + MimeType: mimeType, + FileSize: oas.NewOptInt(d.Size), + }) + case *tg.DocumentAttributeSticker: + var maskPosition oas.OptMaskPosition + if coords, ok := attr.GetMaskCoords(); ok && + coords.N >= 0 && coords.N < len(maskCoordsNames) { + maskPosition.SetTo(oas.MaskPosition{ + Point: maskCoordsNames[coords.N], + XShift: coords.X, + YShift: coords.Y, + Scale: coords.Zoom, + }) + } + + stickerSet, err := b.raw.MessagesGetStickerSet(ctx, attr.Stickerset) + if err != nil { + return errors.Wrap(err, "get sticker_set") + } + + r.Sticker.SetTo(oas.Sticker{ + FileID: fileID, + FileUniqueID: fileUniqueID, + Width: width, + Height: height, + IsAnimated: stickerSet.Set.Animated, + Thumb: thumb, + Emoji: oas.NewOptString(attr.Alt), + SetName: oas.NewOptString(stickerSet.Set.ShortName), + MaskPosition: maskPosition, + FileSize: oas.NewOptInt(d.Size), + }) + case *tg.DocumentAttributeVideo: + if animated { + break + } + + if attr.RoundMessage { + r.VideoNote.SetTo(oas.VideoNote{ + FileID: fileID, + FileUniqueID: fileUniqueID, + Length: width, + Duration: duration, + Thumb: thumb, + FileSize: oas.NewOptInt(d.Size), + }) + } else { + r.Video.SetTo(oas.Video{ + FileID: fileID, + FileUniqueID: fileUniqueID, + Width: width, + Height: height, + Duration: duration, + Thumb: thumb, + FileName: fileName, + MimeType: mimeType, + FileSize: oas.NewOptInt(d.Size), + }) + } + case *tg.DocumentAttributeAudio: + if attr.Voice { + r.Voice.SetTo(oas.Voice{ + FileID: fileID, + FileUniqueID: fileUniqueID, + Duration: attr.Duration, + MimeType: mimeType, + FileSize: oas.NewOptInt(d.Size), + }) + } else { + r.Audio.SetTo(oas.Audio{ + FileID: fileID, + FileUniqueID: fileUniqueID, + Duration: attr.Duration, + Performer: optString(attr.GetPerformer), + Title: optString(attr.GetTitle), + FileName: fileName, + MimeType: mimeType, + FileSize: oas.NewOptInt(d.Size), + Thumb: thumb, + }) + } + case *tg.DocumentAttributeHasStickers: + } + } + + if !r.Sticker.Set && + !r.VideoNote.Set && !r.Video.Set && + !r.Voice.Set && !r.Audio.Set { + r.Document.SetTo(oas.Document{ + FileID: fileID, + FileUniqueID: fileUniqueID, + Thumb: thumb, + FileName: fileName, + MimeType: mimeType, + FileSize: oas.NewOptInt(d.Size), + }) + } + + return nil +} + +func (b *BotAPI) convertMessageMedia(ctx context.Context, media tg.MessageMediaClass, r *oas.Message) error { + switch media := media.(type) { + case *tg.MessageMediaPhoto: + r.Photo = b.convertToBotAPIPhotoSizes(media.Photo) + case *tg.MessageMediaGeo: + p, ok := media.Geo.AsNotEmpty() + if !ok { + break + } + resultLocation := oas.Location{ + Longitude: p.Long, + Latitude: p.Lat, + } + if v, ok := p.GetAccuracyRadius(); ok { + resultLocation.HorizontalAccuracy.SetTo(float64(v)) + } + r.Location.SetTo(resultLocation) + case *tg.MessageMediaContact: + r.Contact.SetTo(oas.Contact{ + PhoneNumber: media.PhoneNumber, + FirstName: media.FirstName, + LastName: oas.OptString{ + Value: media.LastName, + Set: media.LastName != "", + }, + UserID: oas.OptInt64{ + Value: media.UserID, + Set: media.UserID != 0, + }, + Vcard: oas.OptString{ + Value: media.Vcard, + Set: media.Vcard != "", + }, + }) + case *tg.MessageMediaDocument: + d, ok := media.Document.AsNotEmpty() + if !ok { + break + } + if err := b.setDocumentAttachment(ctx, d, r); err != nil { + return errors.Wrap(err, "get document") + } + case *tg.MessageMediaWebPage: + // Bots do not receive web page attachments. + case *tg.MessageMediaVenue: + p, ok := media.Geo.AsNotEmpty() + if !ok { + break + } + resultVenue := oas.Venue{ + Location: convertToBotAPILocation(p), + Title: media.Title, + Address: media.Address, + FoursquareID: oas.OptString{}, + FoursquareType: oas.OptString{}, + GooglePlaceID: oas.OptString{}, + GooglePlaceType: oas.OptString{}, + } + switch media.Provider { + case "foursquare": + resultVenue.FoursquareID.SetTo(media.VenueID) + resultVenue.FoursquareType.SetTo(media.VenueType) + case "gplaces": + resultVenue.GooglePlaceID.SetTo(media.VenueID) + resultVenue.GooglePlaceType.SetTo(media.VenueType) + } + case *tg.MessageMediaGame: + game := media.Game + + r.Game.SetTo(oas.Game{ + Title: game.Title, + Description: game.Description, + Photo: b.convertToBotAPIPhotoSizes(game.Photo), + Text: r.Text, + TextEntities: r.Entities, + Animation: oas.OptAnimation{}, + }) + case *tg.MessageMediaInvoice: + + case *tg.MessageMediaGeoLive: + p, ok := media.Geo.AsNotEmpty() + if !ok { + break + } + r.Location.SetTo(convertToBotAPILocation(p)) + case *tg.MessageMediaPoll: + var ( + poll = media.Poll + results = media.Results + + typ = oas.PollTypeRegular + ) + if a, r := len(poll.Answers), len(results.Results); a != r { + b.logger.Warn("Got poll where len(answers) != len(results)", + zap.Int("answers", a), + zap.Int("results", r), + ) + break + } + + if poll.Quiz { + typ = oas.PollTypeQuiz + } + resultPoll := oas.Poll{ + ID: strconv.FormatInt(poll.ID, 10), + Question: poll.Question, + Options: nil, + TotalVoterCount: results.TotalVoters, + IsClosed: poll.Closed, + IsAnonymous: !poll.PublicVoters, + Type: typ, + AllowsMultipleAnswers: poll.MultipleChoice, + CorrectOptionID: oas.OptInt{}, + Explanation: optString(results.GetSolution), + ExplanationEntities: nil, + OpenPeriod: optInt(poll.GetClosePeriod), + CloseDate: optInt(poll.GetCloseDate), + } + + if e := results.SolutionEntities; len(e) > 0 { + explanationEntities, err := b.convertToBotAPIEntities(ctx, e) + if err != nil { + return errors.Wrap(err, "get entities") + } + resultPoll.ExplanationEntities = explanationEntities + } + + // SAFETY: length equality checked above. + for i, result := range results.Results { + if result.Correct { + resultPoll.CorrectOptionID.SetTo(i) + } + resultPoll.Options = append(resultPoll.Options, oas.PollOption{ + Text: poll.Answers[i].Text, + VoterCount: result.Voters, + }) + } + + r.Poll.SetTo(resultPoll) + case *tg.MessageMediaDice: + r.Dice.SetTo(oas.Dice{ + Emoji: media.Emoticon, + Value: media.Value, + }) + } + + return nil +} + +func (b *BotAPI) convertPlainMessage(ctx context.Context, m *tg.Message) (r oas.Message, _ error) { + getFrom := func(fromID tg.PeerClass, user *oas.OptUser, chat *oas.OptChat) error { + switch fromID := fromID.(type) { + case *tg.PeerUser: + u, err := b.resolveUserID(ctx, fromID.UserID) + if err != nil { + return errors.Errorf("get user", err) + } + r.From.SetTo(convertToUser(u)) + case *tg.PeerChat, *tg.PeerChannel: + ch, err := b.getChatByPeer(ctx, fromID) + if err != nil { + return errors.Errorf("get chat", err) + } + r.SenderChat.SetTo(ch) + } + return nil + } + + ch, err := b.getChatByPeer(ctx, m.PeerID) + if err != nil { + return oas.Message{}, errors.Wrap(err, "get chat") + } + + r = oas.Message{ + MessageID: m.ID, + Date: m.Date, + Chat: ch, + EditDate: optInt(m.GetEditDate), + // TODO(tdakkota): bump gotd version and implement + HasProtectedContent: oas.OptBool{}, + // TODO(tdakkota): generate media album ids + MediaGroupID: oas.OptString{}, + AuthorSignature: optString(m.GetPostAuthor), + } + if m.Out { + r.From.SetTo(convertToUser(b.self)) + } else if fromID, ok := m.GetFromID(); ok { + // FIXME(tdakkota): set service IDs. + // + // See https://github.com/tdlib/telegram-bot-api/blob/90f52477814a2d8a08c9ffb1d780fd179815d715/telegram-bot-api/Client.cpp#L9602 + if err := getFrom(fromID, &r.From, &r.SenderChat); err != nil { + return oas.Message{}, errors.Wrap(err, "get from") + } + } + + // See https://github.com/tdlib/telegram-bot-api/blob/90f52477814a2d8a08c9ffb1d780fd179815d715/telegram-bot-api/Client.cpp#L9585-L9587 + if h, ok := m.GetFwdFrom(); ok { + var isAutomaticForward bool + if fromID, ok := m.GetFromID(); ok { + if err := getFrom(fromID, &r.ForwardFrom, &r.ForwardFromChat); err != nil { + return oas.Message{}, errors.Wrap(err, "get forward_from") + } + + if from, to := r.ForwardFromChat.Value, r.Chat; r.ForwardFromChat.Set { + _, isChannelPost := h.GetChannelPost() + isAutomaticForward = isChannelPost && + from.ID != to.ID && + to.Type == oas.ChatTypeSupergroup && + from.Type == oas.ChatTypeChannel + if isAutomaticForward { + r.IsAutomaticForward.SetTo(true) + } + } + } + r.ForwardFromMessageID = optInt(h.GetChannelPost) + r.ForwardSignature = optString(h.GetPostAuthor) + r.ForwardSenderName = optString(h.GetFromName) + r.ForwardDate = oas.NewOptInt(h.Date) + } + + if reply, ok := m.GetReplyTo(); ok { + // TODO(tdakkota): implement reply to resolve. + r.ReplyToMessage = &oas.Message{ + MessageID: reply.ReplyToMsgID, + } + } + + if botID, ok := m.GetViaBotID(); ok { + u, err := b.resolveUserID(ctx, botID) + if err != nil { + return oas.Message{}, errors.Wrap(err, "get via_bot") + } + r.ViaBot.SetTo(convertToUser(u)) + } + + if text := m.Message; text != "" { + r.Text.SetTo(text) + } + if len(m.Entities) > 0 { + entities, err := b.convertToBotAPIEntities(ctx, m.Entities) + if err != nil { + return oas.Message{}, errors.Wrap(err, "get entities") + } + r.Entities = entities + } + + if err := b.convertMessageMedia(ctx, m.Media, &r); err != nil { + return oas.Message{}, errors.Wrap(err, "get media") + } + + if mkp, ok := m.ReplyMarkup.(*tg.ReplyInlineMarkup); ok { + r.ReplyMarkup.SetTo(convertToBotAPIInlineReplyMarkup(mkp)) + } + + return r, nil +} diff --git a/internal/botapi/errors.go b/internal/botapi/errors.go index d242082..3980a5d 100644 --- a/internal/botapi/errors.go +++ b/internal/botapi/errors.go @@ -12,11 +12,16 @@ import ( ) // NotImplementedError is stub error for not implemented methods. -type NotImplementedError struct{} +type NotImplementedError struct { + Message string +} // Error implements error. func (n *NotImplementedError) Error() string { - return "method not implemented yet" + if n.Message == "" { + return "method not implemented yet" + } + return n.Message } // PeerNotFoundError reports that BotAPI cannot find this peer. @@ -32,6 +37,16 @@ func (p *PeerNotFoundError) Error() string { return fmt.Sprintf("peer %d not found", p.ID.Int64) } +// BadRequestError reports bad request. +type BadRequestError struct { + Message string +} + +// Error implements error. +func (p *BadRequestError) Error() string { + return p.Message +} + func errorOf(code int) oas.ErrorStatusCode { return oas.ErrorStatusCode{ StatusCode: code, @@ -43,16 +58,19 @@ func errorOf(code int) oas.ErrorStatusCode { } // NewError maps error to status code. -func (b BotAPI) NewError(ctx context.Context, err error) oas.ErrorStatusCode { +func (b *BotAPI) NewError(ctx context.Context, err error) oas.ErrorStatusCode { var ( notImplemented *NotImplementedError peerNotFound *PeerNotFoundError + badRequest *BadRequestError ) switch { case errors.As(err, ¬Implemented): return errorOf(http.StatusNotImplemented) case errors.As(err, &peerNotFound): return errorOf(http.StatusNotFound) + case errors.As(err, &badRequest): + return errorOf(http.StatusBadRequest) } resp := errorOf(http.StatusInternalServerError) diff --git a/internal/botapi/file_id.go b/internal/botapi/file_id.go new file mode 100644 index 0000000..0129dcb --- /dev/null +++ b/internal/botapi/file_id.go @@ -0,0 +1,10 @@ +package botapi + +import "github.com/gotd/td/fileid" + +func (b *BotAPI) encodeFileID(f fileid.FileID) (fileID, fileUniqueID string) { + fileID, _ = fileid.EncodeFileID(f) + // TODO(tdakkota): generate unique id + fileUniqueID = "todo" + return fileID, fileUniqueID +} diff --git a/internal/botapi/markup.go b/internal/botapi/markup.go new file mode 100644 index 0000000..89cbab8 --- /dev/null +++ b/internal/botapi/markup.go @@ -0,0 +1,180 @@ +package botapi + +import ( + "context" + "net/url" + + "github.com/go-faster/errors" + "github.com/gotd/td/telegram/message/markup" + "github.com/gotd/td/telegram/message/peer" + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/oas" +) + +func (b *BotAPI) convertToTelegramInlineButton( + ctx context.Context, + button oas.InlineKeyboardButton, +) (tg.KeyboardButtonClass, error) { + switch { + case button.URL.Set: + return markup.URL(button.Text, button.URL.Value.String()), nil + case button.CallbackData.Set: + return markup.Callback(button.Text, []byte(button.CallbackData.Value)), nil + case button.CallbackGame != nil: + return markup.Game(button.Text), nil + case button.Pay.Value: + return markup.Buy(button.Text), nil + case button.SwitchInlineQuery.Set: + return markup.SwitchInline(button.Text, button.SwitchInlineQuery.Value, false), nil + case button.SwitchInlineQueryCurrentChat.Set: + return markup.SwitchInline(button.Text, button.SwitchInlineQuery.Value, true), nil + case button.LoginURL.Set: + loginURL := button.LoginURL.Value + + var user tg.InputUserClass = &tg.InputUserSelf{} + if v, ok := loginURL.BotUsername.Get(); ok && v != "" { + p, err := b.resolver.ResolveDomain(ctx, loginURL.BotUsername.Value) + if err != nil { + return nil, errors.Wrap(err, "resolve bot") + } + + u, ok := peer.ToInputUser(p) + if !ok { + return nil, &BadRequestError{Message: "given username is not user"} + } + user = u + } + + return &tg.InputKeyboardButtonURLAuth{ + RequestWriteAccess: loginURL.RequestWriteAccess.Value, + Text: button.Text, + FwdText: loginURL.ForwardText.Value, + URL: loginURL.URL.String(), + Bot: user, + }, nil + default: + return nil, &BadRequestError{Message: "text buttons are unallowed in the inline keyboard"} + } +} + +func (b *BotAPI) convertToTelegramButton(kb oas.KeyboardButton) tg.KeyboardButtonClass { + if text, ok := kb.GetString(); ok { + return markup.Button(text) + } + + button := kb.KeyboardButtonObject + if button.RequestLocation.Value || button.RequestContact.Value { + return markup.RequestPhone(button.Text) + } + + if poll, ok := button.RequestPoll.Get(); ok { + return markup.RequestPoll(button.Text, poll.Type.Value == "quiz") + } + + return markup.Button(button.Text) +} + +func (b *BotAPI) convertToTelegramReplyMarkup( + ctx context.Context, + m *oas.SendMessageReplyMarkup, +) (tg.ReplyMarkupClass, error) { + switch m.Type { + case oas.InlineKeyboardMarkupSendMessageReplyMarkup: + rows := m.InlineKeyboardMarkup.InlineKeyboard + result := &tg.ReplyInlineMarkup{Rows: make([]tg.KeyboardButtonRow, 0, len(rows))} + for _, row := range rows { + resultRow := make([]tg.KeyboardButtonClass, len(row)) + for i, button := range row { + resultButton, err := b.convertToTelegramInlineButton(ctx, button) + if err != nil { + return nil, errors.Wrapf(err, "convert button %d", i) + } + resultRow[i] = resultButton + } + result.Rows = append(result.Rows, tg.KeyboardButtonRow{Buttons: resultRow}) + } + return result, nil + case oas.ReplyKeyboardMarkupSendMessageReplyMarkup: + mark := m.ReplyKeyboardMarkup + rows := mark.Keyboard + + result := &tg.ReplyKeyboardMarkup{ + Resize: mark.ResizeKeyboard.Value, + SingleUse: mark.OneTimeKeyboard.Value, + Selective: mark.Selective.Value, + Rows: make([]tg.KeyboardButtonRow, 0, len(rows)), + } + if v, ok := mark.InputFieldPlaceholder.Get(); ok { + result.SetPlaceholder(v) + } + for _, row := range rows { + resultRow := make([]tg.KeyboardButtonClass, len(row)) + for _, button := range row { + resultRow = append(resultRow, b.convertToTelegramButton(button)) + } + result.Rows = append(result.Rows, tg.KeyboardButtonRow{Buttons: resultRow}) + } + return result, nil + case oas.ReplyKeyboardRemoveSendMessageReplyMarkup: + if v, ok := m.ReplyKeyboardRemove.Selective.Get(); ok && v { + return markup.SelectiveHide(), nil + } + return markup.Hide(), nil + case oas.ForceReplySendMessageReplyMarkup: + mark := m.ForceReply + result := &tg.ReplyKeyboardForceReply{ + Selective: mark.Selective.Value, + Placeholder: mark.InputFieldPlaceholder.Value, + } + return result, nil + default: + return nil, errors.Errorf("unknown type %q", m.Type) + } +} + +func convertToBotAPIInlineReplyMarkup(mkp *tg.ReplyInlineMarkup) oas.InlineKeyboardMarkup { + resultRows := make([][]oas.InlineKeyboardButton, len(mkp.Rows)) + for i, row := range mkp.Rows { + resultRow := make([]oas.InlineKeyboardButton, len(row.Buttons)) + for i, b := range row.Buttons { + button := oas.InlineKeyboardButton{Text: b.GetText()} + switch b := b.(type) { + case *tg.KeyboardButtonURL: + u, _ := url.Parse(b.URL) + if u == nil { + u = new(url.URL) + } + button.URL.SetTo(*u) + case *tg.KeyboardButtonCallback: + button.CallbackData.SetTo(string(b.Data)) + case *tg.KeyboardButtonSwitchInline: + if b.SamePeer { + button.SwitchInlineQueryCurrentChat.SetTo(b.Query) + } else { + button.SwitchInlineQuery.SetTo(b.Query) + } + case *tg.KeyboardButtonGame: + button.CallbackGame = new(oas.CallbackGame) + case *tg.KeyboardButtonBuy: + button.Pay.SetTo(true) + case *tg.KeyboardButtonURLAuth: + // Quote: login_url buttons are represented as ordinary url buttons. + // + // See Message definition + // See https://github.com/tdlib/telegram-bot-api/blob/90f52477814a2d8a08c9ffb1d780fd179815d715/telegram-bot-api/Client.cpp#L1526 + u, _ := url.Parse(b.URL) + if u == nil { + u = new(url.URL) + } + button.URL.SetTo(*u) + } + resultRow[i] = button + } + resultRows[i] = resultRow + } + + return oas.InlineKeyboardMarkup{ + InlineKeyboard: resultRows, + } +} diff --git a/internal/botapi/me.go b/internal/botapi/me.go index 7539d98..cbfe9dc 100644 --- a/internal/botapi/me.go +++ b/internal/botapi/me.go @@ -16,33 +16,35 @@ func convertToUser(user *tg.User) oas.User { LastName: optString(user.GetLastName), Username: optString(user.GetUsername), LanguageCode: optString(user.GetLangCode), - CanJoinGroups: optBool(user.BotNochats), - CanReadAllGroupMessages: optBool(user.BotChatHistory), - SupportsInlineQueries: optBool(user.BotInlinePlaceholder == ""), + CanJoinGroups: oas.NewOptBool(user.BotNochats), + CanReadAllGroupMessages: oas.NewOptBool(user.BotChatHistory), + SupportsInlineQueries: oas.NewOptBool(user.BotInlinePlaceholder == ""), } } // GetMe implements oas.Handler. func (b *BotAPI) GetMe(ctx context.Context) (oas.ResultUser, error) { - self, err := b.client.Self(ctx) + me, err := b.client.Self(ctx) if err != nil { return oas.ResultUser{}, err } + b.updateSelf(me) return oas.ResultUser{ - Result: oas.NewOptUser(convertToUser(self)), + Result: oas.NewOptUser(convertToUser(me)), Ok: true, }, nil } // Close implements oas.Handler. func (b *BotAPI) Close(ctx context.Context) (oas.Result, error) { + // FIXME(tdakkota): kill BotAPI. return resultOK(true), nil } // LogOut implements oas.Handler. func (b *BotAPI) LogOut(ctx context.Context) (oas.Result, error) { - r, err := b.client.API().AuthLogOut(ctx) + r, err := b.raw.AuthLogOut(ctx) if err != nil { return oas.Result{}, err } diff --git a/internal/botapi/optional.go b/internal/botapi/optional.go index 3429be8..b1dbc07 100644 --- a/internal/botapi/optional.go +++ b/internal/botapi/optional.go @@ -10,7 +10,23 @@ func optString(getter func() (string, bool)) oas.OptString { return oas.NewOptString(v) } -func optBool(v bool) oas.OptBool { +func optInt(getter func() (int, bool)) oas.OptInt { + v, ok := getter() + if !ok { + return oas.OptInt{} + } + return oas.NewOptInt(v) +} + +func optFloat64(getter func() (float64, bool)) oas.OptFloat64 { + v, ok := getter() + if !ok { + return oas.OptFloat64{} + } + return oas.NewOptFloat64(v) +} + +func trueType(v bool) oas.OptBool { return oas.OptBool{ Value: v, Set: v, diff --git a/internal/botapi/options.go b/internal/botapi/options.go new file mode 100644 index 0000000..2f28cc7 --- /dev/null +++ b/internal/botapi/options.go @@ -0,0 +1,15 @@ +package botapi + +import "go.uber.org/zap" + +// Options is options of BotAPI. +type Options struct { + Debug bool + Logger *zap.Logger +} + +func (o *Options) setDefaults() { + if o.Logger == nil { + o.Logger = zap.NewNop() + } +} diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go index 67c608c..1e68343 100644 --- a/internal/botapi/peers.go +++ b/internal/botapi/peers.go @@ -21,6 +21,20 @@ const ( MaxTDLibUserID = (1 << 40) - 1 ) +func fullChatInputPeer(full tg.FullChat) tg.InputPeerClass { + switch full := full.(type) { + case *tg.Chat: + return &tg.InputPeerChat{ChatID: full.ID} + case *tg.Channel: + return &tg.InputPeerChannel{ + ChannelID: full.ID, + AccessHash: full.AccessHash, + } + default: + return &tg.InputPeerEmpty{} + } +} + func toTDLibID(p tg.InputPeerClass) int64 { switch p := p.(type) { case *tg.InputPeerUser: @@ -28,7 +42,7 @@ func toTDLibID(p tg.InputPeerClass) int64 { case *tg.InputPeerChat: return -p.GetChatID() case *tg.InputPeerChannel: - return ZeroTDLibChannelID - p.GetChannelID() + return ZeroTDLibChannelID - (p.GetChannelID() * -1) default: return 0 } @@ -40,7 +54,8 @@ func fromTDLibID(id int64) int64 { case IsChatTDLibID(id): id = -id case IsChannelTDLibID(id): - id += ZeroTDLibChannelID + id -= ZeroTDLibChannelID + id = -id } return id } @@ -64,6 +79,61 @@ func IsChannelTDLibID(id int64) bool { } +func (b *BotAPI) getChatByPeer(ctx context.Context, p tg.PeerClass) (oas.Chat, error) { + var chatID int64 + switch p := p.(type) { + case *tg.PeerUser: + user, ok, err := b.peers.FindUser(ctx, p.UserID) + switch { + case err != nil: + return oas.Chat{}, errors.Wrapf(err, "find user: %d", p.UserID) + case !ok: + return oas.Chat{}, errors.Errorf("can't find user %d", p.UserID) + } + return oas.Chat{ + ID: toTDLibID(user.AsInputPeer()), + Type: oas.ChatTypePrivate, + Username: optString(user.GetUsername), + FirstName: optString(user.GetFirstName), + LastName: optString(user.GetLastName), + }, nil + case *tg.PeerChat: + chatID = p.ChatID + case *tg.PeerChannel: + chatID = p.ChannelID + default: + return oas.Chat{}, errors.Errorf("unexpected type %T", p) + } + + chat, ok, err := b.peers.FindChat(ctx, chatID) + switch { + case err != nil: + return oas.Chat{}, errors.Wrapf(err, "find chat: %d", chatID) + case !ok: + return oas.Chat{}, errors.Errorf("can't find chat %d", chatID) + } + + r := oas.Chat{ + ID: toTDLibID(fullChatInputPeer(chat)), + Type: oas.ChatTypeGroup, + Title: oas.NewOptString(chat.GetTitle()), + // TODO(tdakkota): set more fields, when gotd schema will be updated + HasProtectedContent: oas.OptBool{}, + } + switch ch := chat.(type) { + case *tg.Chat: + case *tg.Channel: + if ch.Broadcast { + r.Type = oas.ChatTypeChannel + } else { + r.Type = oas.ChatTypeSupergroup + } + r.Username = optString(ch.GetUsername) + } + + return r, nil +} + func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (tg.InputPeerClass, error) { if id.IsInt64() { return b.resolveIntID(ctx, id) @@ -78,7 +148,7 @@ func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (tg.InputPeerClass, e p, err := b.resolver.ResolveDomain(ctx, username) if err != nil { - return nil, errors.Errorf("resolve: %w", err) + return nil, errors.Wrapf(err, "resolve %q: %w", username) } switch p.(type) { case *tg.InputPeerChat, *tg.InputPeerChannel: @@ -88,15 +158,15 @@ func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (tg.InputPeerClass, e } } -func (b *BotAPI) resolveUserID(ctx context.Context, id int64) (*tg.InputUser, error) { +func (b *BotAPI) resolveUserID(ctx context.Context, id int64) (*tg.User, error) { user, ok, err := b.peers.FindUser(ctx, id) switch { case err != nil: - return nil, errors.Errorf("find user: %d", id) + return nil, errors.Wrapf(err, "find user: %d", id) case !ok: return nil, &PeerNotFoundError{ID: oas.NewInt64ID(id)} } - return user.AsInput(), nil + return user, nil } func (b *BotAPI) resolveIntID(ctx context.Context, chatID oas.ID) (tg.InputPeerClass, error) { @@ -104,12 +174,9 @@ func (b *BotAPI) resolveIntID(ctx context.Context, chatID oas.ID) (tg.InputPeerC cleanID := fromTDLibID(id) if IsUserTDLibID(id) { - user, ok, err := b.peers.FindUser(ctx, cleanID) - switch { - case err != nil: - return nil, errors.Errorf("find user: %d", id) - case !ok: - return nil, &PeerNotFoundError{ID: chatID} + user, err := b.resolveUserID(ctx, cleanID) + if err != nil { + return nil, err } return user.AsInputPeer(), nil } @@ -117,7 +184,7 @@ func (b *BotAPI) resolveIntID(ctx context.Context, chatID oas.ID) (tg.InputPeerC chat, ok, err := b.peers.FindChat(ctx, cleanID) switch { case err != nil: - return nil, errors.Errorf("find chat: %d", id) + return nil, errors.Wrapf(err, "find chat: %d", id) case !ok: return nil, &PeerNotFoundError{ID: chatID} } diff --git a/internal/botapi/result.go b/internal/botapi/result.go index a43ddfb..8603ba9 100644 --- a/internal/botapi/result.go +++ b/internal/botapi/result.go @@ -4,7 +4,10 @@ import "github.com/gotd/botapi/internal/oas" func resultOK(v bool) oas.Result { return oas.Result{ - Result: optBool(v), - Ok: true, + Result: oas.OptBool{ + Value: v, + Set: v, + }, + Ok: true, } } diff --git a/internal/botapi/send.go b/internal/botapi/send.go index 01b63b0..b5a7f6b 100644 --- a/internal/botapi/send.go +++ b/internal/botapi/send.go @@ -3,6 +3,11 @@ package botapi import ( "context" + "github.com/go-faster/errors" + "github.com/gotd/td/telegram/message/html" + "github.com/gotd/td/telegram/message/unpack" + "github.com/gotd/td/tg" + "github.com/gotd/botapi/internal/oas" ) @@ -58,7 +63,64 @@ func (b *BotAPI) SendMediaGroup(ctx context.Context, req oas.SendMediaGroup) (oa // SendMessage implements oas.Handler. func (b *BotAPI) SendMessage(ctx context.Context, req oas.SendMessage) (oas.ResultMessage, error) { - return oas.ResultMessage{}, &NotImplementedError{} + parseMode, isParseModeSet := req.ParseMode.Get() + if isParseModeSet && parseMode != "HTML" { + return oas.ResultMessage{}, &NotImplementedError{Message: "only HTML formatting is supported"} + } + + p, err := b.resolveID(ctx, req.ChatID) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "resolve chatID") + } + s := &b.sender.To(p).Builder + + if v, ok := req.DisableWebPagePreview.Get(); ok && v { + s = s.NoWebpage() + } + if v, ok := req.DisableNotification.Get(); ok && v { + s = s.Silent() + } + if v, ok := req.ReplyToMessageID.Get(); ok { + s = s.Reply(v) + } + if m := req.ReplyMarkup; m != nil { + mkp, err := b.convertToTelegramReplyMarkup(ctx, m) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "convert markup") + } + s = s.Markup(mkp) + } + + var resp tg.UpdatesClass + if isParseModeSet { + // FIXME(tdakkota): set HTML user resolver. + // FIXME(tdakkota): random_id unpacking. + resp, err = s.StyledText(ctx, html.String(nil, req.Text)) + } else { + // FIXME(tdakkota): get entities from request. + resp, err = s.Text(ctx, req.Text) + } + + m, err := unpack.MessageClass(resp, err) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "send") + } + + msg, ok := m.(*tg.Message) + if !ok { + return oas.ResultMessage{ + Ok: true, + }, nil + } + + resultMsg, err := b.convertPlainMessage(ctx, msg) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "get message") + } + return oas.ResultMessage{ + Result: oas.NewOptMessage(resultMsg), + Ok: true, + }, nil } // SendPhoto implements oas.Handler. diff --git a/internal/botapi/webhook.go b/internal/botapi/webhook.go index 8e2802d..621576d 100644 --- a/internal/botapi/webhook.go +++ b/internal/botapi/webhook.go @@ -7,7 +7,7 @@ import ( ) // DeleteWebhook implements oas.Handler. -func (b *BotAPI) DeleteWebhook(ctx context.Context, req oas.DeleteWebhook) (oas.Result, error) { +func (b *BotAPI) DeleteWebhook(ctx context.Context, req oas.OptDeleteWebhook) (oas.Result, error) { return oas.Result{}, &NotImplementedError{} } diff --git a/internal/pool/pool.go b/internal/pool/pool.go index ff67b57..b387a00 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/go-faster/errors" "go.uber.org/zap" "github.com/gotd/td/telegram" @@ -78,18 +79,19 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAP } log := p.log.Named("client").With(zap.Int("id", token.ID)) + var handler telegram.UpdateHandlerFunc = func(ctx context.Context, u tg.UpdatesClass) error { + return nil + } peerStorage := peers.NewInmemoryStorage() gaps := updates.New(updates.Config{ - Handler: telegram.UpdateHandlerFunc(func(ctx context.Context, u tg.UpdatesClass) error { - return nil - }), + Handler: handler, AccessHasher: peers.AccessHasher{ Storage: peerStorage, }, Logger: log.Named("gaps"), }) options := telegram.Options{ - Logger: log, + Logger: log.Named("client"), UpdateHandler: peers.UpdateHook(peerStorage, gaps), } if p.storage != nil { @@ -102,12 +104,16 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAP tgContext, tgCancel := context.WithCancel(context.Background()) c = &client{ - ctx: tgContext, - cancel: tgCancel, - api: botapi.NewBotAPI(tgClient, peerStorage, p.debug), + ctx: tgContext, + cancel: tgCancel, + api: botapi.NewBotAPI(tgClient, gaps, peerStorage, botapi.Options{ + Debug: p.debug, + Logger: log.Named("botapi"), + }), token: token, lastUsed: p.now(), } + handler = c.api.UpdateHook // Wait for initialization. initializationResult := make(chan error, 1) @@ -138,6 +144,10 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAP } } + if err := c.api.Init(ctx); err != nil { + return errors.Wrap(err, "init BotAPI") + } + // Done. select { case initializationResult <- nil: @@ -159,6 +169,7 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAP select { case err := <-initializationResult: if err != nil { + log.Warn("Initialize", zap.Error(err)) return err } From 3f59371e36688f4252d339c46ba007a9bc22031a Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 12 Dec 2021 14:22:00 +0300 Subject: [PATCH 08/29] ci: disable dupl for a while --- .golangci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.golangci.yml b/.golangci.yml index c0f8cef..0860937 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -37,7 +37,8 @@ linters: - deadcode - depguard - dogsled - - dupl + # False positive: botapi has a lot of similar unimplemented methods + # - dupl - errcheck - gochecknoinits - goconst From 7f740cfc898cf39fa2ee12f0b83df32c11ade4bd Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 12 Dec 2021 14:22:37 +0300 Subject: [PATCH 09/29] chore: fix error wrapping --- internal/botapi/botapi.go | 9 +++++---- internal/botapi/convert_message.go | 11 ++++++----- internal/botapi/markup.go | 1 + internal/botapi/optional.go | 15 --------------- internal/botapi/peers.go | 3 +-- internal/botapi/send.go | 1 + internal/pool/pool.go | 1 - internal/pool/storage.go | 2 +- internal/pool/token.go | 1 + 9 files changed, 16 insertions(+), 28 deletions(-) diff --git a/internal/botapi/botapi.go b/internal/botapi/botapi.go index 71595d0..99133dd 100644 --- a/internal/botapi/botapi.go +++ b/internal/botapi/botapi.go @@ -6,13 +6,14 @@ import ( "sync" "github.com/go-faster/errors" + "go.uber.org/atomic" + "go.uber.org/zap" + "github.com/gotd/td/telegram" "github.com/gotd/td/telegram/message" "github.com/gotd/td/telegram/message/peer" "github.com/gotd/td/telegram/updates" "github.com/gotd/td/tg" - "go.uber.org/atomic" - "go.uber.org/zap" "github.com/gotd/botapi/internal/oas" "github.com/gotd/botapi/internal/peers" @@ -40,7 +41,7 @@ type BotAPI struct { func NewBotAPI( client *telegram.Client, gaps *updates.Manager, - peers peers.Storage, + store peers.Storage, opts Options, ) *BotAPI { opts.setDefaults() @@ -53,7 +54,7 @@ func NewBotAPI( gaps: gaps, sender: message.NewSender(raw).WithResolver(resolver), resolver: resolver, - peers: peers, + peers: store, debug: opts.Debug, logger: opts.Logger, } diff --git a/internal/botapi/convert_message.go b/internal/botapi/convert_message.go index 2d22ddc..4e5cef7 100644 --- a/internal/botapi/convert_message.go +++ b/internal/botapi/convert_message.go @@ -6,9 +6,10 @@ import ( "strconv" "github.com/go-faster/errors" + "go.uber.org/zap" + "github.com/gotd/td/fileid" "github.com/gotd/td/tg" - "go.uber.org/zap" "github.com/gotd/botapi/internal/oas" ) @@ -426,15 +427,15 @@ func (b *BotAPI) convertPlainMessage(ctx context.Context, m *tg.Message) (r oas. case *tg.PeerUser: u, err := b.resolveUserID(ctx, fromID.UserID) if err != nil { - return errors.Errorf("get user", err) + return errors.Wrap(err, "get user") } - r.From.SetTo(convertToUser(u)) + user.SetTo(convertToUser(u)) case *tg.PeerChat, *tg.PeerChannel: ch, err := b.getChatByPeer(ctx, fromID) if err != nil { - return errors.Errorf("get chat", err) + return errors.Wrap(err, "get chat") } - r.SenderChat.SetTo(ch) + chat.SetTo(ch) } return nil } diff --git a/internal/botapi/markup.go b/internal/botapi/markup.go index 89cbab8..b235190 100644 --- a/internal/botapi/markup.go +++ b/internal/botapi/markup.go @@ -5,6 +5,7 @@ import ( "net/url" "github.com/go-faster/errors" + "github.com/gotd/td/telegram/message/markup" "github.com/gotd/td/telegram/message/peer" "github.com/gotd/td/tg" diff --git a/internal/botapi/optional.go b/internal/botapi/optional.go index b1dbc07..78a7aac 100644 --- a/internal/botapi/optional.go +++ b/internal/botapi/optional.go @@ -17,18 +17,3 @@ func optInt(getter func() (int, bool)) oas.OptInt { } return oas.NewOptInt(v) } - -func optFloat64(getter func() (float64, bool)) oas.OptFloat64 { - v, ok := getter() - if !ok { - return oas.OptFloat64{} - } - return oas.NewOptFloat64(v) -} - -func trueType(v bool) oas.OptBool { - return oas.OptBool{ - Value: v, - Set: v, - } -} diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go index 1e68343..8ee5747 100644 --- a/internal/botapi/peers.go +++ b/internal/botapi/peers.go @@ -76,7 +76,6 @@ func IsChannelTDLibID(id int64) bool { id != ZeroTDLibChannelID && !IsChatTDLibID(id) && ZeroTDLibChannelID-MaxTDLibChannelID <= id - } func (b *BotAPI) getChatByPeer(ctx context.Context, p tg.PeerClass) (oas.Chat, error) { @@ -148,7 +147,7 @@ func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (tg.InputPeerClass, e p, err := b.resolver.ResolveDomain(ctx, username) if err != nil { - return nil, errors.Wrapf(err, "resolve %q: %w", username) + return nil, errors.Wrapf(err, "resolve %q", username) } switch p.(type) { case *tg.InputPeerChat, *tg.InputPeerChannel: diff --git a/internal/botapi/send.go b/internal/botapi/send.go index b5a7f6b..e38b594 100644 --- a/internal/botapi/send.go +++ b/internal/botapi/send.go @@ -4,6 +4,7 @@ import ( "context" "github.com/go-faster/errors" + "github.com/gotd/td/telegram/message/html" "github.com/gotd/td/telegram/message/unpack" "github.com/gotd/td/tg" diff --git a/internal/pool/pool.go b/internal/pool/pool.go index b387a00..569e729 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -113,7 +113,6 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAP token: token, lastUsed: p.now(), } - handler = c.api.UpdateHook // Wait for initialization. initializationResult := make(chan error, 1) diff --git a/internal/pool/storage.go b/internal/pool/storage.go index 99d9816..848f498 100644 --- a/internal/pool/storage.go +++ b/internal/pool/storage.go @@ -44,7 +44,7 @@ func (s *fileStorage) Store(ctx context.Context, id string, data []byte) error { return err } - return os.WriteFile(s.path, b, 0600) + return os.WriteFile(s.path, b, 0o600) } func (s *fileStorage) Load(ctx context.Context, id string) ([]byte, error) { diff --git a/internal/pool/token.go b/internal/pool/token.go index 7108a6f..87418bc 100644 --- a/internal/pool/token.go +++ b/internal/pool/token.go @@ -14,6 +14,7 @@ type Token struct { Secret string // ABC-DEF1234ghIkl-zyx57W2v1u123ew11 } +// ParseToken parses bot token from given string. func ParseToken(s string) (Token, error) { if s == "" { return Token{}, errors.New("blank") From 68db78681f5c469b5ce755fc3b0e0d58253bffc0 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 12 Dec 2021 14:29:26 +0300 Subject: [PATCH 10/29] chore: go mod tidy --- go.mod | 2 +- go.sum | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index babe57c..6badcab 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( require ( github.com/go-faster/errors v0.5.0 github.com/go-faster/jx v0.25.0 + go.uber.org/atomic v1.9.0 go.uber.org/multierr v1.7.0 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c ) @@ -42,7 +43,6 @@ require ( github.com/mattn/go-isatty v0.0.14 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - go.uber.org/atomic v1.9.0 // indirect golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 // indirect golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect diff --git a/go.sum b/go.sum index 6e3cf5b..7a545cf 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,7 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/k0kubun/pp/v3 v3.0.7 h1:Qj4zVxA0ceXq0mfNbHwFPye58UyabBWi3emM2SwBT5Y= github.com/k0kubun/pp/v3 v3.0.7/go.mod h1:2ol0zQBSPTermAo8igHVJ4d5vTiNmBkCrUdu7wZp4aI= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= From 5ae0d10512a6b3e3bc2fa4a7945ed1b0b102cdb9 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Mon, 13 Dec 2021 11:39:39 +0300 Subject: [PATCH 11/29] fix(botapi): peer ID generation --- internal/botapi/peers.go | 2 +- internal/botapi/peers_test.go | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go index 8ee5747..cbb3167 100644 --- a/internal/botapi/peers.go +++ b/internal/botapi/peers.go @@ -42,7 +42,7 @@ func toTDLibID(p tg.InputPeerClass) int64 { case *tg.InputPeerChat: return -p.GetChatID() case *tg.InputPeerChannel: - return ZeroTDLibChannelID - (p.GetChannelID() * -1) + return ZeroTDLibChannelID + (p.GetChannelID() * -1) default: return 0 } diff --git a/internal/botapi/peers_test.go b/internal/botapi/peers_test.go index 9506d89..5a58d74 100644 --- a/internal/botapi/peers_test.go +++ b/internal/botapi/peers_test.go @@ -10,19 +10,21 @@ import ( func Test_toTDLibID(t *testing.T) { tests := []struct { - name string - p tg.InputPeerClass + name string + tdlibID int64 + p tg.InputPeerClass }{ - {"User", &tg.InputPeerUser{UserID: 309570373}}, - {"Bot", &tg.InputPeerUser{UserID: 140267078}}, - {"Chat", &tg.InputPeerChat{ChatID: 365219918}}, - {"Channel", &tg.InputPeerChat{ChatID: 1228418968}}, + {"User", 309570373, &tg.InputPeerUser{UserID: 309570373}}, + {"Bot", 140267078, &tg.InputPeerUser{UserID: 140267078}}, + {"Chat", -365219918, &tg.InputPeerChat{ChatID: 365219918}}, + {"Channel", -1001228418968, &tg.InputPeerChannel{ChannelID: 1228418968}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { a := require.New(t) tdlibID := toTDLibID(tt.p) + a.Equal(tt.tdlibID, tdlibID) var mtprotoID int64 switch t := tt.p.(type) { case *tg.InputPeerUser: From c97ea8526dacbe58e725053c2b12eee216a22818 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 12 Dec 2021 17:27:19 +0300 Subject: [PATCH 12/29] fix(poll): call gaps Logout --- internal/pool/pool.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 569e729..0c18bb9 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -146,6 +146,9 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAP if err := c.api.Init(ctx); err != nil { return errors.Wrap(err, "init BotAPI") } + defer func() { + _ = gaps.Logout() + }() // Done. select { From e283c82f15f609174673526e511a57586164ff5c Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 12 Dec 2021 21:05:12 +0300 Subject: [PATCH 13/29] fix(botapi): do not use update hook --- internal/botapi/botapi.go | 34 +++++------------------------- internal/botapi/convert_message.go | 4 +++- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/internal/botapi/botapi.go b/internal/botapi/botapi.go index 99133dd..bc06b22 100644 --- a/internal/botapi/botapi.go +++ b/internal/botapi/botapi.go @@ -82,35 +82,11 @@ func (b *BotAPI) updateSelf(user *tg.User) { b.selfMux.Unlock() } -// UpdateHook is update hook to update some states. -func (b *BotAPI) UpdateHook(ctx context.Context, u tg.UpdatesClass) error { - type updateWithEntities interface { - tg.UpdatesClass - GetUsers() []tg.UserClass - } - - t, ok := u.(updateWithEntities) - if !ok { - return nil - } - - selfID := b.selfID.Load() - if selfID == 0 { - return nil - } - - for _, user := range t.GetUsers() { - nonEmpty, ok := user.AsNotEmpty() - if !ok { - continue - } - - if selfID == b.self.GetID() { - b.updateSelf(nonEmpty) - } - } - - return nil +func (b *BotAPI) getSelf() *tg.User { + b.selfMux.Lock() + self := b.self + b.selfMux.Unlock() + return self } // Client returns *telegram.Client used by this instance of BotAPI. diff --git a/internal/botapi/convert_message.go b/internal/botapi/convert_message.go index 4e5cef7..9a9ed2b 100644 --- a/internal/botapi/convert_message.go +++ b/internal/botapi/convert_message.go @@ -457,7 +457,9 @@ func (b *BotAPI) convertPlainMessage(ctx context.Context, m *tg.Message) (r oas. AuthorSignature: optString(m.GetPostAuthor), } if m.Out { - r.From.SetTo(convertToUser(b.self)) + if self := b.getSelf(); self != nil { + r.From.SetTo(convertToUser(self)) + } } else if fromID, ok := m.GetFromID(); ok { // FIXME(tdakkota): set service IDs. // From 860683cc9844ebca0beccd1251fc32e7c6e7acd0 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Wed, 15 Dec 2021 11:03:39 +0300 Subject: [PATCH 14/29] fix(botdoc): do not validate URLs --- botdoc/oas.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/botdoc/oas.go b/botdoc/oas.go index ed4d4dd..79a1116 100644 --- a/botdoc/oas.go +++ b/botdoc/oas.go @@ -69,10 +69,6 @@ func (a API) typeOAS(f Field) *ogen.Schema { if b.Min > 0 { p.MinLength = &b.Min } - - if strings.Contains(f.Name, "url") { - p.Format = "uri" - } case Integer: p.Type = "integer" // Telegram uses int64 (int53, really) for IDs. From 8df13e685b57855ab178a4bed0270723fd930d18 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Wed, 15 Dec 2021 11:04:14 +0300 Subject: [PATCH 15/29] chore: commit generated files --- _oas/openapi.json | 75 ++++++++-------------- internal/oas/oas_json_gen.go | 108 +++++++++++++------------------- internal/oas/oas_schemas_gen.go | 88 ++++++++------------------ 3 files changed, 92 insertions(+), 179 deletions(-) diff --git a/_oas/openapi.json b/_oas/openapi.json index d1ddbb2..1ab497b 100644 --- a/_oas/openapi.json +++ b/_oas/openapi.json @@ -3159,8 +3159,7 @@ }, "url": { "description": "HTTP or tg:// url to be opened when the button is pressed. Links tg://user?id=\u003cuser_id\u003e can be used to mention a user by their ID without using a username, if this is allowed by their privacy settings", - "type": "string", - "format": "uri" + "type": "string" }, "login_url": { "$ref": "#/components/schemas/LoginUrl" @@ -3359,8 +3358,7 @@ }, "url": { "description": "URL of the result", - "type": "string", - "format": "uri" + "type": "string" }, "hide_url": { "description": "Pass True, if you don't want the URL to be shown in the message", @@ -3372,8 +3370,7 @@ }, "thumb_url": { "description": "URL of the thumbnail for the result", - "type": "string", - "format": "uri" + "type": "string" }, "thumb_width": { "description": "Thumbnail width", @@ -3412,8 +3409,7 @@ }, "audio_url": { "description": "A valid URL for the audio file", - "type": "string", - "format": "uri" + "type": "string" }, "title": { "description": "Title", @@ -3904,8 +3900,7 @@ }, "thumb_url": { "description": "URL of the thumbnail for the result", - "type": "string", - "format": "uri" + "type": "string" }, "thumb_width": { "description": "Thumbnail width", @@ -3964,8 +3959,7 @@ }, "document_url": { "description": "A valid URL for the file", - "type": "string", - "format": "uri" + "type": "string" }, "mime_type": { "description": "Mime type of the content of the file, either “application/pdf” or “application/zip”", @@ -3983,8 +3977,7 @@ }, "thumb_url": { "description": "URL of the thumbnail (JPEG only) for the file", - "type": "string", - "format": "uri" + "type": "string" }, "thumb_width": { "description": "Thumbnail width", @@ -4053,8 +4046,7 @@ }, "gif_url": { "description": "A valid URL for the GIF file. File size must not exceed 1MB", - "type": "string", - "format": "uri" + "type": "string" }, "gif_width": { "description": "Width of the GIF", @@ -4076,8 +4068,7 @@ }, "thumb_url": { "description": "URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result", - "type": "string", - "format": "uri" + "type": "string" }, "thumb_mime_type": { "description": "MIME type of the thumbnail, must be one of “image/jpeg”, “image/gif”, or “video/mp4”. Defaults to “image/jpeg”", @@ -4172,8 +4163,7 @@ }, "thumb_url": { "description": "URL of the thumbnail for the result", - "type": "string", - "format": "uri" + "type": "string" }, "thumb_width": { "description": "Thumbnail width", @@ -4213,8 +4203,7 @@ }, "mpeg4_url": { "description": "A valid URL for the MP4 file. File size must not exceed 1MB", - "type": "string", - "format": "uri" + "type": "string" }, "mpeg4_width": { "description": "Video width", @@ -4236,8 +4225,7 @@ }, "thumb_url": { "description": "URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result", - "type": "string", - "format": "uri" + "type": "string" }, "thumb_mime_type": { "description": "MIME type of the thumbnail, must be one of “image/jpeg”, “image/gif”, or “video/mp4”. Defaults to “image/jpeg”", @@ -4294,13 +4282,11 @@ }, "photo_url": { "description": "A valid URL of the photo. Photo must be in JPEG format. Photo size must not exceed 5MB", - "type": "string", - "format": "uri" + "type": "string" }, "thumb_url": { "description": "URL of the thumbnail for the photo", - "type": "string", - "format": "uri" + "type": "string" }, "photo_width": { "description": "Width of the photo", @@ -4405,8 +4391,7 @@ }, "thumb_url": { "description": "URL of the thumbnail for the result", - "type": "string", - "format": "uri" + "type": "string" }, "thumb_width": { "description": "Thumbnail width", @@ -4447,8 +4432,7 @@ }, "video_url": { "description": "A valid URL for the embedded video player or video file", - "type": "string", - "format": "uri" + "type": "string" }, "mime_type": { "description": "Mime type of the content of video url, “text/html” or “video/mp4”", @@ -4456,8 +4440,7 @@ }, "thumb_url": { "description": "URL of the thumbnail (JPEG only) for the video", - "type": "string", - "format": "uri" + "type": "string" }, "title": { "description": "Title for the result", @@ -4534,8 +4517,7 @@ }, "voice_url": { "description": "A valid URL for the voice recording", - "type": "string", - "format": "uri" + "type": "string" }, "title": { "description": "Recording title", @@ -4658,8 +4640,7 @@ }, "photo_url": { "description": "URL of the product photo for the invoice. Can be a photo of the goods or a marketing image for a service. People like it better when they see what they are paying for", - "type": "string", - "format": "uri" + "type": "string" }, "photo_size": { "description": "Photo size", @@ -5266,8 +5247,7 @@ "properties": { "url": { "description": "An HTTP URL to be opened with user authorization data added to the query string when the button is pressed. If the user refuses to provide authorization data, the original URL without information about the user will be opened. The data added is the same as described in Receiving authorization data.NOTE: You must always check the hash of the received data to verify the authentication and the integrity of the data as described in Checking authorization", - "type": "string", - "format": "uri" + "type": "string" }, "forward_text": { "description": "New text of the button in forwarded messages", @@ -5593,8 +5573,7 @@ }, "url": { "description": "For “text_link” only, url that will be opened after user taps on the text", - "type": "string", - "format": "uri" + "type": "string" }, "user": { "$ref": "#/components/schemas/User" @@ -7270,8 +7249,7 @@ "properties": { "url": { "description": "Webhook URL, may be empty if webhook is not set up", - "type": "string", - "format": "uri" + "type": "string" }, "has_custom_certificate": { "description": "True, if a custom certificate was provided for webhook certificate checks", @@ -7365,8 +7343,7 @@ }, "url": { "description": "URL that will be opened by the user's client. If you have created a Game and accepted the conditions via @Botfather, specify the URL that opens your game — note that this will only work if the query comes from a callback_game button.Otherwise, you may use links like t.me/your_bot?start=XXXX that open your bot with a parameter", - "type": "string", - "format": "uri" + "type": "string" }, "cache_time": { "description": "The maximum amount of time in seconds that the result of the callback query may be cached client-side. Telegram apps will support caching starting in version 3.14. Defaults to 0", @@ -8782,8 +8759,7 @@ }, "photo_url": { "description": "URL of the product photo for the invoice. Can be a photo of the goods or a marketing image for a service. People like it better when they see what they are paying for", - "type": "string", - "format": "uri" + "type": "string" }, "photo_size": { "description": "Photo size", @@ -9788,8 +9764,7 @@ "properties": { "url": { "description": "HTTPS url to send updates to. Use an empty string to remove webhook integration", - "type": "string", - "format": "uri" + "type": "string" }, "certificate": { "description": "Upload your public key certificate so that the root certificate in use can be checked. See our self-signed guide for details", diff --git a/internal/oas/oas_json_gen.go b/internal/oas/oas_json_gen.go index c5ab69f..18562f1 100644 --- a/internal/oas/oas_json_gen.go +++ b/internal/oas/oas_json_gen.go @@ -5309,7 +5309,7 @@ func (s InlineQueryResultAudio) Encode(e *jx.Encoder) { e.Str(s.ID) e.FieldStart("audio_url") - json.EncodeURI(e, s.AudioURL) + e.Str(s.AudioURL) e.FieldStart("title") e.Str(s.Title) @@ -5368,8 +5368,8 @@ func (s *InlineQueryResultAudio) Decode(d *jx.Decoder) error { return err } case "audio_url": - v, err := json.DecodeURI(d) - s.AudioURL = v + v, err := d.Str() + s.AudioURL = string(v) if err != nil { return err } @@ -6464,7 +6464,7 @@ func (s InlineQueryResultDocument) Encode(e *jx.Encoder) { } e.FieldStart("document_url") - json.EncodeURI(e, s.DocumentURL) + e.Str(s.DocumentURL) e.FieldStart("mime_type") e.Str(s.MimeType) @@ -6543,8 +6543,8 @@ func (s *InlineQueryResultDocument) Decode(d *jx.Decoder) error { return err } case "document_url": - v, err := json.DecodeURI(d) - s.DocumentURL = v + v, err := d.Str() + s.DocumentURL = string(v) if err != nil { return err } @@ -6660,7 +6660,7 @@ func (s InlineQueryResultGif) Encode(e *jx.Encoder) { e.Str(s.ID) e.FieldStart("gif_url") - json.EncodeURI(e, s.GIFURL) + e.Str(s.GIFURL) if s.GIFWidth.Set { e.FieldStart("gif_width") s.GIFWidth.Encode(e) @@ -6675,7 +6675,7 @@ func (s InlineQueryResultGif) Encode(e *jx.Encoder) { } e.FieldStart("thumb_url") - json.EncodeURI(e, s.ThumbURL) + e.Str(s.ThumbURL) if s.ThumbMimeType.Set { e.FieldStart("thumb_mime_type") s.ThumbMimeType.Encode(e) @@ -6731,8 +6731,8 @@ func (s *InlineQueryResultGif) Decode(d *jx.Decoder) error { return err } case "gif_url": - v, err := json.DecodeURI(d) - s.GIFURL = v + v, err := d.Str() + s.GIFURL = string(v) if err != nil { return err } @@ -6752,8 +6752,8 @@ func (s *InlineQueryResultGif) Decode(d *jx.Decoder) error { return err } case "thumb_url": - v, err := json.DecodeURI(d) - s.ThumbURL = v + v, err := d.Str() + s.ThumbURL = string(v) if err != nil { return err } @@ -6967,7 +6967,7 @@ func (s InlineQueryResultMpeg4Gif) Encode(e *jx.Encoder) { e.Str(s.ID) e.FieldStart("mpeg4_url") - json.EncodeURI(e, s.Mpeg4URL) + e.Str(s.Mpeg4URL) if s.Mpeg4Width.Set { e.FieldStart("mpeg4_width") s.Mpeg4Width.Encode(e) @@ -6982,7 +6982,7 @@ func (s InlineQueryResultMpeg4Gif) Encode(e *jx.Encoder) { } e.FieldStart("thumb_url") - json.EncodeURI(e, s.ThumbURL) + e.Str(s.ThumbURL) if s.ThumbMimeType.Set { e.FieldStart("thumb_mime_type") s.ThumbMimeType.Encode(e) @@ -7038,8 +7038,8 @@ func (s *InlineQueryResultMpeg4Gif) Decode(d *jx.Decoder) error { return err } case "mpeg4_url": - v, err := json.DecodeURI(d) - s.Mpeg4URL = v + v, err := d.Str() + s.Mpeg4URL = string(v) if err != nil { return err } @@ -7059,8 +7059,8 @@ func (s *InlineQueryResultMpeg4Gif) Decode(d *jx.Decoder) error { return err } case "thumb_url": - v, err := json.DecodeURI(d) - s.ThumbURL = v + v, err := d.Str() + s.ThumbURL = string(v) if err != nil { return err } @@ -7126,10 +7126,10 @@ func (s InlineQueryResultPhoto) Encode(e *jx.Encoder) { e.Str(s.ID) e.FieldStart("photo_url") - json.EncodeURI(e, s.PhotoURL) + e.Str(s.PhotoURL) e.FieldStart("thumb_url") - json.EncodeURI(e, s.ThumbURL) + e.Str(s.ThumbURL) if s.PhotoWidth.Set { e.FieldStart("photo_width") s.PhotoWidth.Encode(e) @@ -7193,14 +7193,14 @@ func (s *InlineQueryResultPhoto) Decode(d *jx.Decoder) error { return err } case "photo_url": - v, err := json.DecodeURI(d) - s.PhotoURL = v + v, err := d.Str() + s.PhotoURL = string(v) if err != nil { return err } case "thumb_url": - v, err := json.DecodeURI(d) - s.ThumbURL = v + v, err := d.Str() + s.ThumbURL = string(v) if err != nil { return err } @@ -7433,13 +7433,13 @@ func (s InlineQueryResultVideo) Encode(e *jx.Encoder) { e.Str(s.ID) e.FieldStart("video_url") - json.EncodeURI(e, s.VideoURL) + e.Str(s.VideoURL) e.FieldStart("mime_type") e.Str(s.MimeType) e.FieldStart("thumb_url") - json.EncodeURI(e, s.ThumbURL) + e.Str(s.ThumbURL) e.FieldStart("title") e.Str(s.Title) @@ -7506,8 +7506,8 @@ func (s *InlineQueryResultVideo) Decode(d *jx.Decoder) error { return err } case "video_url": - v, err := json.DecodeURI(d) - s.VideoURL = v + v, err := d.Str() + s.VideoURL = string(v) if err != nil { return err } @@ -7518,8 +7518,8 @@ func (s *InlineQueryResultVideo) Decode(d *jx.Decoder) error { return err } case "thumb_url": - v, err := json.DecodeURI(d) - s.ThumbURL = v + v, err := d.Str() + s.ThumbURL = string(v) if err != nil { return err } @@ -7601,7 +7601,7 @@ func (s InlineQueryResultVoice) Encode(e *jx.Encoder) { e.Str(s.ID) e.FieldStart("voice_url") - json.EncodeURI(e, s.VoiceURL) + e.Str(s.VoiceURL) e.FieldStart("title") e.Str(s.Title) @@ -7656,8 +7656,8 @@ func (s *InlineQueryResultVoice) Decode(d *jx.Decoder) error { return err } case "voice_url": - v, err := json.DecodeURI(d) - s.VoiceURL = v + v, err := d.Str() + s.VoiceURL = string(v) if err != nil { return err } @@ -9347,7 +9347,7 @@ func (s LoginUrl) Encode(e *jx.Encoder) { e.ObjStart() e.FieldStart("url") - json.EncodeURI(e, s.URL) + e.Str(s.URL) if s.ForwardText.Set { e.FieldStart("forward_text") s.ForwardText.Encode(e) @@ -9371,8 +9371,8 @@ func (s *LoginUrl) Decode(d *jx.Decoder) error { return d.ObjBytes(func(d *jx.Decoder, k []byte) error { switch string(k) { case "url": - v, err := json.DecodeURI(d) - s.URL = v + v, err := d.Str() + s.URL = string(v) if err != nil { return err } @@ -11293,30 +11293,6 @@ func (o *OptSuccessfulPayment) Decode(d *jx.Decoder) error { } } -// Encode encodes url.URL as json. -func (o OptURL) Encode(e *jx.Encoder) { - json.EncodeURI(e, o.Value) -} - -// Decode decodes url.URL from json. -func (o *OptURL) Decode(d *jx.Decoder) error { - if o == nil { - return errors.New(`invalid: unable to decode OptURL to nil`) - } - switch d.Next() { - case jx.String: - o.Set = true - v, err := json.DecodeURI(d) - if err != nil { - return err - } - o.Value = v - return nil - default: - return errors.Errorf(`unexpected type %q while reading OptURL`, d.Next()) - } -} - // Encode encodes User as json. func (o OptUser) Encode(e *jx.Encoder) { o.Value.Encode(e) @@ -18098,7 +18074,7 @@ func (s SetWebhook) Encode(e *jx.Encoder) { e.ObjStart() e.FieldStart("url") - json.EncodeURI(e, s.URL) + e.Str(s.URL) if s.Certificate.Set { e.FieldStart("certificate") s.Certificate.Encode(e) @@ -18134,8 +18110,8 @@ func (s *SetWebhook) Decode(d *jx.Decoder) error { return d.ObjBytes(func(d *jx.Decoder, k []byte) error { switch string(k) { case "url": - v, err := json.DecodeURI(d) - s.URL = v + v, err := d.Str() + s.URL = string(v) if err != nil { return err } @@ -19601,7 +19577,7 @@ func (s WebhookInfo) Encode(e *jx.Encoder) { e.ObjStart() e.FieldStart("url") - json.EncodeURI(e, s.URL) + e.Str(s.URL) e.FieldStart("has_custom_certificate") e.Bool(s.HasCustomCertificate) @@ -19643,8 +19619,8 @@ func (s *WebhookInfo) Decode(d *jx.Decoder) error { return d.ObjBytes(func(d *jx.Decoder, k []byte) error { switch string(k) { case "url": - v, err := json.DecodeURI(d) - s.URL = v + v, err := d.Str() + s.URL = string(v) if err != nil { return err } diff --git a/internal/oas/oas_schemas_gen.go b/internal/oas/oas_schemas_gen.go index b36741f..9a80534 100644 --- a/internal/oas/oas_schemas_gen.go +++ b/internal/oas/oas_schemas_gen.go @@ -94,7 +94,7 @@ type AnswerCallbackQuery struct { CallbackQueryID string `json:"callback_query_id"` Text OptString `json:"text"` ShowAlert OptBool `json:"show_alert"` - URL OptURL `json:"url"` + URL OptString `json:"url"` CacheTime OptInt `json:"cache_time"` } @@ -1269,7 +1269,7 @@ func NewInt64ID(v int64) ID { // Ref: #/components/schemas/InlineKeyboardButton type InlineKeyboardButton struct { Text string `json:"text"` - URL OptURL `json:"url"` + URL OptString `json:"url"` LoginURL OptLoginUrl `json:"login_url"` CallbackData OptString `json:"callback_data"` SwitchInlineQuery OptString `json:"switch_inline_query"` @@ -1883,10 +1883,10 @@ type InlineQueryResultArticle struct { Title string `json:"title"` InputMessageContent InputMessageContent `json:"input_message_content"` ReplyMarkup OptInlineKeyboardMarkup `json:"reply_markup"` - URL OptURL `json:"url"` + URL OptString `json:"url"` HideURL OptBool `json:"hide_url"` Description OptString `json:"description"` - ThumbURL OptURL `json:"thumb_url"` + ThumbURL OptString `json:"thumb_url"` ThumbWidth OptInt `json:"thumb_width"` ThumbHeight OptInt `json:"thumb_height"` } @@ -1895,7 +1895,7 @@ type InlineQueryResultArticle struct { type InlineQueryResultAudio struct { Type string `json:"type"` ID string `json:"id"` - AudioURL url.URL `json:"audio_url"` + AudioURL string `json:"audio_url"` Title string `json:"title"` Caption OptString `json:"caption"` ParseMode OptString `json:"parse_mode"` @@ -2018,7 +2018,7 @@ type InlineQueryResultContact struct { Vcard OptString `json:"vcard"` ReplyMarkup OptInlineKeyboardMarkup `json:"reply_markup"` InputMessageContent *InputMessageContent `json:"input_message_content"` - ThumbURL OptURL `json:"thumb_url"` + ThumbURL OptString `json:"thumb_url"` ThumbWidth OptInt `json:"thumb_width"` ThumbHeight OptInt `json:"thumb_height"` } @@ -2031,12 +2031,12 @@ type InlineQueryResultDocument struct { Caption OptString `json:"caption"` ParseMode OptString `json:"parse_mode"` CaptionEntities []MessageEntity `json:"caption_entities"` - DocumentURL url.URL `json:"document_url"` + DocumentURL string `json:"document_url"` MimeType string `json:"mime_type"` Description OptString `json:"description"` ReplyMarkup OptInlineKeyboardMarkup `json:"reply_markup"` InputMessageContent *InputMessageContent `json:"input_message_content"` - ThumbURL OptURL `json:"thumb_url"` + ThumbURL OptString `json:"thumb_url"` ThumbWidth OptInt `json:"thumb_width"` ThumbHeight OptInt `json:"thumb_height"` } @@ -2053,11 +2053,11 @@ type InlineQueryResultGame struct { type InlineQueryResultGif struct { Type string `json:"type"` ID string `json:"id"` - GIFURL url.URL `json:"gif_url"` + GIFURL string `json:"gif_url"` GIFWidth OptInt `json:"gif_width"` GIFHeight OptInt `json:"gif_height"` GIFDuration OptInt `json:"gif_duration"` - ThumbURL url.URL `json:"thumb_url"` + ThumbURL string `json:"thumb_url"` ThumbMimeType OptString `json:"thumb_mime_type"` Title OptString `json:"title"` Caption OptString `json:"caption"` @@ -2080,7 +2080,7 @@ type InlineQueryResultLocation struct { ProximityAlertRadius OptInt `json:"proximity_alert_radius"` ReplyMarkup OptInlineKeyboardMarkup `json:"reply_markup"` InputMessageContent *InputMessageContent `json:"input_message_content"` - ThumbURL OptURL `json:"thumb_url"` + ThumbURL OptString `json:"thumb_url"` ThumbWidth OptInt `json:"thumb_width"` ThumbHeight OptInt `json:"thumb_height"` } @@ -2089,11 +2089,11 @@ type InlineQueryResultLocation struct { type InlineQueryResultMpeg4Gif struct { Type string `json:"type"` ID string `json:"id"` - Mpeg4URL url.URL `json:"mpeg4_url"` + Mpeg4URL string `json:"mpeg4_url"` Mpeg4Width OptInt `json:"mpeg4_width"` Mpeg4Height OptInt `json:"mpeg4_height"` Mpeg4Duration OptInt `json:"mpeg4_duration"` - ThumbURL url.URL `json:"thumb_url"` + ThumbURL string `json:"thumb_url"` ThumbMimeType OptString `json:"thumb_mime_type"` Title OptString `json:"title"` Caption OptString `json:"caption"` @@ -2107,8 +2107,8 @@ type InlineQueryResultMpeg4Gif struct { type InlineQueryResultPhoto struct { Type string `json:"type"` ID string `json:"id"` - PhotoURL url.URL `json:"photo_url"` - ThumbURL url.URL `json:"thumb_url"` + PhotoURL string `json:"photo_url"` + ThumbURL string `json:"thumb_url"` PhotoWidth OptInt `json:"photo_width"` PhotoHeight OptInt `json:"photo_height"` Title OptString `json:"title"` @@ -2134,7 +2134,7 @@ type InlineQueryResultVenue struct { GooglePlaceType OptString `json:"google_place_type"` ReplyMarkup OptInlineKeyboardMarkup `json:"reply_markup"` InputMessageContent *InputMessageContent `json:"input_message_content"` - ThumbURL OptURL `json:"thumb_url"` + ThumbURL OptString `json:"thumb_url"` ThumbWidth OptInt `json:"thumb_width"` ThumbHeight OptInt `json:"thumb_height"` } @@ -2143,9 +2143,9 @@ type InlineQueryResultVenue struct { type InlineQueryResultVideo struct { Type string `json:"type"` ID string `json:"id"` - VideoURL url.URL `json:"video_url"` + VideoURL string `json:"video_url"` MimeType string `json:"mime_type"` - ThumbURL url.URL `json:"thumb_url"` + ThumbURL string `json:"thumb_url"` Title string `json:"title"` Caption OptString `json:"caption"` ParseMode OptString `json:"parse_mode"` @@ -2162,7 +2162,7 @@ type InlineQueryResultVideo struct { type InlineQueryResultVoice struct { Type string `json:"type"` ID string `json:"id"` - VoiceURL url.URL `json:"voice_url"` + VoiceURL string `json:"voice_url"` Title string `json:"title"` Caption OptString `json:"caption"` ParseMode OptString `json:"parse_mode"` @@ -2191,7 +2191,7 @@ type InputInvoiceMessageContent struct { MaxTipAmount OptInt `json:"max_tip_amount"` SuggestedTipAmounts []int `json:"suggested_tip_amounts"` ProviderData OptString `json:"provider_data"` - PhotoURL OptURL `json:"photo_url"` + PhotoURL OptString `json:"photo_url"` PhotoSize OptInt `json:"photo_size"` PhotoWidth OptInt `json:"photo_width"` PhotoHeight OptInt `json:"photo_height"` @@ -2702,7 +2702,7 @@ type Location struct { // Ref: #/components/schemas/LoginUrl type LoginUrl struct { - URL url.URL `json:"url"` + URL string `json:"url"` ForwardText OptString `json:"forward_text"` BotUsername OptString `json:"bot_username"` RequestWriteAccess OptBool `json:"request_write_access"` @@ -2788,7 +2788,7 @@ type MessageEntity struct { Type MessageEntityType `json:"type"` Offset int `json:"offset"` Length int `json:"length"` - URL OptURL `json:"url"` + URL OptString `json:"url"` User OptUser `json:"user"` Language OptString `json:"language"` } @@ -4680,44 +4680,6 @@ func (o OptSuccessfulPayment) Get() (v SuccessfulPayment, ok bool) { return o.Value, true } -// NewOptURL returns new OptURL with value set to v. -func NewOptURL(v url.URL) OptURL { - return OptURL{ - Value: v, - Set: true, - } -} - -// OptURL is optional url.URL. -type OptURL struct { - Value url.URL - Set bool -} - -// IsSet returns true if OptURL was set. -func (o OptURL) IsSet() bool { return o.Set } - -// Reset unsets value. -func (o *OptURL) Reset() { - var v url.URL - o.Value = v - o.Set = false -} - -// SetTo sets value to v. -func (o *OptURL) SetTo(v url.URL) { - o.Set = true - o.Value = v -} - -// Get returns value and boolean that denotes whether value was set. -func (o OptURL) Get() (v url.URL, ok bool) { - if !o.Set { - return v, false - } - return o.Value, true -} - // NewOptUser returns new OptUser with value set to v. func NewOptUser(v User) OptUser { return OptUser{ @@ -6490,7 +6452,7 @@ type SendInvoice struct { SuggestedTipAmounts []int `json:"suggested_tip_amounts"` StartParameter OptString `json:"start_parameter"` ProviderData OptString `json:"provider_data"` - PhotoURL OptURL `json:"photo_url"` + PhotoURL OptString `json:"photo_url"` PhotoSize OptInt `json:"photo_size"` PhotoWidth OptInt `json:"photo_width"` PhotoHeight OptInt `json:"photo_height"` @@ -7956,7 +7918,7 @@ type SetStickerSetThumb struct { // Ref: #/components/schemas/setWebhook type SetWebhook struct { - URL url.URL `json:"url"` + URL string `json:"url"` Certificate OptString `json:"certificate"` IPAddress OptString `json:"ip_address"` MaxConnections OptInt `json:"max_connections"` @@ -8160,7 +8122,7 @@ type VoiceChatStarted struct{} // Ref: #/components/schemas/WebhookInfo type WebhookInfo struct { - URL url.URL `json:"url"` + URL string `json:"url"` HasCustomCertificate bool `json:"has_custom_certificate"` PendingUpdateCount int `json:"pending_update_count"` IPAddress OptString `json:"ip_address"` From 5ed40f1f9eed55b3e3860b865dbeedfe57984b68 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Wed, 15 Dec 2021 11:04:38 +0300 Subject: [PATCH 16/29] chore(botapi): update code due to schema changes --- internal/botapi/convert_message.go | 7 +------ internal/botapi/markup.go | 17 ++++------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/internal/botapi/convert_message.go b/internal/botapi/convert_message.go index 9a9ed2b..f299ff0 100644 --- a/internal/botapi/convert_message.go +++ b/internal/botapi/convert_message.go @@ -2,7 +2,6 @@ package botapi import ( "context" - "net/url" "strconv" "github.com/go-faster/errors" @@ -48,11 +47,7 @@ func (b *BotAPI) convertToBotAPIEntities( e.Language.SetTo(entity.Language) case *tg.MessageEntityTextURL: e.Type = oas.MessageEntityTypeTextLink - u, _ := url.Parse(entity.URL) - if u == nil { - u = new(url.URL) - } - e.URL.SetTo(*u) + e.URL.SetTo(entity.URL) case *tg.MessageEntityMentionName: e.Type = oas.MessageEntityTypeTextMention user, ok, err := b.peers.FindUser(ctx, entity.UserID) diff --git a/internal/botapi/markup.go b/internal/botapi/markup.go index b235190..97bd61e 100644 --- a/internal/botapi/markup.go +++ b/internal/botapi/markup.go @@ -2,7 +2,6 @@ package botapi import ( "context" - "net/url" "github.com/go-faster/errors" @@ -19,7 +18,7 @@ func (b *BotAPI) convertToTelegramInlineButton( ) (tg.KeyboardButtonClass, error) { switch { case button.URL.Set: - return markup.URL(button.Text, button.URL.Value.String()), nil + return markup.URL(button.Text, button.URL.Value), nil case button.CallbackData.Set: return markup.Callback(button.Text, []byte(button.CallbackData.Value)), nil case button.CallbackGame != nil: @@ -51,7 +50,7 @@ func (b *BotAPI) convertToTelegramInlineButton( RequestWriteAccess: loginURL.RequestWriteAccess.Value, Text: button.Text, FwdText: loginURL.ForwardText.Value, - URL: loginURL.URL.String(), + URL: loginURL.URL, Bot: user, }, nil default: @@ -142,11 +141,7 @@ func convertToBotAPIInlineReplyMarkup(mkp *tg.ReplyInlineMarkup) oas.InlineKeybo button := oas.InlineKeyboardButton{Text: b.GetText()} switch b := b.(type) { case *tg.KeyboardButtonURL: - u, _ := url.Parse(b.URL) - if u == nil { - u = new(url.URL) - } - button.URL.SetTo(*u) + button.URL.SetTo(b.URL) case *tg.KeyboardButtonCallback: button.CallbackData.SetTo(string(b.Data)) case *tg.KeyboardButtonSwitchInline: @@ -164,11 +159,7 @@ func convertToBotAPIInlineReplyMarkup(mkp *tg.ReplyInlineMarkup) oas.InlineKeybo // // See Message definition // See https://github.com/tdlib/telegram-bot-api/blob/90f52477814a2d8a08c9ffb1d780fd179815d715/telegram-bot-api/Client.cpp#L1526 - u, _ := url.Parse(b.URL) - if u == nil { - u = new(url.URL) - } - button.URL.SetTo(*u) + button.URL.SetTo(b.URL) } resultRow[i] = button } From 09bf9220dee92c93cc3ba1799f2856d328b2f4f6 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Wed, 15 Dec 2021 11:04:54 +0300 Subject: [PATCH 17/29] chore: drop telebot dependency --- cmd/bot-example/main.go | 26 -------------------------- go.mod | 5 +---- go.sum | 6 +----- 3 files changed, 2 insertions(+), 35 deletions(-) delete mode 100644 cmd/bot-example/main.go diff --git a/cmd/bot-example/main.go b/cmd/bot-example/main.go deleted file mode 100644 index d90676c..0000000 --- a/cmd/bot-example/main.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -import ( - "flag" - "time" - - tb "gopkg.in/tucnak/telebot.v2" -) - -func main() { - token := flag.String("token", "", "bot token") - flag.Parse() - - b, err := tb.NewBot(tb.Settings{ - URL: "http://localhost:8081", - - Token: *token, - Poller: &tb.LongPoller{Timeout: 10 * time.Second}, - }) - - if err != nil { - panic(err) - } - - _ = b -} diff --git a/go.mod b/go.mod index 6badcab..2c231f0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.17 require ( github.com/PuerkitoBio/goquery v1.8.0 - github.com/gin-gonic/gin v1.7.4 // indirect github.com/go-chi/chi/v5 v5.0.7 github.com/google/uuid v1.3.0 github.com/gotd/td v0.53.0 @@ -14,8 +13,6 @@ require ( go.opentelemetry.io/otel/metric v0.26.0 go.opentelemetry.io/otel/trace v1.3.0 go.uber.org/zap v1.19.1 - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect - gopkg.in/tucnak/telebot.v2 v2.5.0 ) require ( @@ -41,11 +38,11 @@ require ( github.com/klauspost/compress v1.13.6 // indirect github.com/mattn/go-colorable v0.1.11 // indirect github.com/mattn/go-isatty v0.0.14 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 // indirect golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect nhooyr.io/websocket v1.8.7 // indirect rsc.io/qr v0.2.0 // indirect diff --git a/go.sum b/go.sum index 7a545cf..8d01e14 100644 --- a/go.sum +++ b/go.sum @@ -19,9 +19,8 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= -github.com/gin-gonic/gin v1.7.4 h1:QmUZXrvJ9qZ3GfWvQ+2wnW/1ePrTEJqPKMYEU3lD/DM= -github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-faster/errors v0.5.0 h1:hS/zHFJ2Vb14jcupq5J9tk05XW+PFTmySOkDRByHBo4= @@ -121,7 +120,6 @@ github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNX github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -216,8 +214,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/tucnak/telebot.v2 v2.5.0 h1:i+NynLo443Vp+Zn3Gv9JBjh3Z/PaiKAQwcnhNI7y6Po= -gopkg.in/tucnak/telebot.v2 v2.5.0/go.mod h1:BgaIIx50PSRS9pG59JH+geT82cfvoJU/IaI5TJdN3v8= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From 4c6f44175d2cfba5ac4d5c5a286595d8644ddbb4 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Fri, 17 Dec 2021 11:02:26 +0300 Subject: [PATCH 18/29] feat(botapi): update to use peers manager and bbolt --- go.mod | 14 +- go.sum | 20 +- internal/botapi/botapi.go | 63 ++-- internal/botapi/convert_message.go | 36 ++- internal/botapi/markup.go | 10 +- internal/botapi/me.go | 10 +- internal/botapi/peers.go | 191 +++--------- internal/botapi/peers_test.go | 44 --- internal/botstorage/bbolt.go | 479 +++++++++++++++++++++++++++++ internal/peers/inmemory.go | 67 ---- internal/peers/peers.go | 2 - internal/peers/storage.go | 72 ----- internal/pool/client.go | 5 +- internal/pool/pool.go | 189 +++++++----- internal/pool/storage.go | 89 ------ 15 files changed, 704 insertions(+), 587 deletions(-) delete mode 100644 internal/botapi/peers_test.go create mode 100644 internal/botstorage/bbolt.go delete mode 100644 internal/peers/inmemory.go delete mode 100644 internal/peers/peers.go delete mode 100644 internal/peers/storage.go delete mode 100644 internal/pool/storage.go diff --git a/go.mod b/go.mod index 2c231f0..cf29c0a 100644 --- a/go.mod +++ b/go.mod @@ -5,21 +5,19 @@ go 1.17 require ( github.com/PuerkitoBio/goquery v1.8.0 github.com/go-chi/chi/v5 v5.0.7 + github.com/go-faster/errors v0.5.0 + github.com/go-faster/jx v0.25.0 github.com/google/uuid v1.3.0 - github.com/gotd/td v0.53.0 + github.com/gotd/td v0.54.0-alpha.1 github.com/ogen-go/ogen v0.0.0-20211211145630-e16dcf3319e7 github.com/stretchr/testify v1.7.0 + go.etcd.io/bbolt v1.3.6 go.opentelemetry.io/otel v1.3.0 go.opentelemetry.io/otel/metric v0.26.0 go.opentelemetry.io/otel/trace v1.3.0 - go.uber.org/zap v1.19.1 -) - -require ( - github.com/go-faster/errors v0.5.0 - github.com/go-faster/jx v0.25.0 go.uber.org/atomic v1.9.0 go.uber.org/multierr v1.7.0 + go.uber.org/zap v1.19.1 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c ) @@ -40,7 +38,7 @@ require ( github.com/mattn/go-isatty v0.0.14 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect - golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 // indirect + golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect diff --git a/go.sum b/go.sum index 8d01e14..67b7c2e 100644 --- a/go.sum +++ b/go.sum @@ -69,8 +69,8 @@ github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= github.com/gotd/neo v0.1.4 h1:av+c/4R+3B/eAlr+Bz++q+/DOuGKz+sfwJXmPXRbU/s= github.com/gotd/neo v0.1.4/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= -github.com/gotd/td v0.53.0 h1:DgNKyjVrJThxYsl2qmLv6LWSnO+6Cx5aEiYbGrd2ts0= -github.com/gotd/td v0.53.0/go.mod h1:+tPM57zxv7Fjc4dv+WjOfovBAZvHneXYH/bE3JF53Og= +github.com/gotd/td v0.54.0-alpha.1 h1:Zfj67YVVDC0CbTbNZYH1G9KxTTCUbjBKaAwAl6qrLBU= +github.com/gotd/td v0.54.0-alpha.1/go.mod h1:Ce9Z+p9SqI6W+N9mwIQ+IqHEdNIif5khUmydmWkDB44= github.com/gotd/tl v0.4.0/go.mod h1:CMIcjPWFS4qxxJ+1Ce7U/ilbtPrkoVo/t8uhN5Y/D7c= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -113,7 +113,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE 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/quasilyte/go-ruleguard/dsl v0.3.10/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= @@ -133,7 +133,9 @@ github.com/valyala/fasthttp v1.31.0 h1:lrauRLII19afgCs2fnWRJ4M5IkV0lo2FqA61uGkNB github.com/valyala/fasthttp v1.31.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.opentelemetry.io/otel v1.2.0/go.mod h1:aT17Fk0Z1Nor9e0uisf98LrntPGMnk4frBO9+dkf69I= go.opentelemetry.io/otel v1.3.0 h1:APxLf0eiBwLl+SOXiJJCVYzA1OOJNyAoV8C5RNRyy7Y= go.opentelemetry.io/otel v1.3.0/go.mod h1:PWIKzi6JCp7sM0k9yZ43VX+T345uNbAkDKwHVjb2PTs= @@ -171,9 +173,9 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 h1:/6y1LfuqNuQdHAm0jjtPtgRcxIxjVZgm5OTu8/QhZvk= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= @@ -182,14 +184,15 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211030160813-b3129d9d1021/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -198,12 +201,13 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/botapi/botapi.go b/internal/botapi/botapi.go index bc06b22..92a48e1 100644 --- a/internal/botapi/botapi.go +++ b/internal/botapi/botapi.go @@ -3,20 +3,17 @@ package botapi import ( "context" - "sync" "github.com/go-faster/errors" - "go.uber.org/atomic" + "github.com/gotd/td/telegram/peers" "go.uber.org/zap" "github.com/gotd/td/telegram" "github.com/gotd/td/telegram/message" - "github.com/gotd/td/telegram/message/peer" "github.com/gotd/td/telegram/updates" "github.com/gotd/td/tg" "github.com/gotd/botapi/internal/oas" - "github.com/gotd/botapi/internal/peers" ) // BotAPI is Bot API implementation. @@ -25,13 +22,8 @@ type BotAPI struct { raw *tg.Client gaps *updates.Manager - sender *message.Sender - resolver peer.Resolver - peers peers.Storage - - self *tg.User - selfID atomic.Int64 - selfMux sync.Mutex + sender *message.Sender + peers *peers.Manager debug bool logger *zap.Logger @@ -41,59 +33,42 @@ type BotAPI struct { func NewBotAPI( client *telegram.Client, gaps *updates.Manager, - store peers.Storage, + peer *peers.Manager, opts Options, ) *BotAPI { opts.setDefaults() raw := client.API() - resolver := peer.SingleflightResolver(peer.Plain(raw)) return &BotAPI{ - client: client, - raw: raw, - gaps: gaps, - sender: message.NewSender(raw).WithResolver(resolver), - resolver: resolver, - peers: store, - debug: opts.Debug, - logger: opts.Logger, + client: client, + raw: raw, + gaps: gaps, + sender: message.NewSender(raw), + peers: peer, + debug: opts.Debug, + logger: opts.Logger, } } // Init makes some initialization requests. func (b *BotAPI) Init(ctx context.Context) error { - me, err := b.client.Self(ctx) + if err := b.peers.Init(ctx); err != nil { + return errors.Wrap(err, "init peers") + } + + me, err := b.peers.Self(ctx) if err != nil { - return errors.Wrap(err, "self") + return errors.Wrap(err, "get self") } - if err := b.gaps.Auth(ctx, b.raw, me.ID, true, false); err != nil { + _, isBot := me.ToBot() + if err := b.gaps.Auth(ctx, b.raw, me.ID(), isBot, false); err != nil { return errors.Wrap(err, "init gaps") } - b.updateSelf(me) return nil } -func (b *BotAPI) updateSelf(user *tg.User) { - b.selfMux.Lock() - b.self = user - b.selfID.Store(user.ID) - b.selfMux.Unlock() -} - -func (b *BotAPI) getSelf() *tg.User { - b.selfMux.Lock() - self := b.self - b.selfMux.Unlock() - return self -} - -// Client returns *telegram.Client used by this instance of BotAPI. -func (b *BotAPI) Client() *telegram.Client { - return b.client -} - // GetUpdates implements oas.Handler. func (b *BotAPI) GetUpdates(ctx context.Context, req oas.OptGetUpdates) (oas.ResultArrayOfUpdate, error) { return oas.ResultArrayOfUpdate{}, &NotImplementedError{} diff --git a/internal/botapi/convert_message.go b/internal/botapi/convert_message.go index f299ff0..cd9b4b3 100644 --- a/internal/botapi/convert_message.go +++ b/internal/botapi/convert_message.go @@ -50,11 +50,8 @@ func (b *BotAPI) convertToBotAPIEntities( e.URL.SetTo(entity.URL) case *tg.MessageEntityMentionName: e.Type = oas.MessageEntityTypeTextMention - user, ok, err := b.peers.FindUser(ctx, entity.UserID) - if err != nil { - return nil, errors.Wrapf(err, "find user: %d", entity.UserID) - } - if ok { + user, err := b.resolveUserID(ctx, entity.UserID) + if err == nil { e.User.SetTo(convertToUser(user)) } case *tg.MessageEntityPhone: @@ -181,10 +178,21 @@ func (b *BotAPI) setDocumentAttachment(ctx context.Context, d *tg.Document, r *o }) } - stickerSet, err := b.raw.MessagesGetStickerSet(ctx, attr.Stickerset) + // TODO(tdakota): make stickerset cache + result, err := b.raw.MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{ + Stickerset: attr.Stickerset, + Hash: 0, + }) if err != nil { return errors.Wrap(err, "get sticker_set") } + var stickerSet *tg.MessagesStickerSet + switch result := result.(type) { + case *tg.MessagesStickerSet: + stickerSet = result + default: + return errors.Errorf("unexpected type %T", result) + } r.Sticker.SetTo(oas.Sticker{ FileID: fileID, @@ -441,19 +449,19 @@ func (b *BotAPI) convertPlainMessage(ctx context.Context, m *tg.Message) (r oas. } r = oas.Message{ - MessageID: m.ID, - Date: m.Date, - Chat: ch, - EditDate: optInt(m.GetEditDate), - // TODO(tdakkota): bump gotd version and implement - HasProtectedContent: oas.OptBool{}, + MessageID: m.ID, + Date: m.Date, + Chat: ch, + EditDate: optInt(m.GetEditDate), + HasProtectedContent: ch.HasProtectedContent, // TODO(tdakkota): generate media album ids MediaGroupID: oas.OptString{}, AuthorSignature: optString(m.GetPostAuthor), } if m.Out { - if self := b.getSelf(); self != nil { - r.From.SetTo(convertToUser(self)) + self, err := b.peers.Self(ctx) + if err == nil { + r.From.SetTo(convertToUser(self.Raw())) } } else if fromID, ok := m.GetFromID(); ok { // FIXME(tdakkota): set service IDs. diff --git a/internal/botapi/markup.go b/internal/botapi/markup.go index 97bd61e..719e5ab 100644 --- a/internal/botapi/markup.go +++ b/internal/botapi/markup.go @@ -4,9 +4,9 @@ import ( "context" "github.com/go-faster/errors" + "github.com/gotd/td/telegram/peers" "github.com/gotd/td/telegram/message/markup" - "github.com/gotd/td/telegram/message/peer" "github.com/gotd/td/tg" "github.com/gotd/botapi/internal/oas" @@ -34,16 +34,16 @@ func (b *BotAPI) convertToTelegramInlineButton( var user tg.InputUserClass = &tg.InputUserSelf{} if v, ok := loginURL.BotUsername.Get(); ok && v != "" { - p, err := b.resolver.ResolveDomain(ctx, loginURL.BotUsername.Value) + p, err := b.peers.ResolveDomain(ctx, loginURL.BotUsername.Value) if err != nil { return nil, errors.Wrap(err, "resolve bot") } - u, ok := peer.ToInputUser(p) + u, ok := p.(peers.User) if !ok { - return nil, &BadRequestError{Message: "given username is not user"} + return nil, &BadRequestError{Message: "given username is not bot"} } - user = u + user = u.InputUser() } return &tg.InputKeyboardButtonURLAuth{ diff --git a/internal/botapi/me.go b/internal/botapi/me.go index cbfe9dc..d97dfe6 100644 --- a/internal/botapi/me.go +++ b/internal/botapi/me.go @@ -24,14 +24,13 @@ func convertToUser(user *tg.User) oas.User { // GetMe implements oas.Handler. func (b *BotAPI) GetMe(ctx context.Context) (oas.ResultUser, error) { - me, err := b.client.Self(ctx) + me, err := b.peers.Self(ctx) if err != nil { return oas.ResultUser{}, err } - b.updateSelf(me) return oas.ResultUser{ - Result: oas.NewOptUser(convertToUser(me)), + Result: oas.NewOptUser(convertToUser(me.Raw())), Ok: true, }, nil } @@ -44,10 +43,9 @@ func (b *BotAPI) Close(ctx context.Context) (oas.Result, error) { // LogOut implements oas.Handler. func (b *BotAPI) LogOut(ctx context.Context) (oas.Result, error) { - r, err := b.raw.AuthLogOut(ctx) - if err != nil { + if _, err := b.raw.AuthLogOut(ctx); err != nil { return oas.Result{}, err } - return resultOK(r), nil + return resultOK(true), nil } diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go index cbb3167..4ceec9d 100644 --- a/internal/botapi/peers.go +++ b/internal/botapi/peers.go @@ -2,132 +2,50 @@ package botapi import ( "context" + "strconv" "github.com/go-faster/errors" - + "github.com/gotd/td/constant" + "github.com/gotd/td/telegram/peers" "github.com/gotd/td/tg" "github.com/gotd/botapi/internal/oas" ) -const ( - // MaxTDLibChatID is maximum chat TDLib ID. - MaxTDLibChatID = 999999999999 - // MaxTDLibChannelID is maximum channel TDLib ID. - MaxTDLibChannelID = 1000000000000 - int64(1<<31) - // ZeroTDLibChannelID is minimum channel TDLib ID. - ZeroTDLibChannelID = -1000000000000 - // MaxTDLibUserID is maximum user TDLib ID. - MaxTDLibUserID = (1 << 40) - 1 -) - -func fullChatInputPeer(full tg.FullChat) tg.InputPeerClass { - switch full := full.(type) { - case *tg.Chat: - return &tg.InputPeerChat{ChatID: full.ID} - case *tg.Channel: - return &tg.InputPeerChannel{ - ChannelID: full.ID, - AccessHash: full.AccessHash, - } - default: - return &tg.InputPeerEmpty{} - } -} - -func toTDLibID(p tg.InputPeerClass) int64 { - switch p := p.(type) { - case *tg.InputPeerUser: - return p.GetUserID() - case *tg.InputPeerChat: - return -p.GetChatID() - case *tg.InputPeerChannel: - return ZeroTDLibChannelID + (p.GetChannelID() * -1) - default: - return 0 - } -} - -func fromTDLibID(id int64) int64 { - switch { - case IsUserTDLibID(id): - case IsChatTDLibID(id): - id = -id - case IsChannelTDLibID(id): - id -= ZeroTDLibChannelID - id = -id +func (b *BotAPI) getChatByPeer(ctx context.Context, p tg.PeerClass) (oas.Chat, error) { + peer, err := b.peers.ResolvePeer(ctx, p) + if err != nil { + return oas.Chat{}, errors.Wrapf(err, "find peer: %+v", p) } - return id -} - -// IsUserTDLibID whether that given ID is user ID. -func IsUserTDLibID(id int64) bool { - return id > 0 && id <= MaxTDLibUserID -} -// IsChatTDLibID whether that given ID is chat ID. -func IsChatTDLibID(id int64) bool { - return id < 0 && -MaxTDLibChatID <= id -} - -// IsChannelTDLibID whether that given ID is channel ID. -func IsChannelTDLibID(id int64) bool { - return id < 0 && - id != ZeroTDLibChannelID && - !IsChatTDLibID(id) && - ZeroTDLibChannelID-MaxTDLibChannelID <= id -} - -func (b *BotAPI) getChatByPeer(ctx context.Context, p tg.PeerClass) (oas.Chat, error) { - var chatID int64 - switch p := p.(type) { - case *tg.PeerUser: - user, ok, err := b.peers.FindUser(ctx, p.UserID) - switch { - case err != nil: - return oas.Chat{}, errors.Wrapf(err, "find user: %d", p.UserID) - case !ok: - return oas.Chat{}, errors.Errorf("can't find user %d", p.UserID) - } + tdlibID := peer.TDLibPeerID() + if user, ok := peer.(peers.User); ok { return oas.Chat{ - ID: toTDLibID(user.AsInputPeer()), + ID: int64(tdlibID), Type: oas.ChatTypePrivate, - Username: optString(user.GetUsername), - FirstName: optString(user.GetFirstName), - LastName: optString(user.GetLastName), + Username: optString(user.Username), + FirstName: optString(user.FirstName), + LastName: optString(user.LastName), }, nil - case *tg.PeerChat: - chatID = p.ChatID - case *tg.PeerChannel: - chatID = p.ChannelID - default: - return oas.Chat{}, errors.Errorf("unexpected type %T", p) - } - - chat, ok, err := b.peers.FindChat(ctx, chatID) - switch { - case err != nil: - return oas.Chat{}, errors.Wrapf(err, "find chat: %d", chatID) - case !ok: - return oas.Chat{}, errors.Errorf("can't find chat %d", chatID) } r := oas.Chat{ - ID: toTDLibID(fullChatInputPeer(chat)), - Type: oas.ChatTypeGroup, - Title: oas.NewOptString(chat.GetTitle()), - // TODO(tdakkota): set more fields, when gotd schema will be updated + ID: int64(tdlibID), + Type: oas.ChatTypeGroup, + Title: oas.NewOptString(peer.VisibleName()), HasProtectedContent: oas.OptBool{}, } - switch ch := chat.(type) { - case *tg.Chat: - case *tg.Channel: - if ch.Broadcast { + switch ch := peer.(type) { + case peers.Chat: + r.HasProtectedContent.SetTo(ch.NoForwards()) + case peers.Channel: + if _, ok := ch.ToBroadcast(); ok { r.Type = oas.ChatTypeChannel } else { r.Type = oas.ChatTypeSupergroup } - r.Username = optString(ch.GetUsername) + r.Username = optString(ch.Username) + r.HasProtectedContent.SetTo(ch.NoForwards()) } return r, nil @@ -135,64 +53,45 @@ func (b *BotAPI) getChatByPeer(ctx context.Context, p tg.PeerClass) (oas.Chat, e func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (tg.InputPeerClass, error) { if id.IsInt64() { - return b.resolveIntID(ctx, id) + return b.resolveIntID(ctx, id.Int64) } username := id.String - if len(username) < 1 || username[0] != '@' { + if len(username) < 1 { return nil, &PeerNotFoundError{ID: id} } + switch { + case len(username) < 1: + return nil, &PeerNotFoundError{ID: id} + case username[0] != '@': + parsedID, err := strconv.ParseInt(username, 10, 64) + if err != nil { + return nil, errors.Wrap(err, "parse id") + } + return b.resolveIntID(ctx, parsedID) + } // Cut @. username = username[1:] - p, err := b.resolver.ResolveDomain(ctx, username) + p, err := b.peers.ResolveDomain(ctx, username) if err != nil { return nil, errors.Wrapf(err, "resolve %q", username) } - switch p.(type) { - case *tg.InputPeerChat, *tg.InputPeerChannel: - return p, nil - default: - return nil, &PeerNotFoundError{ID: id} - } + return p.InputPeer(), nil } func (b *BotAPI) resolveUserID(ctx context.Context, id int64) (*tg.User, error) { - user, ok, err := b.peers.FindUser(ctx, id) - switch { - case err != nil: + user, err := b.peers.GetUser(ctx, &tg.InputUser{UserID: id}) + if err != nil { return nil, errors.Wrapf(err, "find user: %d", id) - case !ok: - return nil, &PeerNotFoundError{ID: oas.NewInt64ID(id)} } - return user, nil + return user.Raw(), nil } -func (b *BotAPI) resolveIntID(ctx context.Context, chatID oas.ID) (tg.InputPeerClass, error) { - id := chatID.Int64 - cleanID := fromTDLibID(id) - - if IsUserTDLibID(id) { - user, err := b.resolveUserID(ctx, cleanID) - if err != nil { - return nil, err - } - return user.AsInputPeer(), nil - } - - chat, ok, err := b.peers.FindChat(ctx, cleanID) - switch { - case err != nil: - return nil, errors.Wrapf(err, "find chat: %d", id) - case !ok: - return nil, &PeerNotFoundError{ID: chatID} - } - switch chat := chat.(type) { - case *tg.Chat: - return chat.AsInputPeer(), nil - case *tg.Channel: - return chat.AsInputPeer(), nil - default: - return nil, &PeerNotFoundError{ID: chatID} +func (b *BotAPI) resolveIntID(ctx context.Context, id int64) (tg.InputPeerClass, error) { + p, err := b.peers.ResolveTDLibID(ctx, constant.TDLibPeerID(id)) + if err != nil { + return nil, errors.Wrapf(err, "find peer %d", id) } + return p.InputPeer(), nil } diff --git a/internal/botapi/peers_test.go b/internal/botapi/peers_test.go deleted file mode 100644 index 5a58d74..0000000 --- a/internal/botapi/peers_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package botapi - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/gotd/td/tg" -) - -func Test_toTDLibID(t *testing.T) { - tests := []struct { - name string - tdlibID int64 - p tg.InputPeerClass - }{ - {"User", 309570373, &tg.InputPeerUser{UserID: 309570373}}, - {"Bot", 140267078, &tg.InputPeerUser{UserID: 140267078}}, - {"Chat", -365219918, &tg.InputPeerChat{ChatID: 365219918}}, - {"Channel", -1001228418968, &tg.InputPeerChannel{ChannelID: 1228418968}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - a := require.New(t) - - tdlibID := toTDLibID(tt.p) - a.Equal(tt.tdlibID, tdlibID) - var mtprotoID int64 - switch t := tt.p.(type) { - case *tg.InputPeerUser: - mtprotoID = t.UserID - a.True(IsUserTDLibID(tdlibID)) - case *tg.InputPeerChat: - mtprotoID = t.ChatID - a.True(IsChatTDLibID(tdlibID)) - case *tg.InputPeerChannel: - mtprotoID = t.ChannelID - a.True(IsChannelTDLibID(tdlibID)) - } - cleanID := fromTDLibID(tdlibID) - a.Equal(mtprotoID, cleanID) - }) - } -} diff --git a/internal/botstorage/bbolt.go b/internal/botstorage/bbolt.go new file mode 100644 index 0000000..4ea2b05 --- /dev/null +++ b/internal/botstorage/bbolt.go @@ -0,0 +1,479 @@ +package botstorage + +import ( + "context" + "encoding/binary" + + "github.com/go-faster/errors" + "github.com/gotd/td/bin" + "github.com/gotd/td/session" + "github.com/gotd/td/telegram/peers" + "github.com/gotd/td/telegram/updates" + "github.com/gotd/td/tg" + "go.etcd.io/bbolt" + "go.uber.org/multierr" +) + +// BBoltStorage is bbolt-based storage. +type BBoltStorage struct { + db *bbolt.DB +} + +var _ interface { + peers.Storage + peers.Cache + updates.StateStorage +} = (*BBoltStorage)(nil) + +// NewBBoltStorage creates new BBoltStorage. +func NewBBoltStorage(db *bbolt.DB) *BBoltStorage { + return &BBoltStorage{db: db} +} + +var _ = map[string]struct{}{ + hashPrefix: {}, + userPrefix: {}, + userFullPrefix: {}, + chatPrefix: {}, + chatFullPrefix: {}, + channelPrefix: {}, + channelFullPrefix: {}, + statePrefix: {}, + channelsPtsPrefix: {}, + sessionPrefix: {}, +} + +const ( + hashPrefix = "hashes_" + userPrefix = "users_" + userFullPrefix = "userFulls_" + chatPrefix = "chats_" + chatFullPrefix = "chatFulls_" + channelPrefix = "channels_" + channelFullPrefix = "channelFulls_" +) + +func formatInt(i int64) []byte { + var keyBuf [8]byte + binary.LittleEndian.PutUint64(keyBuf[:], uint64(i)) + return keyBuf[:] +} + +func parseInt(v []byte) (int64, bool) { + if len(v) < 8 { + return 0, false + } + i := binary.LittleEndian.Uint64(v[:]) + return int64(i), true +} + +func (b *BBoltStorage) batchBucket(prefix string, cb func(b *bbolt.Bucket, tx *bbolt.Tx) error) error { + return b.db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(prefix)) + if err != nil { + return errors.Wrapf(err, "create %q bucket", prefix) + } + return cb(b, tx) + }) +} + +// Save implements peers.Storage. +func (b *BBoltStorage) Save(_ context.Context, key peers.Key, value peers.Value) error { + return b.batchBucket(hashPrefix+key.Prefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + return b.Put(formatInt(key.ID), formatInt(value.AccessHash)) + }) +} + +// Find implements peers.Storage. +func (b *BBoltStorage) Find(_ context.Context, key peers.Key) (value peers.Value, found bool, err error) { + err = b.batchBucket(hashPrefix+key.Prefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + storageKey := formatInt(key.ID) + val := b.Get(storageKey) + id, ok := parseInt(val) + if !ok { + return multierr.Append(errors.Errorf("got invalid value %+x", val), b.Delete(storageKey)) + } + value.AccessHash = id + found = true + return nil + }) + + return value, found, err +} + +// SavePhone implements peers.Storage. +func (b *BBoltStorage) SavePhone(_ context.Context, phone string, key peers.Key) error { + // FIXME(tdakkota): Implement it. We don't need it for bots + // However, we can use it as default cache. + return nil +} + +// FindPhone implements peers.Storage. +func (b *BBoltStorage) FindPhone(_ context.Context, phone string) (key peers.Key, value peers.Value, found bool, err error) { + // FIXME(tdakkota): Implement it. We don't need it for bots + // However, we can use it as default cache. + return key, value, found, err +} + +// GetContactsHash implements peers.Storage. +func (b *BBoltStorage) GetContactsHash(ctx context.Context) (int64, error) { + // FIXME(tdakkota): Implement it. We don't need it for bots + // However, we can use it as default cache. + return 0, nil +} + +// SaveContactsHash implements peers.Storage. +func (b *BBoltStorage) SaveContactsHash(_ context.Context, hash int64) error { + // FIXME(tdakkota): Implement it. We don't need it for bots + // However, we can use it as default cache. + return nil +} + +func putMTProtoKey(b *bbolt.Bucket, e interface { + GetID() int64 + bin.Encoder +}) error { + var buf bin.Buffer + if err := e.Encode(&buf); err != nil { + return errors.Wrap(err, "encode") + } + id := e.GetID() + if err := b.Put(formatInt(id), buf.Raw()); err != nil { + return errors.Wrapf(err, "put %d", id) + } + return nil +} + +func getMTProtoKey(b *bbolt.Bucket, id int64, d bin.Decoder) (bool, error) { + key := formatInt(id) + data := b.Get(key) + if data == nil { + return false, nil + } + buf := bin.Buffer{Buf: data} + if err := d.Decode(&buf); err != nil { + // Ignore decode errors. + return false, b.Delete(key) + } + return true, nil +} + +// SaveUsers implements BBoltStorage. +func (b *BBoltStorage) SaveUsers(_ context.Context, users ...*tg.User) error { + return b.batchBucket(userPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + for _, user := range users { + if err := putMTProtoKey(b, user); err != nil { + return err + } + } + return nil + }) +} + +// SaveUserFulls implements BBoltStorage. +func (b *BBoltStorage) SaveUserFulls(_ context.Context, users ...*tg.UserFull) error { + return b.batchBucket(userFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + for _, user := range users { + if err := putMTProtoKey(b, user); err != nil { + return err + } + } + return nil + }) +} + +// FindUser implements BBoltStorage. +func (b *BBoltStorage) FindUser(_ context.Context, id int64) (e *tg.User, found bool, err error) { + // Use batch to delete invalid keys. + err = b.batchBucket(userPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.User) + found, err = getMTProtoKey(b, id, e) + return err + }) + return e, found, err +} + +// FindUserFull implements BBoltStorage. +func (b *BBoltStorage) FindUserFull(_ context.Context, id int64) (e *tg.UserFull, found bool, err error) { + // Use batch to delete invalid keys. + err = b.batchBucket(userFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.UserFull) + found, err = getMTProtoKey(b, id, e) + return err + }) + return e, found, err +} + +// SaveChats implements BBoltStorage. +func (b *BBoltStorage) SaveChats(_ context.Context, chats ...*tg.Chat) error { + return b.batchBucket(chatPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + for _, chat := range chats { + if err := putMTProtoKey(b, chat); err != nil { + return err + } + } + return nil + }) +} + +// SaveChatFulls implements BBoltStorage. +func (b *BBoltStorage) SaveChatFulls(_ context.Context, chats ...*tg.ChatFull) error { + return b.batchBucket(chatFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + for _, chat := range chats { + if err := putMTProtoKey(b, chat); err != nil { + return err + } + } + return nil + }) +} + +// FindChat implements BBoltStorage. +func (b *BBoltStorage) FindChat(_ context.Context, id int64) (e *tg.Chat, found bool, err error) { + err = b.batchBucket(chatPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.Chat) + found, err = getMTProtoKey(b, id, e) + return err + }) + return e, found, err +} + +// FindChatFull implements BBoltStorage. +func (b *BBoltStorage) FindChatFull(_ context.Context, id int64) (e *tg.ChatFull, found bool, err error) { + err = b.batchBucket(chatFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.ChatFull) + found, err = getMTProtoKey(b, id, e) + return err + }) + return e, found, err +} + +// SaveChannels implements BBoltStorage. +func (b *BBoltStorage) SaveChannels(_ context.Context, channels ...*tg.Channel) error { + return b.batchBucket(channelPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + for _, channel := range channels { + if err := putMTProtoKey(b, channel); err != nil { + return err + } + } + return nil + }) +} + +// SaveChannelFulls implements BBoltStorage. +func (b *BBoltStorage) SaveChannelFulls(_ context.Context, channels ...*tg.ChannelFull) error { + return b.batchBucket(channelFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + for _, channel := range channels { + if err := putMTProtoKey(b, channel); err != nil { + return err + } + } + return nil + }) +} + +// FindChannel implements BBoltStorage. +func (b *BBoltStorage) FindChannel(_ context.Context, id int64) (e *tg.Channel, found bool, err error) { + err = b.batchBucket(channelPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.Channel) + found, err = getMTProtoKey(b, id, e) + return err + }) + return e, found, err +} + +// FindChannelFull implements BBoltStorage. +func (b *BBoltStorage) FindChannelFull(_ context.Context, id int64) (e *tg.ChannelFull, found bool, err error) { + err = b.batchBucket(channelFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.ChannelFull) + found, err = getMTProtoKey(b, id, e) + return err + }) + return e, found, err +} + +const ( + statePrefix = "state_" + ptsKey = "pts" + qtsKey = "qts" + dateKey = "date" + seqKey = "seq" + + channelsPtsPrefix = "channel_pts_" +) + +func getStateField(b *bbolt.Bucket, key string, v *int) (bool, error) { + bytesKey := []byte(key) + data := b.Get(bytesKey) + if data == nil { + return false, nil + } + p, ok := parseInt(data) + if !ok { + return false, multierr.Append(errors.Errorf("decode %q", key), b.Delete(bytesKey)) + } + *v = int(p) + return true, nil +} + +// GetState implements updates.StateStorage. +func (b *BBoltStorage) GetState(_ int64) (state updates.State, found bool, err error) { + err = b.batchBucket(statePrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + if ok, err := getStateField(b, ptsKey, &state.Pts); err != nil || !ok { + return err + } + if ok, err := getStateField(b, qtsKey, &state.Qts); err != nil || !ok { + return err + } + if ok, err := getStateField(b, dateKey, &state.Date); err != nil || !ok { + return err + } + if ok, err := getStateField(b, seqKey, &state.Seq); err != nil || !ok { + return err + } + found = true + return nil + }) + return state, found, err +} + +func setStateField(b *bbolt.Bucket, key string, v int) error { + return b.Put([]byte(key), formatInt(int64(v))) +} + +// SetState implements updates.StateStorage. +func (b *BBoltStorage) SetState(_ int64, state updates.State) error { + return b.batchBucket(statePrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + if err := setStateField(b, ptsKey, state.Pts); err != nil { + return err + } + if err := setStateField(b, qtsKey, state.Qts); err != nil { + return err + } + if err := setStateField(b, dateKey, state.Date); err != nil { + return err + } + if err := setStateField(b, seqKey, state.Seq); err != nil { + return err + } + return nil + }) +} + +// SetPts implements updates.StateStorage. +func (b *BBoltStorage) SetPts(_ int64, pts int) error { + return b.batchBucket(statePrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + if err := setStateField(b, ptsKey, pts); err != nil { + return err + } + return nil + }) +} + +// SetQts implements updates.StateStorage. +func (b *BBoltStorage) SetQts(_ int64, qts int) error { + return b.batchBucket(statePrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + if err := setStateField(b, qtsKey, qts); err != nil { + return err + } + return nil + }) +} + +// SetDate implements updates.StateStorage. +func (b *BBoltStorage) SetDate(_ int64, date int) error { + return b.batchBucket(statePrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + if err := setStateField(b, dateKey, date); err != nil { + return err + } + return nil + }) +} + +// SetSeq implements updates.StateStorage. +func (b *BBoltStorage) SetSeq(_ int64, seq int) error { + return b.batchBucket(statePrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + if err := setStateField(b, seqKey, seq); err != nil { + return err + } + return nil + }) +} + +// SetDateSeq implements updates.StateStorage. +func (b *BBoltStorage) SetDateSeq(_ int64, date, seq int) error { + return b.batchBucket(statePrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + if err := setStateField(b, dateKey, date); err != nil { + return err + } + if err := setStateField(b, seqKey, seq); err != nil { + return err + } + return nil + }) +} + +// GetChannelPts implements updates.StateStorage. +func (b *BBoltStorage) GetChannelPts(_, channelID int64) (pts int, found bool, err error) { + err = b.batchBucket(channelsPtsPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + b.Get(formatInt(channelID)) + return nil + }) + return pts, found, err +} + +// SetChannelPts implements updates.StateStorage. +func (b *BBoltStorage) SetChannelPts(_, channelID int64, pts int) error { + return b.batchBucket(channelsPtsPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + return b.Put(formatInt(channelID), formatInt(int64(pts))) + }) +} + +// ForEachChannels implements updates.StateStorage. +func (b *BBoltStorage) ForEachChannels(_ int64, f func(channelID int64, pts int) error) error { + return b.batchBucket(channelsPtsPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + return b.ForEach(func(k, v []byte) error { + channelID, ok := parseInt(k) + if !ok { + // Delete invalid entries. + return b.Delete(k) + } + pts, ok := parseInt(v) + if !ok { + // Delete invalid entries. + return b.Delete(v) + } + + return f(channelID, int(pts)) + }) + }) +} + +const ( + sessionPrefix = "session_" + sessionKey = "session" +) + +// LoadSession implements session.Storage. +func (b *BBoltStorage) LoadSession(_ context.Context) (data []byte, err error) { + err = b.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(sessionPrefix)) + if b == nil { + return session.ErrNotFound + } + + data = b.Get([]byte(sessionKey)) + if data == nil { + return session.ErrNotFound + } + return nil + }) + return data, err +} + +// StoreSession implements session.Storage. +func (b *BBoltStorage) StoreSession(_ context.Context, data []byte) error { + return b.batchBucket(sessionPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + return b.Put([]byte(sessionKey), data) + }) +} diff --git a/internal/peers/inmemory.go b/internal/peers/inmemory.go deleted file mode 100644 index 853bc92..0000000 --- a/internal/peers/inmemory.go +++ /dev/null @@ -1,67 +0,0 @@ -package peers - -import ( - "context" - "sync" - - "github.com/gotd/td/tg" -) - -// InmemoryStorage stores users and chats info in memory. -type InmemoryStorage struct { - chats map[int64]tg.FullChat - chatsMux sync.RWMutex - - usersMux sync.RWMutex - users map[int64]*tg.User -} - -// NewInmemoryStorage creates new InmemoryStorage. -func NewInmemoryStorage() *InmemoryStorage { - return &InmemoryStorage{ - chats: map[int64]tg.FullChat{}, - users: map[int64]*tg.User{}, - } -} - -// SaveUsers implements FileStorage. -func (f *InmemoryStorage) SaveUsers(ctx context.Context, users ...*tg.User) error { - f.usersMux.Lock() - defer f.usersMux.Unlock() - - for _, u := range users { - f.users[u.GetID()] = u - } - - return nil -} - -// SaveChats implements InmemoryStorage. -func (f *InmemoryStorage) SaveChats(ctx context.Context, chats ...tg.FullChat) error { - f.chatsMux.Lock() - defer f.chatsMux.Unlock() - - for _, u := range chats { - f.chats[u.GetID()] = u - } - - return nil -} - -// FindUser implements InmemoryStorage. -func (f *InmemoryStorage) FindUser(ctx context.Context, id int64) (*tg.User, bool, error) { - f.usersMux.RLock() - defer f.usersMux.RUnlock() - - v, ok := f.users[id] - return v, ok, nil -} - -// FindChat implements InmemoryStorage. -func (f *InmemoryStorage) FindChat(ctx context.Context, id int64) (tg.FullChat, bool, error) { - f.chatsMux.RLock() - defer f.chatsMux.RUnlock() - - v, ok := f.chats[id] - return v, ok, nil -} diff --git a/internal/peers/peers.go b/internal/peers/peers.go deleted file mode 100644 index 068ed3b..0000000 --- a/internal/peers/peers.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package peers contains some helpers to work with peers and store them. -package peers diff --git a/internal/peers/storage.go b/internal/peers/storage.go deleted file mode 100644 index 4ea648b..0000000 --- a/internal/peers/storage.go +++ /dev/null @@ -1,72 +0,0 @@ -package peers - -import ( - "context" - - "go.uber.org/multierr" - - "github.com/gotd/td/telegram" - "github.com/gotd/td/tg" -) - -// Storage represents peer storage. -type Storage interface { - SaveUsers(ctx context.Context, users ...*tg.User) error - SaveChats(ctx context.Context, chats ...tg.FullChat) error - - FindUser(ctx context.Context, id int64) (*tg.User, bool, error) - FindChat(ctx context.Context, id int64) (tg.FullChat, bool, error) -} - -func save(ctx context.Context, s Storage, from interface { - MapChats() tg.ChatClassArray - MapUsers() tg.UserClassArray -}) error { - return multierr.Append( - s.SaveChats(ctx, from.MapChats().AppendOnlyFull(nil)...), - s.SaveUsers(ctx, from.MapUsers().AppendOnlyNotEmpty(nil)...), - ) -} - -// UpdateHook is update hook for Storage. -func UpdateHook(s Storage, next telegram.UpdateHandler) telegram.UpdateHandler { - return telegram.UpdateHandlerFunc(func(ctx context.Context, u tg.UpdatesClass) error { - var err error - switch v := u.(type) { - case *tg.UpdatesCombined: - err = save(ctx, s, v) - case *tg.Updates: - err = save(ctx, s, v) - } - return multierr.Append(err, next.Handle(ctx, u)) - }) -} - -// AccessHasher is implementation of updates.ChannelAccessHasher based on Storage. -type AccessHasher struct { - Storage Storage -} - -// SetChannelAccessHash implements updates.ChannelAccessHasher. -func (a AccessHasher) SetChannelAccessHash(userID, channelID, accessHash int64) error { - // TODO: update access hash? - return nil -} - -// GetChannelAccessHash implements updates.ChannelAccessHasher. -func (a AccessHasher) GetChannelAccessHash(userID, channelID int64) (accessHash int64, found bool, err error) { - v, ok, err := a.Storage.FindChat(context.TODO(), channelID) - if err != nil { - return 0, false, err - } - if !ok { - return 0, false, nil - } - nonForbidden, ok := v.(interface { - GetAccessHash() int64 - }) - if !ok { - return 0, false, nil - } - return nonForbidden.GetAccessHash(), true, nil -} diff --git a/internal/pool/client.go b/internal/pool/client.go index a8c3cd2..2708276 100644 --- a/internal/pool/client.go +++ b/internal/pool/client.go @@ -5,6 +5,8 @@ import ( "sync" "time" + "github.com/gotd/td/telegram" + "github.com/gotd/botapi/internal/botapi" ) @@ -14,6 +16,7 @@ type client struct { mux sync.Mutex api *botapi.BotAPI + client *telegram.Client token Token lastUsed time.Time } @@ -26,7 +29,7 @@ func (c *client) Deadline(deadline time.Time) bool { c.mux.Lock() defer c.mux.Unlock() - return c.lastUsed.Before(deadline) + return !c.lastUsed.IsZero() && c.lastUsed.Before(deadline) } func (c *client) Use(t time.Time) { diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 0c18bb9..2cc23f8 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -3,20 +3,23 @@ package pool import ( "context" - "crypto/sha256" - "fmt" + "os" + "path/filepath" "sync" "time" "github.com/go-faster/errors" + "go.etcd.io/bbolt" + "go.uber.org/multierr" "go.uber.org/zap" "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/peers" "github.com/gotd/td/telegram/updates" "github.com/gotd/td/tg" "github.com/gotd/botapi/internal/botapi" - "github.com/gotd/botapi/internal/peers" + "github.com/gotd/botapi/internal/botstorage" ) // Pool of clients. @@ -26,9 +29,10 @@ type Pool struct { debug bool log *zap.Logger - storage StateStorage - clientsMux sync.Mutex + statePath string + clients map[Token]*client + clientsMux sync.Mutex } func (p *Pool) tick(deadline time.Time) { @@ -77,68 +81,129 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAP c.Use(p.now()) return fn(c.api) } + + initializationResult := make(chan error, 1) + c, err := p.createClient(token, initializationResult) + if err != nil { + return errors.Wrap(err, "init") + } + + // Waiting for initialization. + select { + case err, ok := <-initializationResult: + if !ok { + if c.ctx != nil { + return c.ctx.Err() + } + return errors.New("unknown initialization error") + } + if err != nil { + return err + } + + p.clientsMux.Lock() + conflictingClient, ok := p.clients[token] + if ok { + // Existing conflicting client, so stopping current client. + c.Kill() + c = conflictingClient + } else { + p.clients[token] = c + } + p.clientsMux.Unlock() + + return fn(c.api) + case <-ctx.Done(): + return ctx.Err() + } +} + +func (p *Pool) RunGC(timeout time.Duration) { + timer := time.NewTicker(time.Second) + for now := range timer.C { + deadline := now.Add(-timeout) + p.tick(deadline) + } +} + +func (p *Pool) createClient(token Token, initializationResult chan<- error) (_ *client, rErr error) { log := p.log.Named("client").With(zap.Int("id", token.ID)) + db, err := bbolt.Open(filepath.Join(p.statePath), 0o666, bbolt.DefaultOptions) + if err != nil { + return nil, err + } + defer func() { + if rErr != nil { + multierr.AppendInto(&rErr, db.Close()) + } + }() + storage := botstorage.NewBBoltStorage(db) + var handler telegram.UpdateHandlerFunc = func(ctx context.Context, u tg.UpdatesClass) error { return nil } - peerStorage := peers.NewInmemoryStorage() + pClient := new(tg.Client) + peerManager := peers.NewManager(pClient, peers.Options{ + Storage: storage, + Cache: storage, + Logger: log.Named("peers"), + }) gaps := updates.New(updates.Config{ Handler: handler, - AccessHasher: peers.AccessHasher{ - Storage: peerStorage, + OnChannelTooLong: func(channelID int64) { + log.Warn("Got channel too long", zap.Int64("channel_id", channelID)) }, - Logger: log.Named("gaps"), + Storage: storage, + AccessHasher: peerManager, + Logger: log.Named("gaps"), }) options := telegram.Options{ - Logger: log.Named("client"), - UpdateHandler: peers.UpdateHook(peerStorage, gaps), - } - if p.storage != nil { - options.SessionStorage = clientStorage{ - id: fmt.Sprintf("%x:%x", token.ID, sha256.Sum256([]byte(token.Secret))), - storage: p.storage, - } + Logger: log.Named("client"), + UpdateHandler: peerManager.UpdateHook(gaps), + SessionStorage: storage, } tgClient := telegram.NewClient(p.appID, p.appHash, options) + // FIXME(tdakkota): fix this + *pClient = *tgClient.API() tgContext, tgCancel := context.WithCancel(context.Background()) - c = &client{ + c := &client{ ctx: tgContext, cancel: tgCancel, - api: botapi.NewBotAPI(tgClient, gaps, peerStorage, botapi.Options{ + api: botapi.NewBotAPI(tgClient, gaps, peerManager, botapi.Options{ Debug: p.debug, Logger: log.Named("botapi"), }), token: token, - lastUsed: p.now(), + lastUsed: time.Time{}, } // Wait for initialization. - initializationResult := make(chan error, 1) go func() { - defer close(initializationResult) - defer tgCancel() - defer func() { // Removing client from client list on close. p.clientsMux.Lock() - c, ok := p.clients[token] - if ok && c.api.Client() == tgClient { + found, ok := p.clients[token] + if ok && found.client == c.client { delete(p.clients, token) } p.clientsMux.Unlock() + // Kill client. + c.Kill() + // Stop waiting for result. + close(initializationResult) }() - if err := tgClient.Run(c.ctx, func(ctx context.Context) error { - status, err := tgClient.Auth().Status(ctx) + if err := c.client.Run(c.ctx, func(ctx context.Context) error { + status, err := c.client.Auth().Status(ctx) if err != nil { return err } if status.Authorized { // Ok. } else { - if _, err := tgClient.Auth().Bot(ctx, token.String()); err != nil { + if _, err := c.client.Auth().Bot(ctx, token.String()); err != nil { return err } } @@ -153,6 +218,8 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAP // Done. select { case initializationResult <- nil: + // Update lastUsed, because it is zero during initialization. + c.Use(p.now()) default: } @@ -162,73 +229,33 @@ func (p *Pool) Do(ctx context.Context, token Token, fn func(client *botapi.BotAP // Failed. select { case initializationResult <- err: + log.Warn("Initialize", zap.Error(err)) default: } } }() - // Waiting for initialization. - select { - case err := <-initializationResult: - if err != nil { - log.Warn("Initialize", zap.Error(err)) - return err - } - - p.clientsMux.Lock() - conflictingClient, ok := p.clients[token] - if ok { - // Existing conflicting client, so stopping current client. - tgCancel() - c = conflictingClient - } else { - p.clients[token] = c - } - p.clientsMux.Unlock() - - return fn(c.api) - case <-ctx.Done(): - return ctx.Err() - case <-tgContext.Done(): - return tgContext.Err() - } -} - -func (p *Pool) RunGC(timeout time.Duration) { - timer := time.NewTicker(time.Second) - for now := range timer.C { - deadline := now.Add(-timeout) - p.tick(deadline) - } + return c, nil } type Options struct { AppID int AppHash string Log *zap.Logger - Storage StateStorage Debug bool } -type StateStorage interface { - Store(ctx context.Context, id string, data []byte) error - Load(ctx context.Context, id string) ([]byte, error) -} - -func NewFileStorage(path string) StateStorage { - return &fileStorage{ - path: path, - } -} - -func NewPool(opt Options) (*Pool, error) { +func NewPool(statePath string, opt Options) (*Pool, error) { p := &Pool{ - appID: opt.AppID, - appHash: opt.AppHash, - debug: opt.Debug, - log: opt.Log, - clients: map[Token]*client{}, - storage: opt.Storage, + appID: opt.AppID, + appHash: opt.AppHash, + debug: opt.Debug, + log: opt.Log, + clients: map[Token]*client{}, + statePath: statePath, + } + if err := os.MkdirAll(statePath, 0o666); err != nil { + return nil, errors.Wrap(err, "create state dir") } return p, nil } diff --git a/internal/pool/storage.go b/internal/pool/storage.go deleted file mode 100644 index 848f498..0000000 --- a/internal/pool/storage.go +++ /dev/null @@ -1,89 +0,0 @@ -package pool - -import ( - "context" - "encoding/json" - "os" - "sync" - - "github.com/go-faster/errors" - - "github.com/gotd/td/session" -) - -type fileStorage struct { - path string - mux sync.Mutex -} - -type sessionFile struct { - Data map[string][]byte `json:"data"` -} - -func (s *fileStorage) Store(ctx context.Context, id string, data []byte) error { - s.mux.Lock() - defer s.mux.Unlock() - - var decoded sessionFile - - b, err := os.ReadFile(s.path) - if os.IsNotExist(err) || len(b) == 0 { - // Blank initial session. - } else if err == nil { - if err := json.Unmarshal(b, &decoded); err != nil { - return errors.Wrap(err, "unmarshal session file") - } - } - if decoded.Data == nil { - decoded.Data = map[string][]byte{} - } - - decoded.Data[id] = data - - if b, err = json.Marshal(&decoded); err != nil { - return err - } - - return os.WriteFile(s.path, b, 0o600) -} - -func (s *fileStorage) Load(ctx context.Context, id string) ([]byte, error) { - s.mux.Lock() - defer s.mux.Unlock() - - data, err := os.ReadFile(s.path) - if os.IsNotExist(err) || len(data) == 0 { - return nil, session.ErrNotFound - } - - var decoded sessionFile - if err := json.Unmarshal(data, &decoded); err != nil { - return nil, err - } - - if len(decoded.Data) == 0 { - return nil, session.ErrNotFound - } - - return decoded.Data[id], nil -} - -type clientStorage struct { - storage StateStorage - id string -} - -func (c clientStorage) LoadSession(ctx context.Context) ([]byte, error) { - data, err := c.storage.Load(ctx, c.id) - if err != nil { - return nil, err - } - if len(data) == 0 { - return nil, session.ErrNotFound - } - return data, nil -} - -func (c clientStorage) StoreSession(ctx context.Context, data []byte) error { - return c.storage.Store(ctx, c.id, data) -} From f4ac493aa691e707b10249d32134328773984bc4 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Fri, 17 Dec 2021 11:47:01 +0300 Subject: [PATCH 19/29] fix(botapi): set UserResolver for HTML, close bbolt --- cmd/botapi/main.go | 26 ++++++++++++++------------ internal/botapi/botapi.go | 2 +- internal/botapi/convert_message.go | 17 +++++------------ internal/botapi/errors.go | 5 +++++ internal/botapi/markup.go | 2 +- internal/botapi/peers.go | 1 + internal/botapi/send.go | 13 +++++++++++-- internal/botstorage/bbolt.go | 11 ++++++++--- internal/botstorage/botstorage.go | 2 ++ internal/pool/pool.go | 6 +++++- 10 files changed, 53 insertions(+), 32 deletions(-) create mode 100644 internal/botstorage/botstorage.go diff --git a/cmd/botapi/main.go b/cmd/botapi/main.go index 96f536f..1524a0a 100644 --- a/cmd/botapi/main.go +++ b/cmd/botapi/main.go @@ -76,12 +76,16 @@ func run(ctx context.Context) error { appHash = flag.String("api-hash", constant.TestAppHash, "The api_hash of application") addr = flag.String("addr", "localhost:8081", "http listen addr") keepalive = flag.Duration("keepalive", 5*time.Minute, "client keepalive") - statePath = flag.String("state", "", "path to state file (json)") + statePath = flag.String("state", "state", "path to state file (json)") debug = flag.Bool("debug", false, "enables debug mode") ) flag.Parse() - log, err := zap.NewDevelopment(zap.IncreaseLevel(zap.InfoLevel)) + level := zap.InfoLevel + if *debug { + level = zap.DebugLevel + } + log, err := zap.NewDevelopment(zap.IncreaseLevel(level)) if err != nil { return errors.Errorf("create logger: %w", err) } @@ -89,21 +93,15 @@ func run(ctx context.Context) error { _ = log.Sync() }() - var storage pool.StateStorage - if *statePath != "" { - storage = pool.NewFileStorage(*statePath) - } - log.Info("Creating pool", zap.Duration("keep_alive", *keepalive), zap.String("storage", *statePath), zap.Bool("debug", *debug), ) - p, err := pool.NewPool(pool.Options{ + p, err := pool.NewPool(*statePath, pool.Options{ AppID: *appID, AppHash: *appHash, Log: log.Named("pool"), - Storage: storage, Debug: *debug, }) if err != nil { @@ -121,12 +119,16 @@ func run(ctx context.Context) error { } method := chi.URLParam(r, "method") - log.Info("New request", zap.Int("bot_id", token.ID), zap.String("method", method)) - _ = p.Do(r.Context(), token, func(client *botapi.BotAPI) error { + log := log.With(zap.Int("bot_id", token.ID), zap.String("method", method)) + + log.Info("New request") + if err := p.Do(r.Context(), token, func(client *botapi.BotAPI) error { r.URL.Path = method oas.NewServer(client).ServeHTTP(w, r) return nil - }) + }); err != nil { + log.Warn("Do error", zap.Error(err)) + } }) return listen(ctx, *addr, r, log.Named("http")) diff --git a/internal/botapi/botapi.go b/internal/botapi/botapi.go index 92a48e1..394ff2d 100644 --- a/internal/botapi/botapi.go +++ b/internal/botapi/botapi.go @@ -5,11 +5,11 @@ import ( "context" "github.com/go-faster/errors" - "github.com/gotd/td/telegram/peers" "go.uber.org/zap" "github.com/gotd/td/telegram" "github.com/gotd/td/telegram/message" + "github.com/gotd/td/telegram/peers" "github.com/gotd/td/telegram/updates" "github.com/gotd/td/tg" diff --git a/internal/botapi/convert_message.go b/internal/botapi/convert_message.go index cd9b4b3..5b8366f 100644 --- a/internal/botapi/convert_message.go +++ b/internal/botapi/convert_message.go @@ -18,7 +18,7 @@ var maskCoordsNames = []string{"forehead", "eyes", "mouth", "chin"} func (b *BotAPI) convertToBotAPIEntities( ctx context.Context, entities []tg.MessageEntityClass, -) (r []oas.MessageEntity, _ error) { +) (r []oas.MessageEntity) { for _, entity := range entities { e := oas.MessageEntity{ Offset: entity.GetOffset(), @@ -53,6 +53,7 @@ func (b *BotAPI) convertToBotAPIEntities( user, err := b.resolveUserID(ctx, entity.UserID) if err == nil { e.User.SetTo(convertToUser(user)) + b.logger.Warn("Resolve user", zap.Int64("user_id", entity.UserID)) } case *tg.MessageEntityPhone: e.Type = oas.MessageEntityTypePhoneNumber @@ -66,7 +67,7 @@ func (b *BotAPI) convertToBotAPIEntities( r = append(r, e) } - return r, nil + return r } func (b *BotAPI) convertToBotAPIPhotoSizes(p tg.PhotoClass) (r []oas.PhotoSize) { @@ -395,11 +396,7 @@ func (b *BotAPI) convertMessageMedia(ctx context.Context, media tg.MessageMediaC } if e := results.SolutionEntities; len(e) > 0 { - explanationEntities, err := b.convertToBotAPIEntities(ctx, e) - if err != nil { - return errors.Wrap(err, "get entities") - } - resultPoll.ExplanationEntities = explanationEntities + resultPoll.ExplanationEntities = b.convertToBotAPIEntities(ctx, e) } // SAFETY: length equality checked above. @@ -516,11 +513,7 @@ func (b *BotAPI) convertPlainMessage(ctx context.Context, m *tg.Message) (r oas. r.Text.SetTo(text) } if len(m.Entities) > 0 { - entities, err := b.convertToBotAPIEntities(ctx, m.Entities) - if err != nil { - return oas.Message{}, errors.Wrap(err, "get entities") - } - r.Entities = entities + r.Entities = b.convertToBotAPIEntities(ctx, m.Entities) } if err := b.convertMessageMedia(ctx, m.Media, &r); err != nil { diff --git a/internal/botapi/errors.go b/internal/botapi/errors.go index 3980a5d..ece35e5 100644 --- a/internal/botapi/errors.go +++ b/internal/botapi/errors.go @@ -7,6 +7,7 @@ import ( "github.com/go-faster/errors" "github.com/go-faster/jx" + "go.uber.org/zap" "github.com/gotd/botapi/internal/oas" ) @@ -59,11 +60,15 @@ func errorOf(code int) oas.ErrorStatusCode { // NewError maps error to status code. func (b *BotAPI) NewError(ctx context.Context, err error) oas.ErrorStatusCode { + // TODO(tdakkota): pass request context info. + b.logger.Warn("Request error", zap.Error(err)) + var ( notImplemented *NotImplementedError peerNotFound *PeerNotFoundError badRequest *BadRequestError ) + // TODO(tdakkota): better error mapping. switch { case errors.As(err, ¬Implemented): return errorOf(http.StatusNotImplemented) diff --git a/internal/botapi/markup.go b/internal/botapi/markup.go index 719e5ab..c809205 100644 --- a/internal/botapi/markup.go +++ b/internal/botapi/markup.go @@ -4,9 +4,9 @@ import ( "context" "github.com/go-faster/errors" - "github.com/gotd/td/telegram/peers" "github.com/gotd/td/telegram/message/markup" + "github.com/gotd/td/telegram/peers" "github.com/gotd/td/tg" "github.com/gotd/botapi/internal/oas" diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go index 4ceec9d..92c9edd 100644 --- a/internal/botapi/peers.go +++ b/internal/botapi/peers.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/go-faster/errors" + "github.com/gotd/td/constant" "github.com/gotd/td/telegram/peers" "github.com/gotd/td/tg" diff --git a/internal/botapi/send.go b/internal/botapi/send.go index e38b594..b698413 100644 --- a/internal/botapi/send.go +++ b/internal/botapi/send.go @@ -94,9 +94,8 @@ func (b *BotAPI) SendMessage(ctx context.Context, req oas.SendMessage) (oas.Resu var resp tg.UpdatesClass if isParseModeSet { - // FIXME(tdakkota): set HTML user resolver. // FIXME(tdakkota): random_id unpacking. - resp, err = s.StyledText(ctx, html.String(nil, req.Text)) + resp, err = s.StyledText(ctx, html.String(b.peers.UserResolveHook(ctx), req.Text)) } else { // FIXME(tdakkota): get entities from request. resp, err = s.Text(ctx, req.Text) @@ -113,6 +112,16 @@ func (b *BotAPI) SendMessage(ctx context.Context, req oas.SendMessage) (oas.Resu Ok: true, }, nil } + if msg.PeerID == nil { + switch p := p.(type) { + case *tg.InputPeerChat: + msg.PeerID = &tg.PeerChat{ChatID: p.ChatID} + case *tg.InputPeerUser: + msg.PeerID = &tg.PeerUser{UserID: p.UserID} + case *tg.InputPeerChannel: + msg.PeerID = &tg.PeerChannel{ChannelID: p.ChannelID} + } + } resultMsg, err := b.convertPlainMessage(ctx, msg) if err != nil { diff --git a/internal/botstorage/bbolt.go b/internal/botstorage/bbolt.go index 4ea2b05..bf7cfd8 100644 --- a/internal/botstorage/bbolt.go +++ b/internal/botstorage/bbolt.go @@ -5,13 +5,14 @@ import ( "encoding/binary" "github.com/go-faster/errors" + "go.etcd.io/bbolt" + "go.uber.org/multierr" + "github.com/gotd/td/bin" "github.com/gotd/td/session" "github.com/gotd/td/telegram/peers" "github.com/gotd/td/telegram/updates" "github.com/gotd/td/tg" - "go.etcd.io/bbolt" - "go.uber.org/multierr" ) // BBoltStorage is bbolt-based storage. @@ -63,7 +64,7 @@ func parseInt(v []byte) (int64, bool) { if len(v) < 8 { return 0, false } - i := binary.LittleEndian.Uint64(v[:]) + i := binary.LittleEndian.Uint64(v) return int64(i), true } @@ -89,6 +90,10 @@ func (b *BBoltStorage) Find(_ context.Context, key peers.Key) (value peers.Value err = b.batchBucket(hashPrefix+key.Prefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { storageKey := formatInt(key.ID) val := b.Get(storageKey) + // Value not found. + if val == nil { + return nil + } id, ok := parseInt(val) if !ok { return multierr.Append(errors.Errorf("got invalid value %+x", val), b.Delete(storageKey)) diff --git a/internal/botstorage/botstorage.go b/internal/botstorage/botstorage.go new file mode 100644 index 0000000..1ab3367 --- /dev/null +++ b/internal/botstorage/botstorage.go @@ -0,0 +1,2 @@ +// Package botstorage contains gotd storage implementations for Telegram bots. +package botstorage diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 2cc23f8..5c9ae10 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -5,6 +5,7 @@ import ( "context" "os" "path/filepath" + "strconv" "sync" "time" @@ -129,7 +130,8 @@ func (p *Pool) RunGC(timeout time.Duration) { func (p *Pool) createClient(token Token, initializationResult chan<- error) (_ *client, rErr error) { log := p.log.Named("client").With(zap.Int("id", token.ID)) - db, err := bbolt.Open(filepath.Join(p.statePath), 0o666, bbolt.DefaultOptions) + dbPath := filepath.Join(p.statePath, strconv.Itoa(token.ID)) + db, err := bbolt.Open(dbPath, 0o666, bbolt.DefaultOptions) if err != nil { return nil, err } @@ -175,6 +177,7 @@ func (p *Pool) createClient(token Token, initializationResult chan<- error) (_ * Debug: p.debug, Logger: log.Named("botapi"), }), + client: tgClient, token: token, lastUsed: time.Time{}, } @@ -191,6 +194,7 @@ func (p *Pool) createClient(token Token, initializationResult chan<- error) (_ * p.clientsMux.Unlock() // Kill client. c.Kill() + _ = db.Close() // Stop waiting for result. close(initializationResult) }() From e2799ae45144a932bd27d0a2dd3eb699ceb63865 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Fri, 17 Dec 2021 11:51:34 +0300 Subject: [PATCH 20/29] chore(pool): add bbolt extension for db files --- internal/pool/pool.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 5c9ae10..d34dc85 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -3,9 +3,9 @@ package pool import ( "context" + "fmt" "os" "path/filepath" - "strconv" "sync" "time" @@ -130,7 +130,7 @@ func (p *Pool) RunGC(timeout time.Duration) { func (p *Pool) createClient(token Token, initializationResult chan<- error) (_ *client, rErr error) { log := p.log.Named("client").With(zap.Int("id", token.ID)) - dbPath := filepath.Join(p.statePath, strconv.Itoa(token.ID)) + dbPath := filepath.Join(p.statePath, fmt.Sprintf("%d.bbolt", token.ID)) db, err := bbolt.Open(dbPath, 0o666, bbolt.DefaultOptions) if err != nil { return nil, err From 654a6218a5214b4becb15af1dff3458083778952 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Fri, 17 Dec 2021 11:53:48 +0300 Subject: [PATCH 21/29] chore: add bot db files to gitignore --- .gitignore | 3 +++ go.mod | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4f5c733..70af200 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ *.iml out gen + +# Bot db files +state/*.bbolt diff --git a/go.mod b/go.mod index cf29c0a..5e44405 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( go.opentelemetry.io/otel v1.3.0 go.opentelemetry.io/otel/metric v0.26.0 go.opentelemetry.io/otel/trace v1.3.0 - go.uber.org/atomic v1.9.0 go.uber.org/multierr v1.7.0 go.uber.org/zap v1.19.1 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c @@ -37,6 +36,7 @@ require ( github.com/mattn/go-colorable v0.1.11 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/atomic v1.9.0 // indirect golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect From c9cdaf3bbab5b0c92243a78791d036ef1a3b9c01 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Fri, 17 Dec 2021 15:44:11 +0300 Subject: [PATCH 22/29] feat(botapi): implement LeaveChat --- internal/botapi/chat.go | 27 +++++++++- internal/botapi/command.go | 6 +-- internal/botapi/errors.go | 87 +++++++++++++++++++++++++------- internal/botapi/live_location.go | 16 ++++++ internal/botapi/markup.go | 2 +- internal/botapi/message.go | 10 ---- internal/botapi/peers.go | 29 +++++------ internal/botapi/send.go | 4 +- 8 files changed, 130 insertions(+), 51 deletions(-) diff --git a/internal/botapi/chat.go b/internal/botapi/chat.go index a15cc2d..2cd6d5a 100644 --- a/internal/botapi/chat.go +++ b/internal/botapi/chat.go @@ -3,6 +3,9 @@ package botapi import ( "context" + "github.com/go-faster/errors" + "github.com/gotd/td/telegram/peers" + "github.com/gotd/botapi/internal/oas" ) @@ -63,5 +66,27 @@ func (b *BotAPI) SetChatTitle(ctx context.Context, req oas.SetChatTitle) (oas.Re // LeaveChat implements oas.Handler. func (b *BotAPI) LeaveChat(ctx context.Context, req oas.LeaveChat) (oas.Result, error) { - return oas.Result{}, &NotImplementedError{} + p, err := b.resolveID(ctx, req.ChatID) + if err != nil { + return oas.Result{}, errors.Wrap(err, "resolve chatID") + } + switch p := p.(type) { + case peers.Chat: + if p.Left() { + break + } + if err := p.Leave(ctx, false); err != nil { + return oas.Result{}, err + } + case peers.Channel: + if p.Left() { + break + } + if err := p.Leave(ctx); err != nil { + return oas.Result{}, err + } + default: + return oas.Result{}, &BadRequestError{Message: "Bad Request: chat not found"} + } + return resultOK(true), nil } diff --git a/internal/botapi/command.go b/internal/botapi/command.go index 39e637f..7fadcb9 100644 --- a/internal/botapi/command.go +++ b/internal/botapi/command.go @@ -32,14 +32,14 @@ func (b *BotAPI) convertToBotCommandScopeClass( if err != nil { return nil, errors.Wrap(err, "resolve chatID") } - return &tg.BotCommandScopePeer{Peer: p}, nil + return &tg.BotCommandScopePeer{Peer: p.InputPeer()}, nil case oas.BotCommandScopeChatAdministratorsBotCommandScope: chatID := scope.BotCommandScopeChatAdministrators.ChatID p, err := b.resolveID(ctx, chatID) if err != nil { return nil, errors.Wrap(err, "resolve chatID") } - return &tg.BotCommandScopePeerAdmins{Peer: p}, nil + return &tg.BotCommandScopePeerAdmins{Peer: p.InputPeer()}, nil case oas.BotCommandScopeChatMemberBotCommandScope: userID := scope.BotCommandScopeChatMember.UserID user, err := b.resolveUserID(ctx, userID) @@ -53,7 +53,7 @@ func (b *BotAPI) convertToBotCommandScopeClass( return nil, errors.Wrap(err, "resolve chatID") } return &tg.BotCommandScopePeerUser{ - Peer: p, + Peer: p.InputPeer(), UserID: user.AsInput(), }, nil default: diff --git a/internal/botapi/errors.go b/internal/botapi/errors.go index ece35e5..b1a849d 100644 --- a/internal/botapi/errors.go +++ b/internal/botapi/errors.go @@ -2,11 +2,12 @@ package botapi import ( "context" - "fmt" "net/http" "github.com/go-faster/errors" "github.com/go-faster/jx" + "github.com/gotd/td/telegram/peers" + "github.com/gotd/td/tgerr" "go.uber.org/zap" "github.com/gotd/botapi/internal/oas" @@ -25,19 +26,6 @@ func (n *NotImplementedError) Error() string { return n.Message } -// PeerNotFoundError reports that BotAPI cannot find this peer. -type PeerNotFoundError struct { - ID oas.ID -} - -// Error implements error. -func (p *PeerNotFoundError) Error() string { - if p.ID.IsString() { - return fmt.Sprintf("peer %q not found", p.ID.String) - } - return fmt.Sprintf("peer %d not found", p.ID.Int64) -} - // BadRequestError reports bad request. type BadRequestError struct { Message string @@ -49,11 +37,18 @@ func (p *BadRequestError) Error() string { } func errorOf(code int) oas.ErrorStatusCode { + return errorStatusCode(code, "") +} + +func errorStatusCode(code int, description string) oas.ErrorStatusCode { + if description == "" { + description = http.StatusText(code) + } return oas.ErrorStatusCode{ StatusCode: code, Response: oas.Error{ ErrorCode: code, - Description: http.StatusText(code), + Description: description, }, } } @@ -65,7 +60,7 @@ func (b *BotAPI) NewError(ctx context.Context, err error) oas.ErrorStatusCode { var ( notImplemented *NotImplementedError - peerNotFound *PeerNotFoundError + peerNotFound *peers.PeerNotFoundError badRequest *BadRequestError ) // TODO(tdakkota): better error mapping. @@ -73,9 +68,65 @@ func (b *BotAPI) NewError(ctx context.Context, err error) oas.ErrorStatusCode { case errors.As(err, ¬Implemented): return errorOf(http.StatusNotImplemented) case errors.As(err, &peerNotFound): - return errorOf(http.StatusNotFound) + return errorStatusCode(http.StatusBadRequest, "Bad Request: chat not found") case errors.As(err, &badRequest): - return errorOf(http.StatusBadRequest) + return errorStatusCode(http.StatusBadRequest, badRequest.Message) + } + + // See https://github.com/tdlib/telegram-bot-api/blob/90f52477814a2d8a08c9ffb1d780fd179815d715/telegram-bot-api/Client.cpp#L86. + if rpcErr, ok := tgerr.As(err); ok && rpcErr.Code == 400 { + var ( + errorCode = rpcErr.Code + errorMessage = rpcErr.Message + ) + switch rpcErr.Type { + case "MESSAGE_NOT_MODIFIED": + errorMessage = "message is not modified: specified new message content " + + "and reply markup are exactly the same as a current content " + + "and reply markup of the message" + case "WC_CONVERT_URL_INVALID", "EXTERNAL_URL_INVALID": + errorMessage = "Wrong HTTP URL specified" + case "WEBPAGE_CURL_FAILED": + errorMessage = "Failed to get HTTP URL content" + case "WEBPAGE_MEDIA_EMPTY": + errorMessage = "Wrong type of the web page content" + case "MEDIA_GROUPED_INVALID": + errorMessage = "Can't use the media of the specified type in the album" + case "REPLY_MARKUP_TOO_LONG": + errorMessage = "reply markup is too long" + case "INPUT_USER_DEACTIVATED": + errorCode = 403 + errorMessage = "Forbidden: user is deactivated" + case "USER_IS_BLOCKED": + errorCode = 403 + errorMessage = "bot was blocked by the user" + case "USER_ADMIN_INVALID": + errorCode = 400 + errorMessage = "user is an administrator of the chat" + case "File generation failed": + errorCode = 400 + errorMessage = "can't upload file by URL" + case "CHAT_ABOUT_NOT_MODIFIED": + errorCode = 400 + errorMessage = "chat description is not modified" + case "PACK_SHORT_NAME_INVALID": + errorCode = 400 + errorMessage = "invalid sticker set name is specified" + case "PACK_SHORT_NAME_OCCUPIED": + errorCode = 400 + errorMessage = "sticker set name is already occupied" + case "STICKER_EMOJI_INVALID": + errorCode = 400 + errorMessage = "invalid sticker emojis" + case "QUERY_ID_INVALID": + errorCode = 400 + errorMessage = "query is too old and response timeout expired or query ID is invalid" + case "MESSAGE_DELETE_FORBIDDEN": + errorCode = 400 + errorMessage = "message can't be deleted" + } + + return errorStatusCode(errorCode, errorMessage) } resp := errorOf(http.StatusInternalServerError) diff --git a/internal/botapi/live_location.go b/internal/botapi/live_location.go index d3aa314..d6596fd 100644 --- a/internal/botapi/live_location.go +++ b/internal/botapi/live_location.go @@ -1 +1,17 @@ package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// EditMessageLiveLocation implements oas.Handler. +func (b *BotAPI) EditMessageLiveLocation(ctx context.Context, req oas.EditMessageLiveLocation) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} + +// StopMessageLiveLocation implements oas.Handler. +func (b *BotAPI) StopMessageLiveLocation(ctx context.Context, req oas.StopMessageLiveLocation) (oas.Result, error) { + return oas.Result{}, &NotImplementedError{} +} diff --git a/internal/botapi/markup.go b/internal/botapi/markup.go index c809205..47b41c4 100644 --- a/internal/botapi/markup.go +++ b/internal/botapi/markup.go @@ -54,7 +54,7 @@ func (b *BotAPI) convertToTelegramInlineButton( Bot: user, }, nil default: - return nil, &BadRequestError{Message: "text buttons are unallowed in the inline keyboard"} + return nil, &BadRequestError{Message: "Text buttons are unallowed in the inline keyboard"} } } diff --git a/internal/botapi/message.go b/internal/botapi/message.go index 90684fe..2e31e89 100644 --- a/internal/botapi/message.go +++ b/internal/botapi/message.go @@ -21,11 +21,6 @@ func (b *BotAPI) EditMessageCaption(ctx context.Context, req oas.EditMessageCapt return oas.Result{}, &NotImplementedError{} } -// EditMessageLiveLocation implements oas.Handler. -func (b *BotAPI) EditMessageLiveLocation(ctx context.Context, req oas.EditMessageLiveLocation) (oas.Result, error) { - return oas.Result{}, &NotImplementedError{} -} - // EditMessageMedia implements oas.Handler. func (b *BotAPI) EditMessageMedia(ctx context.Context, req oas.EditMessageMedia) (oas.Result, error) { return oas.Result{}, &NotImplementedError{} @@ -46,11 +41,6 @@ func (b *BotAPI) ForwardMessage(ctx context.Context, req oas.ForwardMessage) (oa return oas.ResultMessage{}, &NotImplementedError{} } -// StopMessageLiveLocation implements oas.Handler. -func (b *BotAPI) StopMessageLiveLocation(ctx context.Context, req oas.StopMessageLiveLocation) (oas.Result, error) { - return oas.Result{}, &NotImplementedError{} -} - // StopPoll implements oas.Handler. func (b *BotAPI) StopPoll(ctx context.Context, req oas.StopPoll) (oas.ResultPoll, error) { return oas.ResultPoll{}, &NotImplementedError{} diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go index 92c9edd..65bf4c4 100644 --- a/internal/botapi/peers.go +++ b/internal/botapi/peers.go @@ -52,18 +52,23 @@ func (b *BotAPI) getChatByPeer(ctx context.Context, p tg.PeerClass) (oas.Chat, e return r, nil } -func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (tg.InputPeerClass, error) { +func (b *BotAPI) resolveUserID(ctx context.Context, id int64) (*tg.User, error) { + user, err := b.peers.GetUser(ctx, &tg.InputUser{UserID: id}) + if err != nil { + return nil, errors.Wrapf(err, "find user: %d", id) + } + return user.Raw(), nil +} + +func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (peers.Peer, error) { if id.IsInt64() { return b.resolveIntID(ctx, id.Int64) } username := id.String - if len(username) < 1 { - return nil, &PeerNotFoundError{ID: id} - } switch { case len(username) < 1: - return nil, &PeerNotFoundError{ID: id} + return nil, &BadRequestError{Message: "Bad Request: chat_id is empty"} case username[0] != '@': parsedID, err := strconv.ParseInt(username, 10, 64) if err != nil { @@ -78,21 +83,13 @@ func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (tg.InputPeerClass, e if err != nil { return nil, errors.Wrapf(err, "resolve %q", username) } - return p.InputPeer(), nil -} - -func (b *BotAPI) resolveUserID(ctx context.Context, id int64) (*tg.User, error) { - user, err := b.peers.GetUser(ctx, &tg.InputUser{UserID: id}) - if err != nil { - return nil, errors.Wrapf(err, "find user: %d", id) - } - return user.Raw(), nil + return p, nil } -func (b *BotAPI) resolveIntID(ctx context.Context, id int64) (tg.InputPeerClass, error) { +func (b *BotAPI) resolveIntID(ctx context.Context, id int64) (peers.Peer, error) { p, err := b.peers.ResolveTDLibID(ctx, constant.TDLibPeerID(id)) if err != nil { return nil, errors.Wrapf(err, "find peer %d", id) } - return p.InputPeer(), nil + return p, nil } diff --git a/internal/botapi/send.go b/internal/botapi/send.go index b698413..aaa8956 100644 --- a/internal/botapi/send.go +++ b/internal/botapi/send.go @@ -73,7 +73,7 @@ func (b *BotAPI) SendMessage(ctx context.Context, req oas.SendMessage) (oas.Resu if err != nil { return oas.ResultMessage{}, errors.Wrap(err, "resolve chatID") } - s := &b.sender.To(p).Builder + s := &b.sender.To(p.InputPeer()).Builder if v, ok := req.DisableWebPagePreview.Get(); ok && v { s = s.NoWebpage() @@ -113,7 +113,7 @@ func (b *BotAPI) SendMessage(ctx context.Context, req oas.SendMessage) (oas.Resu }, nil } if msg.PeerID == nil { - switch p := p.(type) { + switch p := p.InputPeer().(type) { case *tg.InputPeerChat: msg.PeerID = &tg.PeerChat{ChatID: p.ChatID} case *tg.InputPeerUser: From def35b9899f306eaaf39d21af34fcccb62667e1b Mon Sep 17 00:00:00 2001 From: tdakkota Date: Fri, 17 Dec 2021 16:24:04 +0300 Subject: [PATCH 23/29] fix(botstorage): do not acquire write lock on reading Prevents deadlock. We acquired write lock to delete invalid entries before, now we just ignore them. --- internal/botstorage/bbolt.go | 52 +++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/internal/botstorage/bbolt.go b/internal/botstorage/bbolt.go index bf7cfd8..444ce17 100644 --- a/internal/botstorage/bbolt.go +++ b/internal/botstorage/bbolt.go @@ -5,14 +5,12 @@ import ( "encoding/binary" "github.com/go-faster/errors" - "go.etcd.io/bbolt" - "go.uber.org/multierr" - "github.com/gotd/td/bin" "github.com/gotd/td/session" "github.com/gotd/td/telegram/peers" "github.com/gotd/td/telegram/updates" "github.com/gotd/td/tg" + "go.etcd.io/bbolt" ) // BBoltStorage is bbolt-based storage. @@ -68,6 +66,16 @@ func parseInt(v []byte) (int64, bool) { return int64(i), true } +func (b *BBoltStorage) viewBucket(prefix string, cb func(b *bbolt.Bucket, tx *bbolt.Tx) error) error { + return b.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(prefix)) + if b == nil { + return nil + } + return cb(b, tx) + }) +} + func (b *BBoltStorage) batchBucket(prefix string, cb func(b *bbolt.Bucket, tx *bbolt.Tx) error) error { return b.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte(prefix)) @@ -87,7 +95,7 @@ func (b *BBoltStorage) Save(_ context.Context, key peers.Key, value peers.Value) // Find implements peers.Storage. func (b *BBoltStorage) Find(_ context.Context, key peers.Key) (value peers.Value, found bool, err error) { - err = b.batchBucket(hashPrefix+key.Prefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + err = b.viewBucket(hashPrefix+key.Prefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { storageKey := formatInt(key.ID) val := b.Get(storageKey) // Value not found. @@ -96,7 +104,7 @@ func (b *BBoltStorage) Find(_ context.Context, key peers.Key) (value peers.Value } id, ok := parseInt(val) if !ok { - return multierr.Append(errors.Errorf("got invalid value %+x", val), b.Delete(storageKey)) + return errors.Errorf("got invalid value %+x", val) } value.AccessHash = id found = true @@ -158,7 +166,7 @@ func getMTProtoKey(b *bbolt.Bucket, id int64, d bin.Decoder) (bool, error) { buf := bin.Buffer{Buf: data} if err := d.Decode(&buf); err != nil { // Ignore decode errors. - return false, b.Delete(key) + return false, nil } return true, nil } @@ -190,7 +198,7 @@ func (b *BBoltStorage) SaveUserFulls(_ context.Context, users ...*tg.UserFull) e // FindUser implements BBoltStorage. func (b *BBoltStorage) FindUser(_ context.Context, id int64) (e *tg.User, found bool, err error) { // Use batch to delete invalid keys. - err = b.batchBucket(userPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + err = b.viewBucket(userPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.User) found, err = getMTProtoKey(b, id, e) return err @@ -201,7 +209,7 @@ func (b *BBoltStorage) FindUser(_ context.Context, id int64) (e *tg.User, found // FindUserFull implements BBoltStorage. func (b *BBoltStorage) FindUserFull(_ context.Context, id int64) (e *tg.UserFull, found bool, err error) { // Use batch to delete invalid keys. - err = b.batchBucket(userFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + err = b.viewBucket(userFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.UserFull) found, err = getMTProtoKey(b, id, e) return err @@ -235,7 +243,7 @@ func (b *BBoltStorage) SaveChatFulls(_ context.Context, chats ...*tg.ChatFull) e // FindChat implements BBoltStorage. func (b *BBoltStorage) FindChat(_ context.Context, id int64) (e *tg.Chat, found bool, err error) { - err = b.batchBucket(chatPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + err = b.viewBucket(chatPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.Chat) found, err = getMTProtoKey(b, id, e) return err @@ -245,7 +253,7 @@ func (b *BBoltStorage) FindChat(_ context.Context, id int64) (e *tg.Chat, found // FindChatFull implements BBoltStorage. func (b *BBoltStorage) FindChatFull(_ context.Context, id int64) (e *tg.ChatFull, found bool, err error) { - err = b.batchBucket(chatFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + err = b.viewBucket(chatFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.ChatFull) found, err = getMTProtoKey(b, id, e) return err @@ -279,7 +287,7 @@ func (b *BBoltStorage) SaveChannelFulls(_ context.Context, channels ...*tg.Chann // FindChannel implements BBoltStorage. func (b *BBoltStorage) FindChannel(_ context.Context, id int64) (e *tg.Channel, found bool, err error) { - err = b.batchBucket(channelPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + err = b.viewBucket(channelPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.Channel) found, err = getMTProtoKey(b, id, e) return err @@ -289,7 +297,7 @@ func (b *BBoltStorage) FindChannel(_ context.Context, id int64) (e *tg.Channel, // FindChannelFull implements BBoltStorage. func (b *BBoltStorage) FindChannelFull(_ context.Context, id int64) (e *tg.ChannelFull, found bool, err error) { - err = b.batchBucket(channelFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + err = b.viewBucket(channelFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.ChannelFull) found, err = getMTProtoKey(b, id, e) return err @@ -315,7 +323,7 @@ func getStateField(b *bbolt.Bucket, key string, v *int) (bool, error) { } p, ok := parseInt(data) if !ok { - return false, multierr.Append(errors.Errorf("decode %q", key), b.Delete(bytesKey)) + return false, errors.Errorf("decode %q", key) } *v = int(p) return true, nil @@ -323,7 +331,7 @@ func getStateField(b *bbolt.Bucket, key string, v *int) (bool, error) { // GetState implements updates.StateStorage. func (b *BBoltStorage) GetState(_ int64) (state updates.State, found bool, err error) { - err = b.batchBucket(statePrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + err = b.viewBucket(statePrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { if ok, err := getStateField(b, ptsKey, &state.Pts); err != nil || !ok { return err } @@ -420,8 +428,9 @@ func (b *BBoltStorage) SetDateSeq(_ int64, date, seq int) error { // GetChannelPts implements updates.StateStorage. func (b *BBoltStorage) GetChannelPts(_, channelID int64) (pts int, found bool, err error) { - err = b.batchBucket(channelsPtsPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { - b.Get(formatInt(channelID)) + err = b.viewBucket(channelsPtsPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + v, ok := parseInt(b.Get(formatInt(channelID))) + pts, found = int(v), ok return nil }) return pts, found, err @@ -436,19 +445,18 @@ func (b *BBoltStorage) SetChannelPts(_, channelID int64, pts int) error { // ForEachChannels implements updates.StateStorage. func (b *BBoltStorage) ForEachChannels(_ int64, f func(channelID int64, pts int) error) error { - return b.batchBucket(channelsPtsPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + return b.viewBucket(channelsPtsPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { return b.ForEach(func(k, v []byte) error { channelID, ok := parseInt(k) if !ok { - // Delete invalid entries. - return b.Delete(k) + // Ignore invalid entries. + return nil } pts, ok := parseInt(v) if !ok { - // Delete invalid entries. - return b.Delete(v) + // Ignore invalid entries. + return nil } - return f(channelID, int(pts)) }) }) From dbd6f8609a7fec97dc1b83ee62a663d66d0414fc Mon Sep 17 00:00:00 2001 From: tdakkota Date: Fri, 17 Dec 2021 16:24:33 +0300 Subject: [PATCH 24/29] fix(botapi): return chat not found if int is invalid --- internal/botapi/chat.go | 3 ++- internal/botapi/errors.go | 7 ++++++- internal/botapi/peers.go | 2 +- internal/botstorage/bbolt.go | 35 ++++++++++++++++++----------------- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/internal/botapi/chat.go b/internal/botapi/chat.go index 2cd6d5a..8156884 100644 --- a/internal/botapi/chat.go +++ b/internal/botapi/chat.go @@ -4,6 +4,7 @@ import ( "context" "github.com/go-faster/errors" + "github.com/gotd/td/telegram/peers" "github.com/gotd/botapi/internal/oas" @@ -86,7 +87,7 @@ func (b *BotAPI) LeaveChat(ctx context.Context, req oas.LeaveChat) (oas.Result, return oas.Result{}, err } default: - return oas.Result{}, &BadRequestError{Message: "Bad Request: chat not found"} + return oas.Result{}, chatNotFound() } return resultOK(true), nil } diff --git a/internal/botapi/errors.go b/internal/botapi/errors.go index b1a849d..5c37898 100644 --- a/internal/botapi/errors.go +++ b/internal/botapi/errors.go @@ -6,9 +6,10 @@ import ( "github.com/go-faster/errors" "github.com/go-faster/jx" + "go.uber.org/zap" + "github.com/gotd/td/telegram/peers" "github.com/gotd/td/tgerr" - "go.uber.org/zap" "github.com/gotd/botapi/internal/oas" ) @@ -53,6 +54,10 @@ func errorStatusCode(code int, description string) oas.ErrorStatusCode { } } +func chatNotFound() *BadRequestError { + return &BadRequestError{Message: "Bad Request: chat not found"} +} + // NewError maps error to status code. func (b *BotAPI) NewError(ctx context.Context, err error) oas.ErrorStatusCode { // TODO(tdakkota): pass request context info. diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go index 65bf4c4..02cfa3c 100644 --- a/internal/botapi/peers.go +++ b/internal/botapi/peers.go @@ -72,7 +72,7 @@ func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (peers.Peer, error) { case username[0] != '@': parsedID, err := strconv.ParseInt(username, 10, 64) if err != nil { - return nil, errors.Wrap(err, "parse id") + return nil, chatNotFound() } return b.resolveIntID(ctx, parsedID) } diff --git a/internal/botstorage/bbolt.go b/internal/botstorage/bbolt.go index 444ce17..e4cab2f 100644 --- a/internal/botstorage/bbolt.go +++ b/internal/botstorage/bbolt.go @@ -5,12 +5,13 @@ import ( "encoding/binary" "github.com/go-faster/errors" + "go.etcd.io/bbolt" + "github.com/gotd/td/bin" "github.com/gotd/td/session" "github.com/gotd/td/telegram/peers" "github.com/gotd/td/telegram/updates" "github.com/gotd/td/tg" - "go.etcd.io/bbolt" ) // BBoltStorage is bbolt-based storage. @@ -157,18 +158,18 @@ func putMTProtoKey(b *bbolt.Bucket, e interface { return nil } -func getMTProtoKey(b *bbolt.Bucket, id int64, d bin.Decoder) (bool, error) { +func getMTProtoKey(b *bbolt.Bucket, id int64, d bin.Decoder) bool { key := formatInt(id) data := b.Get(key) if data == nil { - return false, nil + return false } buf := bin.Buffer{Buf: data} if err := d.Decode(&buf); err != nil { // Ignore decode errors. - return false, nil + return false } - return true, nil + return true } // SaveUsers implements BBoltStorage. @@ -200,8 +201,8 @@ func (b *BBoltStorage) FindUser(_ context.Context, id int64) (e *tg.User, found // Use batch to delete invalid keys. err = b.viewBucket(userPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.User) - found, err = getMTProtoKey(b, id, e) - return err + found = getMTProtoKey(b, id, e) + return nil }) return e, found, err } @@ -211,8 +212,8 @@ func (b *BBoltStorage) FindUserFull(_ context.Context, id int64) (e *tg.UserFull // Use batch to delete invalid keys. err = b.viewBucket(userFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.UserFull) - found, err = getMTProtoKey(b, id, e) - return err + found = getMTProtoKey(b, id, e) + return nil }) return e, found, err } @@ -245,8 +246,8 @@ func (b *BBoltStorage) SaveChatFulls(_ context.Context, chats ...*tg.ChatFull) e func (b *BBoltStorage) FindChat(_ context.Context, id int64) (e *tg.Chat, found bool, err error) { err = b.viewBucket(chatPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.Chat) - found, err = getMTProtoKey(b, id, e) - return err + found = getMTProtoKey(b, id, e) + return nil }) return e, found, err } @@ -255,8 +256,8 @@ func (b *BBoltStorage) FindChat(_ context.Context, id int64) (e *tg.Chat, found func (b *BBoltStorage) FindChatFull(_ context.Context, id int64) (e *tg.ChatFull, found bool, err error) { err = b.viewBucket(chatFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.ChatFull) - found, err = getMTProtoKey(b, id, e) - return err + found = getMTProtoKey(b, id, e) + return nil }) return e, found, err } @@ -289,8 +290,8 @@ func (b *BBoltStorage) SaveChannelFulls(_ context.Context, channels ...*tg.Chann func (b *BBoltStorage) FindChannel(_ context.Context, id int64) (e *tg.Channel, found bool, err error) { err = b.viewBucket(channelPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.Channel) - found, err = getMTProtoKey(b, id, e) - return err + found = getMTProtoKey(b, id, e) + return nil }) return e, found, err } @@ -299,8 +300,8 @@ func (b *BBoltStorage) FindChannel(_ context.Context, id int64) (e *tg.Channel, func (b *BBoltStorage) FindChannelFull(_ context.Context, id int64) (e *tg.ChannelFull, found bool, err error) { err = b.viewBucket(channelFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { e = new(tg.ChannelFull) - found, err = getMTProtoKey(b, id, e) - return err + found = getMTProtoKey(b, id, e) + return nil }) return e, found, err } From fd4584a9513ef838f92f80c9848db066fcde92f5 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 18 Dec 2021 22:51:15 +0300 Subject: [PATCH 25/29] fix(botapi): user mapping --- go.mod | 2 +- go.sum | 4 +-- internal/botapi/botapi.go | 10 ++----- internal/botapi/botapi_test.go | 27 +++++++++++++++++ internal/botapi/chat.go | 14 +++------ internal/botapi/me.go | 4 +-- internal/botapi/me_test.go | 54 ++++++++++++++++++++++++++++++++++ internal/pool/pool.go | 6 ++-- 8 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 internal/botapi/botapi_test.go create mode 100644 internal/botapi/me_test.go diff --git a/go.mod b/go.mod index 5e44405..2ecbeac 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-faster/errors v0.5.0 github.com/go-faster/jx v0.25.0 github.com/google/uuid v1.3.0 - github.com/gotd/td v0.54.0-alpha.1 + github.com/gotd/td v0.54.0-alpha.2 github.com/ogen-go/ogen v0.0.0-20211211145630-e16dcf3319e7 github.com/stretchr/testify v1.7.0 go.etcd.io/bbolt v1.3.6 diff --git a/go.sum b/go.sum index 67b7c2e..828c052 100644 --- a/go.sum +++ b/go.sum @@ -69,8 +69,8 @@ github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= github.com/gotd/neo v0.1.4 h1:av+c/4R+3B/eAlr+Bz++q+/DOuGKz+sfwJXmPXRbU/s= github.com/gotd/neo v0.1.4/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= -github.com/gotd/td v0.54.0-alpha.1 h1:Zfj67YVVDC0CbTbNZYH1G9KxTTCUbjBKaAwAl6qrLBU= -github.com/gotd/td v0.54.0-alpha.1/go.mod h1:Ce9Z+p9SqI6W+N9mwIQ+IqHEdNIif5khUmydmWkDB44= +github.com/gotd/td v0.54.0-alpha.2 h1:UbRCH5D1aURaZSEQ2M4uu01QuBtRA+4Wocf78DwwG30= +github.com/gotd/td v0.54.0-alpha.2/go.mod h1:Ce9Z+p9SqI6W+N9mwIQ+IqHEdNIif5khUmydmWkDB44= github.com/gotd/tl v0.4.0/go.mod h1:CMIcjPWFS4qxxJ+1Ce7U/ilbtPrkoVo/t8uhN5Y/D7c= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/internal/botapi/botapi.go b/internal/botapi/botapi.go index 394ff2d..3719bfa 100644 --- a/internal/botapi/botapi.go +++ b/internal/botapi/botapi.go @@ -7,7 +7,6 @@ import ( "github.com/go-faster/errors" "go.uber.org/zap" - "github.com/gotd/td/telegram" "github.com/gotd/td/telegram/message" "github.com/gotd/td/telegram/peers" "github.com/gotd/td/telegram/updates" @@ -18,9 +17,8 @@ import ( // BotAPI is Bot API implementation. type BotAPI struct { - client *telegram.Client - raw *tg.Client - gaps *updates.Manager + raw *tg.Client + gaps *updates.Manager sender *message.Sender peers *peers.Manager @@ -31,16 +29,14 @@ type BotAPI struct { // NewBotAPI creates new BotAPI instance. func NewBotAPI( - client *telegram.Client, + raw *tg.Client, gaps *updates.Manager, peer *peers.Manager, opts Options, ) *BotAPI { opts.setDefaults() - raw := client.API() return &BotAPI{ - client: client, raw: raw, gaps: gaps, sender: message.NewSender(raw), diff --git a/internal/botapi/botapi_test.go b/internal/botapi/botapi_test.go new file mode 100644 index 0000000..e4f71cc --- /dev/null +++ b/internal/botapi/botapi_test.go @@ -0,0 +1,27 @@ +package botapi + +import ( + "testing" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/telegram/peers" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgmock" +) + +func testBotAPI(t *testing.T) (*tgmock.Mock, *BotAPI) { + m := tgmock.New(t) + raw := tg.NewClient(m) + logger := zaptest.NewLogger(t) + return m, NewBotAPI( + raw, + nil, + peers.Options{ + Logger: logger.Named("peers"), + }.Build(raw), + Options{ + Logger: logger.Named("botapi"), + }, + ) +} diff --git a/internal/botapi/chat.go b/internal/botapi/chat.go index 8156884..7a978cb 100644 --- a/internal/botapi/chat.go +++ b/internal/botapi/chat.go @@ -5,8 +5,6 @@ import ( "github.com/go-faster/errors" - "github.com/gotd/td/telegram/peers" - "github.com/gotd/botapi/internal/oas" ) @@ -72,14 +70,10 @@ func (b *BotAPI) LeaveChat(ctx context.Context, req oas.LeaveChat) (oas.Result, return oas.Result{}, errors.Wrap(err, "resolve chatID") } switch p := p.(type) { - case peers.Chat: - if p.Left() { - break - } - if err := p.Leave(ctx, false); err != nil { - return oas.Result{}, err - } - case peers.Channel: + case interface { + Left() bool + Leave(ctx context.Context) error + }: if p.Left() { break } diff --git a/internal/botapi/me.go b/internal/botapi/me.go index d97dfe6..457e72f 100644 --- a/internal/botapi/me.go +++ b/internal/botapi/me.go @@ -16,9 +16,9 @@ func convertToUser(user *tg.User) oas.User { LastName: optString(user.GetLastName), Username: optString(user.GetUsername), LanguageCode: optString(user.GetLangCode), - CanJoinGroups: oas.NewOptBool(user.BotNochats), + CanJoinGroups: oas.NewOptBool(!user.BotNochats), CanReadAllGroupMessages: oas.NewOptBool(user.BotChatHistory), - SupportsInlineQueries: oas.NewOptBool(user.BotInlinePlaceholder == ""), + SupportsInlineQueries: oas.NewOptBool(user.BotInlinePlaceholder != ""), } } diff --git a/internal/botapi/me_test.go b/internal/botapi/me_test.go new file mode 100644 index 0000000..ac8fd90 --- /dev/null +++ b/internal/botapi/me_test.go @@ -0,0 +1,54 @@ +package botapi + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/oas" +) + +func TestBotAPI_GetMe(t *testing.T) { + a := require.New(t) + ctx := context.Background() + mock, api := testBotAPI(t) + + user := &tg.User{ + Self: true, + Bot: true, + ID: 10, + AccessHash: 10, + FirstName: "Elsa", + LastName: "Jean", + Username: "thebot", + BotInfoVersion: 1, + BotInlinePlaceholder: "aboba", + } + user.SetFlags() + + mock.ExpectCall(&tg.UsersGetUsersRequest{ + ID: []tg.InputUserClass{&tg.InputUserSelf{}}, + }).ThenResult(&tg.UserClassVector{Elems: []tg.UserClass{user}}) + result, err := api.GetMe(ctx) + a.NoError(err) + a.Equal(oas.ResultUser{ + Result: oas.OptUser{ + Value: oas.User{ + ID: user.ID, + IsBot: user.Bot, + FirstName: user.FirstName, + LastName: oas.NewOptString(user.LastName), + Username: oas.NewOptString(user.Username), + LanguageCode: oas.OptString{}, + CanJoinGroups: oas.NewOptBool(true), + CanReadAllGroupMessages: oas.NewOptBool(false), + SupportsInlineQueries: oas.NewOptBool(user.BotInlinePlaceholder != ""), + }, + Set: true, + }, + Ok: true, + }, result) +} diff --git a/internal/pool/pool.go b/internal/pool/pool.go index d34dc85..319b37a 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -146,11 +146,11 @@ func (p *Pool) createClient(token Token, initializationResult chan<- error) (_ * return nil } pClient := new(tg.Client) - peerManager := peers.NewManager(pClient, peers.Options{ + peerManager := peers.Options{ Storage: storage, Cache: storage, Logger: log.Named("peers"), - }) + }.Build(pClient) gaps := updates.New(updates.Config{ Handler: handler, OnChannelTooLong: func(channelID int64) { @@ -173,7 +173,7 @@ func (p *Pool) createClient(token Token, initializationResult chan<- error) (_ * c := &client{ ctx: tgContext, cancel: tgCancel, - api: botapi.NewBotAPI(tgClient, gaps, peerManager, botapi.Options{ + api: botapi.NewBotAPI(tgClient.API(), gaps, peerManager, botapi.Options{ Debug: p.debug, Logger: log.Named("botapi"), }), From f30c777b2ba9d824bbed7bcf7912179979f71c5c Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 18 Dec 2021 23:42:20 +0300 Subject: [PATCH 26/29] feat(botapi): implement more methods --- internal/botapi/botapi_test.go | 445 ++++++++++++++++++++++++++++ internal/botapi/chat.go | 31 +- internal/botapi/chat_member.go | 9 +- internal/botapi/chat_member_test.go | 26 ++ internal/botapi/chat_test.go | 78 +++++ internal/botapi/peers.go | 26 ++ 6 files changed, 601 insertions(+), 14 deletions(-) create mode 100644 internal/botapi/chat_member_test.go create mode 100644 internal/botapi/chat_test.go diff --git a/internal/botapi/botapi_test.go b/internal/botapi/botapi_test.go index e4f71cc..026291f 100644 --- a/internal/botapi/botapi_test.go +++ b/internal/botapi/botapi_test.go @@ -1,13 +1,19 @@ package botapi import ( + "context" "testing" + "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" + "github.com/gotd/td/constant" + "github.com/gotd/td/telegram/peers" "github.com/gotd/td/tg" "github.com/gotd/td/tgmock" + + "github.com/gotd/botapi/internal/oas" ) func testBotAPI(t *testing.T) (*tgmock.Mock, *BotAPI) { @@ -19,9 +25,448 @@ func testBotAPI(t *testing.T) (*tgmock.Mock, *BotAPI) { nil, peers.Options{ Logger: logger.Named("peers"), + Cache: new(peers.InmemoryCache), }.Build(raw), Options{ Logger: logger.Named("botapi"), }, ) } + +func chatID() int64 { + var id constant.TDLibPeerID + id.Chat(10) + return int64(id) +} + +func TestUnimplemented(t *testing.T) { + ctx := context.Background() + a := require.New(t) + b := BotAPI{} + + { + _, err := b.AddStickerToSet(ctx, oas.AddStickerToSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.AnswerCallbackQuery(ctx, oas.AnswerCallbackQuery{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.AnswerInlineQuery(ctx, oas.AnswerInlineQuery{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.AnswerPreCheckoutQuery(ctx, oas.AnswerPreCheckoutQuery{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.AnswerShippingQuery(ctx, oas.AnswerShippingQuery{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.ApproveChatJoinRequest(ctx, oas.ApproveChatJoinRequest{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.BanChatMember(ctx, oas.BanChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.BanChatSenderChat(ctx, oas.BanChatSenderChat{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.CopyMessage(ctx, oas.CopyMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.CreateChatInviteLink(ctx, oas.CreateChatInviteLink{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.CreateNewStickerSet(ctx, oas.CreateNewStickerSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeclineChatJoinRequest(ctx, oas.DeclineChatJoinRequest{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteChatPhoto(ctx, oas.DeleteChatPhoto{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteChatStickerSet(ctx, oas.DeleteChatStickerSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteMessage(ctx, oas.DeleteMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteStickerFromSet(ctx, oas.DeleteStickerFromSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteWebhook(ctx, oas.OptDeleteWebhook{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditChatInviteLink(ctx, oas.EditChatInviteLink{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageCaption(ctx, oas.EditMessageCaption{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageLiveLocation(ctx, oas.EditMessageLiveLocation{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageMedia(ctx, oas.EditMessageMedia{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageReplyMarkup(ctx, oas.EditMessageReplyMarkup{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageText(ctx, oas.EditMessageText{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.ExportChatInviteLink(ctx, oas.ExportChatInviteLink{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.ForwardMessage(ctx, oas.ForwardMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetChat(ctx, oas.GetChat{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetChatAdministrators(ctx, oas.GetChatAdministrators{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetChatMember(ctx, oas.GetChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetFile(ctx, oas.GetFile{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetGameHighScores(ctx, oas.GetGameHighScores{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetStickerSet(ctx, oas.GetStickerSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetUpdates(ctx, oas.OptGetUpdates{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetUserProfilePhotos(ctx, oas.GetUserProfilePhotos{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetWebhookInfo(ctx) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.PinChatMessage(ctx, oas.PinChatMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.PromoteChatMember(ctx, oas.PromoteChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.RestrictChatMember(ctx, oas.RestrictChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.RevokeChatInviteLink(ctx, oas.RevokeChatInviteLink{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendAnimation(ctx, oas.SendAnimation{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendAudio(ctx, oas.SendAudio{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendChatAction(ctx, oas.SendChatAction{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendContact(ctx, oas.SendContact{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendDice(ctx, oas.SendDice{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendDocument(ctx, oas.SendDocument{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendGame(ctx, oas.SendGame{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendInvoice(ctx, oas.SendInvoice{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendLocation(ctx, oas.SendLocation{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendMediaGroup(ctx, oas.SendMediaGroup{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendPhoto(ctx, oas.SendPhoto{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendPoll(ctx, oas.SendPoll{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendSticker(ctx, oas.SendSticker{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendVenue(ctx, oas.SendVenue{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendVideo(ctx, oas.SendVideo{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendVideoNote(ctx, oas.SendVideoNote{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendVoice(ctx, oas.SendVoice{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetChatAdministratorCustomTitle(ctx, oas.SetChatAdministratorCustomTitle{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetChatPermissions(ctx, oas.SetChatPermissions{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetChatPhoto(ctx, oas.SetChatPhoto{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetChatStickerSet(ctx, oas.SetChatStickerSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetGameScore(ctx, oas.SetGameScore{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetPassportDataErrors(ctx, oas.SetPassportDataErrors{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetStickerPositionInSet(ctx, oas.SetStickerPositionInSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetStickerSetThumb(ctx, oas.SetStickerSetThumb{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetWebhook(ctx, oas.SetWebhook{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.StopMessageLiveLocation(ctx, oas.StopMessageLiveLocation{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.StopPoll(ctx, oas.StopPoll{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UnbanChatMember(ctx, oas.UnbanChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UnbanChatSenderChat(ctx, oas.UnbanChatSenderChat{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UnpinAllChatMessages(ctx, oas.UnpinAllChatMessages{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UnpinChatMessage(ctx, oas.UnpinChatMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UploadStickerFile(ctx, oas.UploadStickerFile{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } +} diff --git a/internal/botapi/chat.go b/internal/botapi/chat.go index 7a978cb..60fae29 100644 --- a/internal/botapi/chat.go +++ b/internal/botapi/chat.go @@ -40,7 +40,14 @@ func (b *BotAPI) SetChatAdministratorCustomTitle(ctx context.Context, req oas.Se // SetChatDescription implements oas.Handler. func (b *BotAPI) SetChatDescription(ctx context.Context, req oas.SetChatDescription) (oas.Result, error) { - return oas.Result{}, &NotImplementedError{} + p, err := b.resolveChatID(ctx, req.ChatID) + if err != nil { + return oas.Result{}, errors.Wrap(err, "resolve chatID") + } + if err := p.SetDescription(ctx, req.Description.Value); err != nil { + return oas.Result{}, err + } + return resultOK(true), nil } // SetChatPermissions implements oas.Handler. @@ -60,28 +67,26 @@ func (b *BotAPI) SetChatStickerSet(ctx context.Context, req oas.SetChatStickerSe // SetChatTitle implements oas.Handler. func (b *BotAPI) SetChatTitle(ctx context.Context, req oas.SetChatTitle) (oas.Result, error) { - return oas.Result{}, &NotImplementedError{} + p, err := b.resolveChatID(ctx, req.ChatID) + if err != nil { + return oas.Result{}, errors.Wrap(err, "resolve chatID") + } + if err := p.SetTitle(ctx, req.Title); err != nil { + return oas.Result{}, err + } + return resultOK(true), nil } // LeaveChat implements oas.Handler. func (b *BotAPI) LeaveChat(ctx context.Context, req oas.LeaveChat) (oas.Result, error) { - p, err := b.resolveID(ctx, req.ChatID) + p, err := b.resolveChatID(ctx, req.ChatID) if err != nil { return oas.Result{}, errors.Wrap(err, "resolve chatID") } - switch p := p.(type) { - case interface { - Left() bool - Leave(ctx context.Context) error - }: - if p.Left() { - break - } + if !p.Left() { if err := p.Leave(ctx); err != nil { return oas.Result{}, err } - default: - return oas.Result{}, chatNotFound() } return resultOK(true), nil } diff --git a/internal/botapi/chat_member.go b/internal/botapi/chat_member.go index 300b2ae..d412806 100644 --- a/internal/botapi/chat_member.go +++ b/internal/botapi/chat_member.go @@ -28,7 +28,14 @@ func (b *BotAPI) GetChatMember(ctx context.Context, req oas.GetChatMember) (oas. // GetChatMemberCount implements oas.Handler. func (b *BotAPI) GetChatMemberCount(ctx context.Context, req oas.GetChatMemberCount) (oas.ResultInt, error) { - return oas.ResultInt{}, &NotImplementedError{} + ch, err := b.resolveChatID(ctx, req.ChatID) + if err != nil { + return oas.ResultInt{}, err + } + return oas.ResultInt{ + Result: oas.NewOptInt(ch.ParticipantsCount()), + Ok: true, + }, nil } // PromoteChatMember implements oas.Handler. diff --git a/internal/botapi/chat_member_test.go b/internal/botapi/chat_member_test.go new file mode 100644 index 0000000..d799fb6 --- /dev/null +++ b/internal/botapi/chat_member_test.go @@ -0,0 +1,26 @@ +package botapi + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gotd/td/tgmock" + + "github.com/gotd/botapi/internal/oas" +) + +func TestBotAPI_GetChatMemberCount(t *testing.T) { + ctx := context.Background() + testWithChat(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + r, err := api.GetChatMemberCount(ctx, oas.GetChatMemberCount{ + ChatID: oas.NewInt64ID(chatID()), + }) + a.NoError(err) + a.Equal(oas.ResultInt{ + Result: oas.NewOptInt(10), + Ok: true, + }, r) + }) +} diff --git a/internal/botapi/chat_test.go b/internal/botapi/chat_test.go new file mode 100644 index 0000000..11a178c --- /dev/null +++ b/internal/botapi/chat_test.go @@ -0,0 +1,78 @@ +package botapi + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgmock" + + "github.com/gotd/botapi/internal/oas" +) + +func testWithChat(t *testing.T, cb func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI)) { + a := require.New(t) + + mock, api := testBotAPI(t) + a.NoError(api.peers.Apply(context.Background(), nil, []tg.ChatClass{ + &tg.Chat{ID: 10, ParticipantsCount: 10}, + })) + + cb(a, mock, api) +} + +func TestBotAPI_SetChatDescription(t *testing.T) { + ctx := context.Background() + testWithChat(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + mock.ExpectCall(&tg.MessagesEditChatAboutRequest{ + Peer: &tg.InputPeerChat{ChatID: 10}, + About: "", + }).ThenTrue() + _, err := api.SetChatDescription(ctx, oas.SetChatDescription{ + ChatID: oas.NewInt64ID(chatID()), + Description: oas.OptString{}, + }) + a.NoError(err) + + mock.ExpectCall(&tg.MessagesEditChatAboutRequest{ + Peer: &tg.InputPeerChat{ChatID: 10}, + About: "aboba", + }).ThenTrue() + _, err = api.SetChatDescription(ctx, oas.SetChatDescription{ + ChatID: oas.NewInt64ID(chatID()), + Description: oas.NewOptString("aboba"), + }) + a.NoError(err) + }) +} + +func TestBotAPI_SetChatTitle(t *testing.T) { + ctx := context.Background() + testWithChat(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + mock.ExpectCall(&tg.MessagesEditChatTitleRequest{ + ChatID: 10, + Title: "aboba", + }).ThenResult(&tg.Updates{}) + _, err := api.SetChatTitle(ctx, oas.SetChatTitle{ + ChatID: oas.NewInt64ID(chatID()), + Title: "aboba", + }) + a.NoError(err) + }) +} + +func TestBotAPI_LeaveChat(t *testing.T) { + ctx := context.Background() + testWithChat(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + mock.ExpectCall(&tg.MessagesDeleteChatUserRequest{ + ChatID: 10, + UserID: &tg.InputUserSelf{}, + }).ThenResult(&tg.Updates{}) + _, err := api.LeaveChat(ctx, oas.LeaveChat{ + ChatID: oas.NewInt64ID(chatID()), + }) + a.NoError(err) + }) +} diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go index 02cfa3c..0fd22a1 100644 --- a/internal/botapi/peers.go +++ b/internal/botapi/peers.go @@ -60,6 +60,32 @@ func (b *BotAPI) resolveUserID(ctx context.Context, id int64) (*tg.User, error) return user.Raw(), nil } +type Chat interface { + peers.Peer + Left() bool + ParticipantsCount() int + Leave(ctx context.Context) error + SetTitle(ctx context.Context, title string) error + SetDescription(ctx context.Context, about string) error +} + +var _ = []Chat{ + peers.Chat{}, + peers.Channel{}, +} + +func (b *BotAPI) resolveChatID(ctx context.Context, id oas.ID) (Chat, error) { + p, err := b.resolveID(ctx, id) + if err != nil { + return nil, err + } + ch, ok := p.(Chat) + if !ok { + return nil, chatNotFound() + } + return ch, nil +} + func (b *BotAPI) resolveID(ctx context.Context, id oas.ID) (peers.Peer, error) { if id.IsInt64() { return b.resolveIntID(ctx, id.Int64) From 6e8376cbe8d1adeced24b1dd35e4f8728e7f48dc Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 19 Dec 2021 15:44:01 +0300 Subject: [PATCH 27/29] test(botapi): add more tests --- internal/botapi/botapi_test.go | 472 +++--------------------- internal/botapi/chat_member_test.go | 4 +- internal/botapi/chat_test.go | 27 +- internal/botapi/command_test.go | 217 +++++++++++ internal/botapi/convert_message_test.go | 111 ++++++ internal/botapi/me_test.go | 13 +- internal/botapi/peers.go | 1 + internal/botapi/unimplemented_test.go | 442 ++++++++++++++++++++++ 8 files changed, 833 insertions(+), 454 deletions(-) create mode 100644 internal/botapi/command_test.go create mode 100644 internal/botapi/convert_message_test.go create mode 100644 internal/botapi/unimplemented_test.go diff --git a/internal/botapi/botapi_test.go b/internal/botapi/botapi_test.go index 026291f..b48c965 100644 --- a/internal/botapi/botapi_test.go +++ b/internal/botapi/botapi_test.go @@ -1,10 +1,8 @@ package botapi import ( - "context" "testing" - "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" "github.com/gotd/td/constant" @@ -33,440 +31,56 @@ func testBotAPI(t *testing.T) (*tgmock.Mock, *BotAPI) { ) } -func chatID() int64 { +func testChatID() int64 { var id constant.TDLibPeerID - id.Chat(10) + id.Chat(testChat().ID) return int64(id) } -func TestUnimplemented(t *testing.T) { - ctx := context.Background() - a := require.New(t) - b := BotAPI{} - - { - _, err := b.AddStickerToSet(ctx, oas.AddStickerToSet{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.AnswerCallbackQuery(ctx, oas.AnswerCallbackQuery{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.AnswerInlineQuery(ctx, oas.AnswerInlineQuery{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.AnswerPreCheckoutQuery(ctx, oas.AnswerPreCheckoutQuery{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.AnswerShippingQuery(ctx, oas.AnswerShippingQuery{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.ApproveChatJoinRequest(ctx, oas.ApproveChatJoinRequest{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.BanChatMember(ctx, oas.BanChatMember{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.BanChatSenderChat(ctx, oas.BanChatSenderChat{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.CopyMessage(ctx, oas.CopyMessage{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.CreateChatInviteLink(ctx, oas.CreateChatInviteLink{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.CreateNewStickerSet(ctx, oas.CreateNewStickerSet{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.DeclineChatJoinRequest(ctx, oas.DeclineChatJoinRequest{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.DeleteChatPhoto(ctx, oas.DeleteChatPhoto{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.DeleteChatStickerSet(ctx, oas.DeleteChatStickerSet{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.DeleteMessage(ctx, oas.DeleteMessage{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.DeleteStickerFromSet(ctx, oas.DeleteStickerFromSet{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.DeleteWebhook(ctx, oas.OptDeleteWebhook{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.EditChatInviteLink(ctx, oas.EditChatInviteLink{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.EditMessageCaption(ctx, oas.EditMessageCaption{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.EditMessageLiveLocation(ctx, oas.EditMessageLiveLocation{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.EditMessageMedia(ctx, oas.EditMessageMedia{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.EditMessageReplyMarkup(ctx, oas.EditMessageReplyMarkup{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.EditMessageText(ctx, oas.EditMessageText{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.ExportChatInviteLink(ctx, oas.ExportChatInviteLink{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.ForwardMessage(ctx, oas.ForwardMessage{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.GetChat(ctx, oas.GetChat{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.GetChatAdministrators(ctx, oas.GetChatAdministrators{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.GetChatMember(ctx, oas.GetChatMember{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.GetFile(ctx, oas.GetFile{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.GetGameHighScores(ctx, oas.GetGameHighScores{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.GetStickerSet(ctx, oas.GetStickerSet{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.GetUpdates(ctx, oas.OptGetUpdates{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.GetUserProfilePhotos(ctx, oas.GetUserProfilePhotos{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.GetWebhookInfo(ctx) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.PinChatMessage(ctx, oas.PinChatMessage{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.PromoteChatMember(ctx, oas.PromoteChatMember{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.RestrictChatMember(ctx, oas.RestrictChatMember{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.RevokeChatInviteLink(ctx, oas.RevokeChatInviteLink{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendAnimation(ctx, oas.SendAnimation{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendAudio(ctx, oas.SendAudio{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendChatAction(ctx, oas.SendChatAction{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendContact(ctx, oas.SendContact{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendDice(ctx, oas.SendDice{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendDocument(ctx, oas.SendDocument{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendGame(ctx, oas.SendGame{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendInvoice(ctx, oas.SendInvoice{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendLocation(ctx, oas.SendLocation{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendMediaGroup(ctx, oas.SendMediaGroup{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendPhoto(ctx, oas.SendPhoto{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendPoll(ctx, oas.SendPoll{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendSticker(ctx, oas.SendSticker{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendVenue(ctx, oas.SendVenue{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendVideo(ctx, oas.SendVideo{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendVideoNote(ctx, oas.SendVideoNote{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SendVoice(ctx, oas.SendVoice{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SetChatAdministratorCustomTitle(ctx, oas.SetChatAdministratorCustomTitle{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SetChatPermissions(ctx, oas.SetChatPermissions{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SetChatPhoto(ctx, oas.SetChatPhoto{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SetChatStickerSet(ctx, oas.SetChatStickerSet{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SetGameScore(ctx, oas.SetGameScore{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SetPassportDataErrors(ctx, oas.SetPassportDataErrors{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SetStickerPositionInSet(ctx, oas.SetStickerPositionInSet{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SetStickerSetThumb(ctx, oas.SetStickerSetThumb{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.SetWebhook(ctx, oas.SetWebhook{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.StopMessageLiveLocation(ctx, oas.StopMessageLiveLocation{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.StopPoll(ctx, oas.StopPoll{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.UnbanChatMember(ctx, oas.UnbanChatMember{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - - { - _, err := b.UnbanChatSenderChat(ctx, oas.UnbanChatSenderChat{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } +func testUser() *tg.User { + u := &tg.User{ + Self: true, + Bot: true, + ID: 10, + AccessHash: 10, + FirstName: "Elsa", + LastName: "Jean", + Username: "thebot", + BotInfoVersion: 1, + BotInlinePlaceholder: "aboba", + } + u.SetFlags() + return u +} - { - _, err := b.UnpinAllChatMessages(ctx, oas.UnpinAllChatMessages{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } +func testChat() *tg.Chat { + u := &tg.Chat{ + Noforwards: true, + ID: 10, + Title: "I hate mondays", + ParticipantsCount: 10, + Date: int(10), + Version: 1, + Photo: &tg.ChatPhotoEmpty{}, + } + u.SetFlags() + return u +} - { - _, err := b.UnpinChatMessage(ctx, oas.UnpinChatMessage{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) +func testCommands() []tg.BotCommand { + return []tg.BotCommand{ + { + Command: "freeburger", + Description: "trolling", + }, } +} - { - _, err := b.UploadStickerFile(ctx, oas.UploadStickerFile{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) +func testCommandsBotAPI() []oas.BotCommand { + return []oas.BotCommand{ + { + Command: "freeburger", + Description: "trolling", + }, } } diff --git a/internal/botapi/chat_member_test.go b/internal/botapi/chat_member_test.go index d799fb6..ee4d1b3 100644 --- a/internal/botapi/chat_member_test.go +++ b/internal/botapi/chat_member_test.go @@ -13,9 +13,9 @@ import ( func TestBotAPI_GetChatMemberCount(t *testing.T) { ctx := context.Background() - testWithChat(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { r, err := api.GetChatMemberCount(ctx, oas.GetChatMemberCount{ - ChatID: oas.NewInt64ID(chatID()), + ChatID: oas.NewInt64ID(testChatID()), }) a.NoError(err) a.Equal(oas.ResultInt{ diff --git a/internal/botapi/chat_test.go b/internal/botapi/chat_test.go index 11a178c..fb86c31 100644 --- a/internal/botapi/chat_test.go +++ b/internal/botapi/chat_test.go @@ -12,26 +12,31 @@ import ( "github.com/gotd/botapi/internal/oas" ) -func testWithChat(t *testing.T, cb func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI)) { +func testWithCache(t *testing.T, cb func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI)) { a := require.New(t) mock, api := testBotAPI(t) - a.NoError(api.peers.Apply(context.Background(), nil, []tg.ChatClass{ - &tg.Chat{ID: 10, ParticipantsCount: 10}, - })) + a.NoError(api.peers.Apply(context.Background(), + []tg.UserClass{ + testUser(), + }, + []tg.ChatClass{ + testChat(), + }, + )) cb(a, mock, api) } func TestBotAPI_SetChatDescription(t *testing.T) { ctx := context.Background() - testWithChat(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { mock.ExpectCall(&tg.MessagesEditChatAboutRequest{ Peer: &tg.InputPeerChat{ChatID: 10}, About: "", }).ThenTrue() _, err := api.SetChatDescription(ctx, oas.SetChatDescription{ - ChatID: oas.NewInt64ID(chatID()), + ChatID: oas.NewInt64ID(testChatID()), Description: oas.OptString{}, }) a.NoError(err) @@ -41,7 +46,7 @@ func TestBotAPI_SetChatDescription(t *testing.T) { About: "aboba", }).ThenTrue() _, err = api.SetChatDescription(ctx, oas.SetChatDescription{ - ChatID: oas.NewInt64ID(chatID()), + ChatID: oas.NewInt64ID(testChatID()), Description: oas.NewOptString("aboba"), }) a.NoError(err) @@ -50,13 +55,13 @@ func TestBotAPI_SetChatDescription(t *testing.T) { func TestBotAPI_SetChatTitle(t *testing.T) { ctx := context.Background() - testWithChat(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { mock.ExpectCall(&tg.MessagesEditChatTitleRequest{ ChatID: 10, Title: "aboba", }).ThenResult(&tg.Updates{}) _, err := api.SetChatTitle(ctx, oas.SetChatTitle{ - ChatID: oas.NewInt64ID(chatID()), + ChatID: oas.NewInt64ID(testChatID()), Title: "aboba", }) a.NoError(err) @@ -65,13 +70,13 @@ func TestBotAPI_SetChatTitle(t *testing.T) { func TestBotAPI_LeaveChat(t *testing.T) { ctx := context.Background() - testWithChat(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { mock.ExpectCall(&tg.MessagesDeleteChatUserRequest{ ChatID: 10, UserID: &tg.InputUserSelf{}, }).ThenResult(&tg.Updates{}) _, err := api.LeaveChat(ctx, oas.LeaveChat{ - ChatID: oas.NewInt64ID(chatID()), + ChatID: oas.NewInt64ID(testChatID()), }) a.NoError(err) }) diff --git a/internal/botapi/command_test.go b/internal/botapi/command_test.go new file mode 100644 index 0000000..b0331d6 --- /dev/null +++ b/internal/botapi/command_test.go @@ -0,0 +1,217 @@ +package botapi + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgmock" + + "github.com/gotd/botapi/internal/oas" +) + +func TestBotAPI_convertToBotCommandScopeClass(t *testing.T) { + newOASscope := func(typ oas.BotCommandScopeType) *oas.BotCommandScope { + return &oas.BotCommandScope{ + Type: typ, + BotCommandScopeDefault: oas.BotCommandScopeDefault{}, + BotCommandScopeAllPrivateChats: oas.BotCommandScopeAllPrivateChats{}, + BotCommandScopeAllGroupChats: oas.BotCommandScopeAllGroupChats{}, + BotCommandScopeAllChatAdministrators: oas.BotCommandScopeAllChatAdministrators{}, + BotCommandScopeChat: oas.BotCommandScopeChat{}, + BotCommandScopeChatAdministrators: oas.BotCommandScopeChatAdministrators{}, + BotCommandScopeChatMember: oas.BotCommandScopeChatMember{}, + } + } + + tests := []struct { + name string + input *oas.BotCommandScope + want tg.BotCommandScopeClass + wantErr bool + }{ + { + "Nil", + nil, + &tg.BotCommandScopeDefault{}, + false, + }, + { + "", + newOASscope(oas.BotCommandScopeDefaultBotCommandScope), + &tg.BotCommandScopeDefault{}, + false, + }, + { + "", + newOASscope(oas.BotCommandScopeAllPrivateChatsBotCommandScope), + &tg.BotCommandScopeUsers{}, + false, + }, + { + "", + newOASscope(oas.BotCommandScopeAllGroupChatsBotCommandScope), + &tg.BotCommandScopeChats{}, + false, + }, + { + "", + newOASscope(oas.BotCommandScopeAllChatAdministratorsBotCommandScope), + &tg.BotCommandScopeChatAdmins{}, + false, + }, + { + "", + &oas.BotCommandScope{ + Type: oas.BotCommandScopeChatBotCommandScope, + BotCommandScopeChat: oas.BotCommandScopeChat{ + ChatID: oas.NewInt64ID(testChatID()), + }, + }, + &tg.BotCommandScopePeer{Peer: testChat().AsInputPeer()}, + false, + }, + { + "", + &oas.BotCommandScope{ + Type: oas.BotCommandScopeChatAdministratorsBotCommandScope, + BotCommandScopeChatAdministrators: oas.BotCommandScopeChatAdministrators{ + ChatID: oas.NewInt64ID(testChatID()), + }, + }, + &tg.BotCommandScopePeerAdmins{Peer: testChat().AsInputPeer()}, + false, + }, + { + "", + &oas.BotCommandScope{ + Type: oas.BotCommandScopeChatMemberBotCommandScope, + BotCommandScopeChatMember: oas.BotCommandScopeChatMember{ + ChatID: oas.NewInt64ID(testChatID()), + UserID: testUser().ID, + }, + }, + &tg.BotCommandScopePeerUser{ + Peer: testChat().AsInputPeer(), + UserID: testUser().AsInput(), + }, + false, + }, + { + "UnknownType", + newOASscope("aboba"), + nil, + true, + }, + } + for _, tt := range tests { + if tt.name == "" { + if tt.wantErr { + tt.name = "Error" + } else { + tt.name = string(tt.input.Type) + } + } + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + v, err := api.convertToBotCommandScopeClass(ctx, tt.input) + if tt.wantErr { + a.Error(err) + return + } + a.NoError(err) + a.Equal(tt.want, v) + }) + }) + } +} + +func TestBotAPI_GetMyCommands(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + mock.ExpectCall(&tg.BotsGetBotCommandsRequest{ + Scope: &tg.BotCommandScopeDefault{}, + LangCode: "ru", + }).ThenResult(&tg.BotCommandVector{Elems: testCommands()}) + commands, err := api.GetMyCommands(ctx, oas.NewOptGetMyCommands(oas.GetMyCommands{ + Scope: nil, + LanguageCode: oas.NewOptString("ru"), + })) + a.NoError(err) + a.Equal(testCommandsBotAPI(), commands.Result) + + mock.ExpectCall(&tg.BotsGetBotCommandsRequest{ + Scope: &tg.BotCommandScopeUsers{}, + LangCode: "ru", + }).ThenResult(&tg.BotCommandVector{Elems: testCommands()}) + commands, err = api.GetMyCommands(ctx, oas.NewOptGetMyCommands(oas.GetMyCommands{ + Scope: &oas.BotCommandScope{ + Type: oas.BotCommandScopeAllPrivateChatsBotCommandScope, + }, + LanguageCode: oas.NewOptString("ru"), + })) + a.NoError(err) + a.Equal(testCommandsBotAPI(), commands.Result) + }) +} + +func TestBotAPI_SetMyCommands(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + mock.ExpectCall(&tg.BotsSetBotCommandsRequest{ + Scope: &tg.BotCommandScopeDefault{}, + LangCode: "ru", + Commands: testCommands(), + }).ThenTrue() + _, err := api.SetMyCommands(ctx, oas.SetMyCommands{ + Scope: nil, + LanguageCode: oas.NewOptString("ru"), + Commands: testCommandsBotAPI(), + }) + a.NoError(err) + + mock.ExpectCall(&tg.BotsSetBotCommandsRequest{ + Scope: &tg.BotCommandScopeUsers{}, + LangCode: "ru", + Commands: testCommands(), + }).ThenTrue() + _, err = api.SetMyCommands(ctx, oas.SetMyCommands{ + Scope: &oas.BotCommandScope{ + Type: oas.BotCommandScopeAllPrivateChatsBotCommandScope, + }, + LanguageCode: oas.NewOptString("ru"), + Commands: testCommandsBotAPI(), + }) + a.NoError(err) + }) +} + +func TestBotAPI_DeleteMyCommands(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + mock.ExpectCall(&tg.BotsResetBotCommandsRequest{ + Scope: &tg.BotCommandScopeDefault{}, + LangCode: "ru", + }).ThenTrue() + _, err := api.DeleteMyCommands(ctx, oas.NewOptDeleteMyCommands(oas.DeleteMyCommands{ + Scope: nil, + LanguageCode: oas.NewOptString("ru"), + })) + a.NoError(err) + + mock.ExpectCall(&tg.BotsResetBotCommandsRequest{ + Scope: &tg.BotCommandScopeUsers{}, + LangCode: "ru", + }).ThenTrue() + _, err = api.DeleteMyCommands(ctx, oas.NewOptDeleteMyCommands(oas.DeleteMyCommands{ + Scope: &oas.BotCommandScope{ + Type: oas.BotCommandScopeAllPrivateChatsBotCommandScope, + }, + LanguageCode: oas.NewOptString("ru"), + })) + a.NoError(err) + }) +} diff --git a/internal/botapi/convert_message_test.go b/internal/botapi/convert_message_test.go new file mode 100644 index 0000000..885812d --- /dev/null +++ b/internal/botapi/convert_message_test.go @@ -0,0 +1,111 @@ +package botapi + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgmock" + + "github.com/gotd/botapi/internal/oas" +) + +func TestBotAPI_convertToBotAPIEntities(t *testing.T) { + tests := []struct { + name string + input tg.MessageEntityClass + wantR oas.MessageEntity + }{ + { + "Mention", + &tg.MessageEntityMention{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeMention, Offset: 1, Length: 10}, + }, + { + "Hashtag", + &tg.MessageEntityHashtag{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeHashtag, Offset: 1, Length: 10}, + }, + { + "BotCommand", + &tg.MessageEntityBotCommand{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeBotCommand, Offset: 1, Length: 10}, + }, + { + "URL", + &tg.MessageEntityURL{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeURL, Offset: 1, Length: 10}, + }, + { + "Email", + &tg.MessageEntityEmail{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeEmail, Offset: 1, Length: 10}, + }, + { + "Bold", + &tg.MessageEntityBold{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeBold, Offset: 1, Length: 10}, + }, + { + "Italic", + &tg.MessageEntityItalic{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeItalic, Offset: 1, Length: 10}, + }, + { + "Code", + &tg.MessageEntityCode{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeCode, Offset: 1, Length: 10}, + }, + { + "Pre", + &tg.MessageEntityPre{Offset: 1, Length: 10, Language: "python"}, + oas.MessageEntity{Type: oas.MessageEntityTypePre, Offset: 1, Length: 10, + Language: oas.NewOptString("python")}, + }, + { + "TextURL", + &tg.MessageEntityTextURL{Offset: 1, Length: 10, URL: "https://ya.ru"}, + oas.MessageEntity{Type: oas.MessageEntityTypeTextLink, Offset: 1, Length: 10, + URL: oas.NewOptString("https://ya.ru")}, + }, + { + "MentionName", + &tg.MessageEntityMentionName{Offset: 1, Length: 10, UserID: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeTextMention, Offset: 1, Length: 10, + User: oas.NewOptUser(convertToUser(testUser()))}, + }, + { + "Phone", + &tg.MessageEntityPhone{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypePhoneNumber, Offset: 1, Length: 10}, + }, + { + "Cashtag", + &tg.MessageEntityCashtag{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeCashtag, Offset: 1, Length: 10}, + }, + { + "Underline", + &tg.MessageEntityUnderline{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeUnderline, Offset: 1, Length: 10}, + }, + { + "Strikethrough", + &tg.MessageEntityStrike{Offset: 1, Length: 10}, + oas.MessageEntity{Type: oas.MessageEntityTypeStrikethrough, Offset: 1, Length: 10}, + }, + } + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + require.Equal(t, + []oas.MessageEntity{tt.wantR}, + api.convertToBotAPIEntities(ctx, []tg.MessageEntityClass{tt.input}), + ) + }) + }) + } +} diff --git a/internal/botapi/me_test.go b/internal/botapi/me_test.go index ac8fd90..ff4b3ac 100644 --- a/internal/botapi/me_test.go +++ b/internal/botapi/me_test.go @@ -16,18 +16,7 @@ func TestBotAPI_GetMe(t *testing.T) { ctx := context.Background() mock, api := testBotAPI(t) - user := &tg.User{ - Self: true, - Bot: true, - ID: 10, - AccessHash: 10, - FirstName: "Elsa", - LastName: "Jean", - Username: "thebot", - BotInfoVersion: 1, - BotInlinePlaceholder: "aboba", - } - user.SetFlags() + user := testUser() mock.ExpectCall(&tg.UsersGetUsersRequest{ ID: []tg.InputUserClass{&tg.InputUserSelf{}}, diff --git a/internal/botapi/peers.go b/internal/botapi/peers.go index 0fd22a1..a3715d5 100644 --- a/internal/botapi/peers.go +++ b/internal/botapi/peers.go @@ -60,6 +60,7 @@ func (b *BotAPI) resolveUserID(ctx context.Context, id int64) (*tg.User, error) return user.Raw(), nil } +// Chat is generic interface for peers.Chat, peers.Channel and friends. type Chat interface { peers.Peer Left() bool diff --git a/internal/botapi/unimplemented_test.go b/internal/botapi/unimplemented_test.go new file mode 100644 index 0000000..c379421 --- /dev/null +++ b/internal/botapi/unimplemented_test.go @@ -0,0 +1,442 @@ +package botapi + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gotd/botapi/internal/oas" +) + +func TestUnimplemented(t *testing.T) { + ctx := context.Background() + a := require.New(t) + b := BotAPI{} + + { + _, err := b.AddStickerToSet(ctx, oas.AddStickerToSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.AnswerCallbackQuery(ctx, oas.AnswerCallbackQuery{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.AnswerInlineQuery(ctx, oas.AnswerInlineQuery{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.AnswerPreCheckoutQuery(ctx, oas.AnswerPreCheckoutQuery{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.AnswerShippingQuery(ctx, oas.AnswerShippingQuery{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.ApproveChatJoinRequest(ctx, oas.ApproveChatJoinRequest{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.BanChatMember(ctx, oas.BanChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.BanChatSenderChat(ctx, oas.BanChatSenderChat{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.CopyMessage(ctx, oas.CopyMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.CreateChatInviteLink(ctx, oas.CreateChatInviteLink{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.CreateNewStickerSet(ctx, oas.CreateNewStickerSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeclineChatJoinRequest(ctx, oas.DeclineChatJoinRequest{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteChatPhoto(ctx, oas.DeleteChatPhoto{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteChatStickerSet(ctx, oas.DeleteChatStickerSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteMessage(ctx, oas.DeleteMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteStickerFromSet(ctx, oas.DeleteStickerFromSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.DeleteWebhook(ctx, oas.OptDeleteWebhook{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditChatInviteLink(ctx, oas.EditChatInviteLink{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageCaption(ctx, oas.EditMessageCaption{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageLiveLocation(ctx, oas.EditMessageLiveLocation{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageMedia(ctx, oas.EditMessageMedia{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageReplyMarkup(ctx, oas.EditMessageReplyMarkup{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.EditMessageText(ctx, oas.EditMessageText{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.ExportChatInviteLink(ctx, oas.ExportChatInviteLink{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.ForwardMessage(ctx, oas.ForwardMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetChat(ctx, oas.GetChat{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetChatAdministrators(ctx, oas.GetChatAdministrators{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetChatMember(ctx, oas.GetChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetFile(ctx, oas.GetFile{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetGameHighScores(ctx, oas.GetGameHighScores{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetStickerSet(ctx, oas.GetStickerSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetUpdates(ctx, oas.OptGetUpdates{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetUserProfilePhotos(ctx, oas.GetUserProfilePhotos{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.GetWebhookInfo(ctx) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.PinChatMessage(ctx, oas.PinChatMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.PromoteChatMember(ctx, oas.PromoteChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.RestrictChatMember(ctx, oas.RestrictChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.RevokeChatInviteLink(ctx, oas.RevokeChatInviteLink{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendAnimation(ctx, oas.SendAnimation{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendAudio(ctx, oas.SendAudio{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendChatAction(ctx, oas.SendChatAction{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendContact(ctx, oas.SendContact{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendDice(ctx, oas.SendDice{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendDocument(ctx, oas.SendDocument{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendGame(ctx, oas.SendGame{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendInvoice(ctx, oas.SendInvoice{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendLocation(ctx, oas.SendLocation{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendMediaGroup(ctx, oas.SendMediaGroup{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendPhoto(ctx, oas.SendPhoto{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendPoll(ctx, oas.SendPoll{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendSticker(ctx, oas.SendSticker{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendVenue(ctx, oas.SendVenue{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendVideo(ctx, oas.SendVideo{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendVideoNote(ctx, oas.SendVideoNote{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SendVoice(ctx, oas.SendVoice{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetChatAdministratorCustomTitle(ctx, oas.SetChatAdministratorCustomTitle{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetChatPermissions(ctx, oas.SetChatPermissions{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetChatPhoto(ctx, oas.SetChatPhoto{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetChatStickerSet(ctx, oas.SetChatStickerSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetGameScore(ctx, oas.SetGameScore{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetPassportDataErrors(ctx, oas.SetPassportDataErrors{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetStickerPositionInSet(ctx, oas.SetStickerPositionInSet{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetStickerSetThumb(ctx, oas.SetStickerSetThumb{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.SetWebhook(ctx, oas.SetWebhook{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.StopMessageLiveLocation(ctx, oas.StopMessageLiveLocation{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.StopPoll(ctx, oas.StopPoll{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UnbanChatMember(ctx, oas.UnbanChatMember{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UnbanChatSenderChat(ctx, oas.UnbanChatSenderChat{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UnpinAllChatMessages(ctx, oas.UnpinAllChatMessages{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UnpinChatMessage(ctx, oas.UnpinChatMessage{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } + + { + _, err := b.UploadStickerFile(ctx, oas.UploadStickerFile{}) + var implErr *NotImplementedError + a.ErrorAs(err, &implErr) + } +} From a0cf0f8d2487d6a7d7b1d9ae16714fa2080eefed Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 19 Dec 2021 15:56:06 +0300 Subject: [PATCH 28/29] fix(pool): set update hook --- internal/pool/pool.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 319b37a..6eeb2d5 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -17,6 +17,7 @@ import ( "github.com/gotd/td/telegram" "github.com/gotd/td/telegram/peers" "github.com/gotd/td/telegram/updates" + updhook "github.com/gotd/td/telegram/updates/hook" "github.com/gotd/td/tg" "github.com/gotd/botapi/internal/botapi" @@ -160,10 +161,14 @@ func (p *Pool) createClient(token Token, initializationResult chan<- error) (_ * AccessHasher: peerManager, Logger: log.Named("gaps"), }) + h := peerManager.UpdateHook(gaps) options := telegram.Options{ Logger: log.Named("client"), - UpdateHandler: peerManager.UpdateHook(gaps), + UpdateHandler: h, SessionStorage: storage, + Middlewares: []telegram.Middleware{ + updhook.UpdateHook(h.Handle), + }, } tgClient := telegram.NewClient(p.appID, p.appHash, options) // FIXME(tdakkota): fix this From 7a42ad8c9d341b0d9a33174456db8d2a28cea8af Mon Sep 17 00:00:00 2001 From: tdakkota Date: Mon, 20 Dec 2021 12:06:42 +0300 Subject: [PATCH 29/29] test(botapi): add markup mapping tests --- internal/botapi/botapi_test.go | 16 + internal/botapi/convert_message.go | 48 +-- internal/botapi/convert_message_test.go | 4 +- internal/botapi/errors.go | 12 +- internal/botapi/markup.go | 65 ++-- internal/botapi/markup_test.go | 419 ++++++++++++++++++++++++ internal/botapi/me.go | 4 +- internal/botapi/me_test.go | 14 + internal/botapi/peers_test.go | 55 ++++ internal/botapi/stickers.go | 28 ++ 10 files changed, 600 insertions(+), 65 deletions(-) create mode 100644 internal/botapi/markup_test.go create mode 100644 internal/botapi/peers_test.go create mode 100644 internal/botapi/stickers.go diff --git a/internal/botapi/botapi_test.go b/internal/botapi/botapi_test.go index b48c965..446cf8e 100644 --- a/internal/botapi/botapi_test.go +++ b/internal/botapi/botapi_test.go @@ -5,6 +5,10 @@ import ( "go.uber.org/zap/zaptest" + "github.com/gotd/td/tgerr" + + "github.com/gotd/td/bin" + "github.com/gotd/td/constant" "github.com/gotd/td/telegram/peers" @@ -31,6 +35,10 @@ func testBotAPI(t *testing.T) (*tgmock.Mock, *BotAPI) { ) } +func testError() *tgerr.Error { + return tgerr.New(1337, "TEST_ERROR") +} + func testChatID() int64 { var id constant.TDLibPeerID id.Chat(testChat().ID) @@ -84,3 +92,11 @@ func testCommandsBotAPI() []oas.BotCommand { }, } } + +func setFlags(b bin.Object) { + if v, ok := b.(interface { + SetFlags() + }); ok { + v.SetFlags() + } +} diff --git a/internal/botapi/convert_message.go b/internal/botapi/convert_message.go index 5b8366f..41260c0 100644 --- a/internal/botapi/convert_message.go +++ b/internal/botapi/convert_message.go @@ -52,7 +52,7 @@ func (b *BotAPI) convertToBotAPIEntities( e.Type = oas.MessageEntityTypeTextMention user, err := b.resolveUserID(ctx, entity.UserID) if err == nil { - e.User.SetTo(convertToUser(user)) + e.User.SetTo(convertToBotAPIUser(user)) b.logger.Warn("Resolve user", zap.Int64("user_id", entity.UserID)) } case *tg.MessageEntityPhone: @@ -178,32 +178,19 @@ func (b *BotAPI) setDocumentAttachment(ctx context.Context, d *tg.Document, r *o Scale: coords.Zoom, }) } - - // TODO(tdakota): make stickerset cache - result, err := b.raw.MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{ - Stickerset: attr.Stickerset, - Hash: 0, - }) + result, err := b.getStickerSet(ctx, attr.Stickerset) if err != nil { return errors.Wrap(err, "get sticker_set") } - var stickerSet *tg.MessagesStickerSet - switch result := result.(type) { - case *tg.MessagesStickerSet: - stickerSet = result - default: - return errors.Errorf("unexpected type %T", result) - } - r.Sticker.SetTo(oas.Sticker{ FileID: fileID, FileUniqueID: fileUniqueID, Width: width, Height: height, - IsAnimated: stickerSet.Set.Animated, + IsAnimated: result.Set.Animated, Thumb: thumb, Emoji: oas.NewOptString(attr.Alt), - SetName: oas.NewOptString(stickerSet.Set.ShortName), + SetName: oas.NewOptString(result.Set.ShortName), MaskPosition: maskPosition, FileSize: oas.NewOptInt(d.Size), }) @@ -285,14 +272,7 @@ func (b *BotAPI) convertMessageMedia(ctx context.Context, media tg.MessageMediaC if !ok { break } - resultLocation := oas.Location{ - Longitude: p.Long, - Latitude: p.Lat, - } - if v, ok := p.GetAccuracyRadius(); ok { - resultLocation.HorizontalAccuracy.SetTo(float64(v)) - } - r.Location.SetTo(resultLocation) + r.Location.SetTo(convertToBotAPILocation(p)) case *tg.MessageMediaContact: r.Contact.SetTo(oas.Contact{ PhoneNumber: media.PhoneNumber, @@ -325,8 +305,9 @@ func (b *BotAPI) convertMessageMedia(ctx context.Context, media tg.MessageMediaC if !ok { break } + location := convertToBotAPILocation(p) resultVenue := oas.Venue{ - Location: convertToBotAPILocation(p), + Location: location, Title: media.Title, Address: media.Address, FoursquareID: oas.OptString{}, @@ -342,6 +323,9 @@ func (b *BotAPI) convertMessageMedia(ctx context.Context, media tg.MessageMediaC resultVenue.GooglePlaceID.SetTo(media.VenueID) resultVenue.GooglePlaceType.SetTo(media.VenueType) } + r.Venue.SetTo(resultVenue) + // Set for backward compatibility. + r.Location.SetTo(location) case *tg.MessageMediaGame: game := media.Game @@ -360,7 +344,11 @@ func (b *BotAPI) convertMessageMedia(ctx context.Context, media tg.MessageMediaC if !ok { break } - r.Location.SetTo(convertToBotAPILocation(p)) + location := convertToBotAPILocation(p) + location.Heading = optInt(media.GetHeading) + location.LivePeriod.SetTo(media.Period) + location.ProximityAlertRadius = optInt(media.GetProximityNotificationRadius) + r.Location.SetTo(location) case *tg.MessageMediaPoll: var ( poll = media.Poll @@ -429,7 +417,7 @@ func (b *BotAPI) convertPlainMessage(ctx context.Context, m *tg.Message) (r oas. if err != nil { return errors.Wrap(err, "get user") } - user.SetTo(convertToUser(u)) + user.SetTo(convertToBotAPIUser(u)) case *tg.PeerChat, *tg.PeerChannel: ch, err := b.getChatByPeer(ctx, fromID) if err != nil { @@ -458,7 +446,7 @@ func (b *BotAPI) convertPlainMessage(ctx context.Context, m *tg.Message) (r oas. if m.Out { self, err := b.peers.Self(ctx) if err == nil { - r.From.SetTo(convertToUser(self.Raw())) + r.From.SetTo(convertToBotAPIUser(self.Raw())) } } else if fromID, ok := m.GetFromID(); ok { // FIXME(tdakkota): set service IDs. @@ -506,7 +494,7 @@ func (b *BotAPI) convertPlainMessage(ctx context.Context, m *tg.Message) (r oas. if err != nil { return oas.Message{}, errors.Wrap(err, "get via_bot") } - r.ViaBot.SetTo(convertToUser(u)) + r.ViaBot.SetTo(convertToBotAPIUser(u)) } if text := m.Message; text != "" { diff --git a/internal/botapi/convert_message_test.go b/internal/botapi/convert_message_test.go index 885812d..fa11850 100644 --- a/internal/botapi/convert_message_test.go +++ b/internal/botapi/convert_message_test.go @@ -74,7 +74,7 @@ func TestBotAPI_convertToBotAPIEntities(t *testing.T) { "MentionName", &tg.MessageEntityMentionName{Offset: 1, Length: 10, UserID: 10}, oas.MessageEntity{Type: oas.MessageEntityTypeTextMention, Offset: 1, Length: 10, - User: oas.NewOptUser(convertToUser(testUser()))}, + User: oas.NewOptUser(convertToBotAPIUser(testUser()))}, }, { "Phone", @@ -101,7 +101,7 @@ func TestBotAPI_convertToBotAPIEntities(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { - require.Equal(t, + a.Equal( []oas.MessageEntity{tt.wantR}, api.convertToBotAPIEntities(ctx, []tg.MessageEntityClass{tt.input}), ) diff --git a/internal/botapi/errors.go b/internal/botapi/errors.go index 5c37898..aac0bea 100644 --- a/internal/botapi/errors.go +++ b/internal/botapi/errors.go @@ -59,9 +59,17 @@ func chatNotFound() *BadRequestError { } // NewError maps error to status code. -func (b *BotAPI) NewError(ctx context.Context, err error) oas.ErrorStatusCode { +func (b *BotAPI) NewError(ctx context.Context, err error) (r oas.ErrorStatusCode) { // TODO(tdakkota): pass request context info. - b.logger.Warn("Request error", zap.Error(err)) + defer func() { + level := zap.DebugLevel + if r.StatusCode >= 500 { + level = zap.WarnLevel + } + if e := b.logger.Check(level, "Request error"); e != nil { + e.Write(zap.Error(err)) + } + }() var ( notImplemented *NotImplementedError diff --git a/internal/botapi/markup.go b/internal/botapi/markup.go index 47b41c4..2d9fa53 100644 --- a/internal/botapi/markup.go +++ b/internal/botapi/markup.go @@ -28,7 +28,7 @@ func (b *BotAPI) convertToTelegramInlineButton( case button.SwitchInlineQuery.Set: return markup.SwitchInline(button.Text, button.SwitchInlineQuery.Value, false), nil case button.SwitchInlineQueryCurrentChat.Set: - return markup.SwitchInline(button.Text, button.SwitchInlineQuery.Value, true), nil + return markup.SwitchInline(button.Text, button.SwitchInlineQueryCurrentChat.Value, true), nil case button.LoginURL.Set: loginURL := button.LoginURL.Value @@ -58,13 +58,43 @@ func (b *BotAPI) convertToTelegramInlineButton( } } -func (b *BotAPI) convertToTelegramButton(kb oas.KeyboardButton) tg.KeyboardButtonClass { +func convertToBotAPIInlineButton(b tg.KeyboardButtonClass) oas.InlineKeyboardButton { + button := oas.InlineKeyboardButton{Text: b.GetText()} + switch b := b.(type) { + case *tg.KeyboardButtonURL: + button.URL.SetTo(b.URL) + case *tg.KeyboardButtonCallback: + button.CallbackData.SetTo(string(b.Data)) + case *tg.KeyboardButtonGame: + button.CallbackGame = new(oas.CallbackGame) + case *tg.KeyboardButtonBuy: + button.Pay.SetTo(true) + case *tg.KeyboardButtonSwitchInline: + if b.SamePeer { + button.SwitchInlineQueryCurrentChat.SetTo(b.Query) + } else { + button.SwitchInlineQuery.SetTo(b.Query) + } + case *tg.KeyboardButtonURLAuth: + // Quote: login_url buttons are represented as ordinary url buttons. + // + // See Message definition + // See https://github.com/tdlib/telegram-bot-api/blob/90f52477814a2d8a08c9ffb1d780fd179815d715/telegram-bot-api/Client.cpp#L1526 + button.URL.SetTo(b.URL) + } + return button +} + +func convertToTelegramButton(kb oas.KeyboardButton) tg.KeyboardButtonClass { if text, ok := kb.GetString(); ok { return markup.Button(text) } button := kb.KeyboardButtonObject - if button.RequestLocation.Value || button.RequestContact.Value { + switch { + case button.RequestLocation.Set: + return markup.RequestGeoLocation(button.Text) + case button.RequestContact.Set: return markup.RequestPhone(button.Text) } @@ -110,8 +140,8 @@ func (b *BotAPI) convertToTelegramReplyMarkup( } for _, row := range rows { resultRow := make([]tg.KeyboardButtonClass, len(row)) - for _, button := range row { - resultRow = append(resultRow, b.convertToTelegramButton(button)) + for i, button := range row { + resultRow[i] = convertToTelegramButton(button) } result.Rows = append(result.Rows, tg.KeyboardButtonRow{Buttons: resultRow}) } @@ -138,30 +168,7 @@ func convertToBotAPIInlineReplyMarkup(mkp *tg.ReplyInlineMarkup) oas.InlineKeybo for i, row := range mkp.Rows { resultRow := make([]oas.InlineKeyboardButton, len(row.Buttons)) for i, b := range row.Buttons { - button := oas.InlineKeyboardButton{Text: b.GetText()} - switch b := b.(type) { - case *tg.KeyboardButtonURL: - button.URL.SetTo(b.URL) - case *tg.KeyboardButtonCallback: - button.CallbackData.SetTo(string(b.Data)) - case *tg.KeyboardButtonSwitchInline: - if b.SamePeer { - button.SwitchInlineQueryCurrentChat.SetTo(b.Query) - } else { - button.SwitchInlineQuery.SetTo(b.Query) - } - case *tg.KeyboardButtonGame: - button.CallbackGame = new(oas.CallbackGame) - case *tg.KeyboardButtonBuy: - button.Pay.SetTo(true) - case *tg.KeyboardButtonURLAuth: - // Quote: login_url buttons are represented as ordinary url buttons. - // - // See Message definition - // See https://github.com/tdlib/telegram-bot-api/blob/90f52477814a2d8a08c9ffb1d780fd179815d715/telegram-bot-api/Client.cpp#L1526 - button.URL.SetTo(b.URL) - } - resultRow[i] = button + resultRow[i] = convertToBotAPIInlineButton(b) } resultRows[i] = resultRow } diff --git a/internal/botapi/markup_test.go b/internal/botapi/markup_test.go new file mode 100644 index 0000000..8f43ec0 --- /dev/null +++ b/internal/botapi/markup_test.go @@ -0,0 +1,419 @@ +package botapi + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gotd/td/telegram/message/markup" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgmock" + + "github.com/gotd/botapi/internal/oas" +) + +func TestBotAPI_convertToTelegramInlineButton(t *testing.T) { + tests := []struct { + name string + input oas.InlineKeyboardButton + want tg.KeyboardButtonClass + wantErr bool + }{ + { + "URL", + oas.InlineKeyboardButton{ + Text: "aboba", + URL: oas.NewOptString("https://ya.ru"), + }, + markup.URL("aboba", "https://ya.ru"), + false, + }, + { + "Callback", + oas.InlineKeyboardButton{ + Text: "aboba", + CallbackData: oas.NewOptString("data"), + }, + markup.Callback("aboba", []byte("data")), + false, + }, + { + "Game", + oas.InlineKeyboardButton{ + Text: "aboba", + CallbackGame: &oas.CallbackGame{}, + }, + markup.Game("aboba"), + false, + }, + { + "Pay", + oas.InlineKeyboardButton{ + Text: "aboba", + Pay: oas.NewOptBool(true), + }, + markup.Buy("aboba"), + false, + }, + { + "SwitchInlineQuery", + oas.InlineKeyboardButton{ + Text: "aboba", + SwitchInlineQuery: oas.NewOptString("query"), + }, + markup.SwitchInline("aboba", "query", false), + false, + }, + { + "SwitchInlineQueryCurrentChat", + oas.InlineKeyboardButton{ + Text: "aboba", + SwitchInlineQueryCurrentChat: oas.NewOptString("query"), + }, + markup.SwitchInline("aboba", "query", true), + false, + }, + { + "LoginURL", + oas.InlineKeyboardButton{ + Text: "aboba", + LoginURL: oas.NewOptLoginUrl(oas.LoginUrl{ + URL: "https://ya.ru", + ForwardText: oas.NewOptString("forward text"), + BotUsername: oas.OptString{}, + RequestWriteAccess: oas.NewOptBool(true), + }), + }, + &tg.InputKeyboardButtonURLAuth{ + RequestWriteAccess: true, + Text: "aboba", + FwdText: "forward text", + URL: "https://ya.ru", + Bot: &tg.InputUserSelf{}, + }, + false, + }, + { + "Text", + oas.InlineKeyboardButton{ + Text: "aboba", + }, + nil, + true, + }, + } + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + r, err := api.convertToTelegramInlineButton(ctx, tt.input) + if tt.wantErr { + a.Error(err) + return + } + a.NoError(err) + a.Equal(tt.want, r) + }) + }) + } +} + +func Test_convertToBotAPIInlineButton(t *testing.T) { + tests := []struct { + name string + input tg.KeyboardButtonClass + want oas.InlineKeyboardButton + }{ + { + "URL", + &tg.KeyboardButtonURL{ + Text: "aboba", + URL: "https://ya.ru", + }, + oas.InlineKeyboardButton{ + Text: "aboba", + URL: oas.NewOptString("https://ya.ru"), + }, + }, + { + "Callback", + &tg.KeyboardButtonCallback{ + Text: "aboba", + Data: []byte("data"), + }, + oas.InlineKeyboardButton{ + Text: "aboba", + CallbackData: oas.NewOptString("data"), + }, + }, + { + "Game", + &tg.KeyboardButtonGame{ + Text: "aboba", + }, + oas.InlineKeyboardButton{ + Text: "aboba", + CallbackGame: &oas.CallbackGame{}, + }, + }, + { + "Game", + &tg.KeyboardButtonBuy{ + Text: "aboba", + }, + oas.InlineKeyboardButton{ + Text: "aboba", + Pay: oas.NewOptBool(true), + }, + }, + { + "Pay", + &tg.KeyboardButtonBuy{ + Text: "aboba", + }, + oas.InlineKeyboardButton{ + Text: "aboba", + Pay: oas.NewOptBool(true), + }, + }, + { + "SwitchInlineQuery", + markup.SwitchInline("aboba", "query", false), + oas.InlineKeyboardButton{ + Text: "aboba", + SwitchInlineQuery: oas.NewOptString("query"), + }, + }, + { + "SwitchInlineQueryCurrentChat", + markup.SwitchInline("aboba", "query", true), + oas.InlineKeyboardButton{ + Text: "aboba", + SwitchInlineQueryCurrentChat: oas.NewOptString("query"), + }, + }, + { + "LoginURL", + &tg.KeyboardButtonURLAuth{ + Text: "aboba", + FwdText: "forward text", + URL: "https://ya.ru", + }, + oas.InlineKeyboardButton{ + Text: "aboba", + URL: oas.NewOptString("https://ya.ru"), + }, + }, + } + + var ( + inputs []tg.KeyboardButtonClass + results []oas.InlineKeyboardButton + ) + for _, tt := range tests { + inputs = append(inputs, tt.input) + results = append(results, tt.want) + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, convertToBotAPIInlineButton(tt.input)) + }) + } + + m := convertToBotAPIInlineReplyMarkup(&tg.ReplyInlineMarkup{ + Rows: []tg.KeyboardButtonRow{ + {Buttons: inputs}, + {Buttons: inputs[:1]}, + }, + }) + a := require.New(t) + a.Len(m.InlineKeyboard, 2) + a.Equal(results, m.InlineKeyboard[0]) + a.Equal(results[:1], m.InlineKeyboard[1]) +} + +func Test_convertToTelegramButton(t *testing.T) { + obj := oas.NewKeyboardButtonObjectKeyboardButton + tests := []struct { + name string + input oas.KeyboardButton + want tg.KeyboardButtonClass + }{ + { + "StringText", + oas.NewStringKeyboardButton("aboba"), + &tg.KeyboardButton{Text: "aboba"}, + }, + { + "Text", + obj(oas.KeyboardButtonObject{ + Text: "aboba", + }), + &tg.KeyboardButton{Text: "aboba"}, + }, + { + "RequestLocation", + obj(oas.KeyboardButtonObject{ + Text: "aboba", + RequestLocation: oas.NewOptBool(true), + }), + &tg.KeyboardButtonRequestGeoLocation{Text: "aboba"}, + }, + { + "RequestContact", + obj(oas.KeyboardButtonObject{ + Text: "aboba", + RequestContact: oas.NewOptBool(true), + }), + &tg.KeyboardButtonRequestPhone{Text: "aboba"}, + }, + { + "RequestPoll", + obj(oas.KeyboardButtonObject{ + Text: "aboba", + RequestPoll: oas.NewOptKeyboardButtonPollType(oas.KeyboardButtonPollType{ + Type: oas.NewOptString("quiz"), + }), + }), + &tg.KeyboardButtonRequestPoll{Quiz: true, Text: "aboba"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, convertToTelegramButton(tt.input)) + }) + } +} + +func TestBotAPI_convertToTelegramReplyMarkup(t *testing.T) { + tests := []struct { + name string + input *oas.SendMessageReplyMarkup + want tg.ReplyMarkupClass + wantErr bool + }{ + { + "Inline", + &oas.SendMessageReplyMarkup{ + Type: oas.InlineKeyboardMarkupSendMessageReplyMarkup, + InlineKeyboardMarkup: oas.InlineKeyboardMarkup{ + InlineKeyboard: [][]oas.InlineKeyboardButton{ + { + { + Text: "aboba", + CallbackData: oas.NewOptString("data"), + }, + }, + }, + }, + }, + &tg.ReplyInlineMarkup{ + Rows: []tg.KeyboardButtonRow{ + { + Buttons: []tg.KeyboardButtonClass{ + markup.Callback("aboba", []byte("data")), + }, + }, + }, + }, + false, + }, + { + "Reply", + &oas.SendMessageReplyMarkup{ + Type: oas.ReplyKeyboardMarkupSendMessageReplyMarkup, + ReplyKeyboardMarkup: oas.ReplyKeyboardMarkup{ + Keyboard: [][]oas.KeyboardButton{ + { + oas.NewStringKeyboardButton("aboba"), + }, + }, + ResizeKeyboard: oas.NewOptBool(true), + OneTimeKeyboard: oas.NewOptBool(true), + InputFieldPlaceholder: oas.NewOptString("placeholder"), + Selective: oas.NewOptBool(true), + }, + }, + &tg.ReplyKeyboardMarkup{ + Resize: true, + SingleUse: true, + Selective: true, + Rows: []tg.KeyboardButtonRow{ + { + Buttons: []tg.KeyboardButtonClass{ + markup.Button("aboba"), + }, + }, + }, + Placeholder: "placeholder", + }, + false, + }, + { + "Hide", + &oas.SendMessageReplyMarkup{ + Type: oas.ReplyKeyboardRemoveSendMessageReplyMarkup, + ReplyKeyboardRemove: oas.ReplyKeyboardRemove{ + RemoveKeyboard: true, + }, + }, + &tg.ReplyKeyboardHide{ + Selective: false, + }, + false, + }, + { + "SelectiveHide", + &oas.SendMessageReplyMarkup{ + Type: oas.ReplyKeyboardRemoveSendMessageReplyMarkup, + ReplyKeyboardRemove: oas.ReplyKeyboardRemove{ + RemoveKeyboard: true, + Selective: oas.NewOptBool(true), + }, + }, + &tg.ReplyKeyboardHide{ + Selective: true, + }, + false, + }, + { + "ForceReply", + &oas.SendMessageReplyMarkup{ + Type: oas.ForceReplySendMessageReplyMarkup, + ForceReply: oas.ForceReply{ + ForceReply: true, + InputFieldPlaceholder: oas.NewOptString("placeholder"), + Selective: oas.NewOptBool(true), + }, + }, + &tg.ReplyKeyboardForceReply{ + Selective: true, + Placeholder: "placeholder", + }, + false, + }, + { + "UnknownType", + &oas.SendMessageReplyMarkup{ + Type: "aboba", + }, + nil, + true, + }, + } + for _, tt := range tests { + ctx := context.Background() + t.Run(tt.name, func(t *testing.T) { + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + got, err := api.convertToTelegramReplyMarkup(ctx, tt.input) + if tt.wantErr { + a.Error(err) + return + } + a.NoError(err) + setFlags(got) + setFlags(tt.want) + a.Equal(tt.want, got) + }) + }) + } +} diff --git a/internal/botapi/me.go b/internal/botapi/me.go index 457e72f..459877a 100644 --- a/internal/botapi/me.go +++ b/internal/botapi/me.go @@ -8,7 +8,7 @@ import ( "github.com/gotd/botapi/internal/oas" ) -func convertToUser(user *tg.User) oas.User { +func convertToBotAPIUser(user *tg.User) oas.User { return oas.User{ ID: user.ID, IsBot: user.Bot, @@ -30,7 +30,7 @@ func (b *BotAPI) GetMe(ctx context.Context) (oas.ResultUser, error) { } return oas.ResultUser{ - Result: oas.NewOptUser(convertToUser(me.Raw())), + Result: oas.NewOptUser(convertToBotAPIUser(me.Raw())), Ok: true, }, nil } diff --git a/internal/botapi/me_test.go b/internal/botapi/me_test.go index ff4b3ac..c80f364 100644 --- a/internal/botapi/me_test.go +++ b/internal/botapi/me_test.go @@ -41,3 +41,17 @@ func TestBotAPI_GetMe(t *testing.T) { Ok: true, }, result) } + +func TestBotAPI_LogOut(t *testing.T) { + a := require.New(t) + ctx := context.Background() + mock, api := testBotAPI(t) + + mock.ExpectCall(&tg.AuthLogOutRequest{}).ThenRPCErr(testError()) + _, err := api.LogOut(ctx) + a.Error(err) + + mock.ExpectCall(&tg.AuthLogOutRequest{}).ThenResult(&tg.AuthLoggedOut{}) + _, err = api.LogOut(ctx) + a.NoError(err) +} diff --git a/internal/botapi/peers_test.go b/internal/botapi/peers_test.go new file mode 100644 index 0000000..9fb48cf --- /dev/null +++ b/internal/botapi/peers_test.go @@ -0,0 +1,55 @@ +package botapi + +import ( + "context" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gotd/td/telegram/peers" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgmock" + + "github.com/gotd/botapi/internal/oas" +) + +func TestBotAPI_resolveID(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + var badRequestError *BadRequestError + + _, err := api.resolveID(ctx, oas.NewStringID("")) + a.ErrorAs(err, &badRequestError) + + _, err = api.resolveID(ctx, oas.NewStringID("aboba")) + a.ErrorAs(err, &badRequestError) + + p, err := api.resolveID(ctx, oas.NewStringID(strconv.FormatInt(testChatID(), 10))) + a.NoError(err) + a.IsType(peers.Chat{}, p) + + mock.ExpectCall(&tg.ContactsResolveUsernameRequest{ + Username: "tdakkota", + }).ThenRPCErr(testError()) + _, err = api.resolveID(ctx, oas.NewStringID("@tdakkota")) + a.Error(err) + + mock.ExpectCall(&tg.ContactsResolveUsernameRequest{ + Username: "tdakkota", + }).ThenResult(&tg.ContactsResolvedPeer{ + Peer: &tg.PeerUser{UserID: 1337}, + Users: []tg.UserClass{ + &tg.User{ + ID: 1337, + AccessHash: 1337, + FirstName: "tdakkota", + Username: "tdakkota", + }, + }, + }) + p, err = api.resolveID(ctx, oas.NewStringID("@tdakkota")) + a.NoError(err) + a.IsType(peers.User{}, p) + }) +} diff --git a/internal/botapi/stickers.go b/internal/botapi/stickers.go new file mode 100644 index 0000000..e33c792 --- /dev/null +++ b/internal/botapi/stickers.go @@ -0,0 +1,28 @@ +package botapi + +import ( + "context" + + "github.com/go-faster/errors" + + "github.com/gotd/td/tg" +) + +func (b *BotAPI) getStickerSet(ctx context.Context, input tg.InputStickerSetClass) (*tg.MessagesStickerSet, error) { + // TODO(tdakkota): investigate GreatMinds hack + // See https://github.com/tdlib/telegram-bot-api/blob/6abdb73512110c2adfaa7145eb01e102e75b89f6/telegram-bot-api/Client.h#L69-L70 + result, err := b.raw.MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{ + Stickerset: input, + Hash: 0, + }) + if err != nil { + return nil, errors.Wrap(err, "get sticker_set") + } + // TODO(tdakkota): make cache + switch result := result.(type) { + case *tg.MessagesStickerSet: + return result, nil + default: + return nil, errors.Errorf("unexpected type %T", result) + } +}