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/.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 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/botdoc/oas.go b/botdoc/oas.go index c281552..79a1116 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" ) @@ -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. 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/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 be603a5..1524a0a 100644 --- a/cmd/botapi/main.go +++ b/cmd/botapi/main.go @@ -4,101 +4,142 @@ package main import ( "context" "flag" + "fmt" + "net" "net/http" - "strings" + "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/telegram" + "github.com/gotd/td/constant" - "github.com/gotd/botapi/pool" + "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 -} +func listen(ctx context.Context, addr string, h http.Handler, logger *zap.Logger) error { + grp, ctx := errgroup.WithContext(ctx) -type handler struct { - handlers map[string]func(ctx context.Context, h handleContext) error -} + 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) + } -func (h handler) On(method string, f func(ctx context.Context, h handleContext) error) { - h.handlers[strings.ToLower(method)] = f + return nil } -func main() { +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)") + keepalive = flag.Duration("keepalive", 5*time.Minute, "client keepalive") + 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)) - if err != nil { - panic(err) + level := zap.InfoLevel + if *debug { + level = zap.DebugLevel } - - var storage pool.StateStorage - if *statePath != "" { - storage = pool.NewFileStorage(*statePath) + log, err := zap.NewDevelopment(zap.IncreaseLevel(level)) + if err != nil { + return errors.Errorf("create logger: %w", err) } - - log.Info("Start", zap.String("addr", *addr)) - p, err := pool.NewPool(pool.Options{ + defer func() { + _ = log.Sync() + }() + + log.Info("Creating pool", + zap.Duration("keep_alive", *keepalive), + zap.String("storage", *statePath), + zap.Bool("debug", *debug), + ) + p, err := pool.NewPool(*statePath, pool.Options{ AppID: *appID, AppHash: *appHash, Log: log.Named("pool"), - Storage: storage, + Debug: *debug, }) if err != nil { panic(err) } go p.RunGC(*keepalive) - h := handler{ - handlers: map[string]func(ctx context.Context, h handleContext) error{}, - } - h.On("getMe", getMe) - // 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) + botapi.NotFound(w, r) return } + method := chi.URLParam(r, "method") - method := strings.ToLower(chi.URLParam(r, "method")) - handler, ok := h.handlers[method] - if !ok { - w.WriteHeader(http.StatusNotFound) - return - } + log := log.With(zap.Int("bot_id", token.ID), zap.String("method", method)) - 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, - }) + 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 { - w.WriteHeader(http.StatusInternalServerError) - return + log.Warn("Do error", zap.Error(err)) } }) - 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/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/go.mod b/go.mod index 3ea6fca..2ecbeac 100644 --- a/go.mod +++ b/go.mod @@ -4,25 +4,20 @@ 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/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/ogen-go/errors v0.4.0 - github.com/ogen-go/jx v0.13.3 + 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 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/multierr v1.7.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 ( - github.com/go-faster/errors v0.5.0 - github.com/go-faster/jx v0.25.0 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c ) require ( @@ -40,14 +35,12 @@ 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 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/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 nhooyr.io/websocket v1.8.7 // indirect rsc.io/qr v0.2.0 // indirect diff --git a/go.sum b/go.sum index 4dd133c..828c052 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= @@ -70,13 +69,14 @@ 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.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= 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= @@ -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= @@ -117,14 +113,13 @@ 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= 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= @@ -138,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= @@ -176,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= @@ -187,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= @@ -203,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= @@ -219,8 +218,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= 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..3719bfa --- /dev/null +++ b/internal/botapi/botapi.go @@ -0,0 +1,76 @@ +// Package botapi contains Telegram Bot API handlers implementation. +package botapi + +import ( + "context" + + "github.com/go-faster/errors" + "go.uber.org/zap" + + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/telegram/peers" + "github.com/gotd/td/telegram/updates" + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/oas" +) + +// BotAPI is Bot API implementation. +type BotAPI struct { + raw *tg.Client + gaps *updates.Manager + + sender *message.Sender + peers *peers.Manager + + debug bool + logger *zap.Logger +} + +// NewBotAPI creates new BotAPI instance. +func NewBotAPI( + raw *tg.Client, + gaps *updates.Manager, + peer *peers.Manager, + opts Options, +) *BotAPI { + opts.setDefaults() + + return &BotAPI{ + 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 { + 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, "get self") + } + + _, isBot := me.ToBot() + if err := b.gaps.Auth(ctx, b.raw, me.ID(), isBot, false); err != nil { + return errors.Wrap(err, "init gaps") + } + + return nil +} + +// GetUpdates implements oas.Handler. +func (b *BotAPI) GetUpdates(ctx context.Context, req oas.OptGetUpdates) (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/botapi_test.go b/internal/botapi/botapi_test.go new file mode 100644 index 0000000..446cf8e --- /dev/null +++ b/internal/botapi/botapi_test.go @@ -0,0 +1,102 @@ +package botapi + +import ( + "testing" + + "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" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgmock" + + "github.com/gotd/botapi/internal/oas" +) + +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"), + Cache: new(peers.InmemoryCache), + }.Build(raw), + Options{ + Logger: logger.Named("botapi"), + }, + ) +} + +func testError() *tgerr.Error { + return tgerr.New(1337, "TEST_ERROR") +} + +func testChatID() int64 { + var id constant.TDLibPeerID + id.Chat(testChat().ID) + return int64(id) +} + +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 +} + +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 +} + +func testCommands() []tg.BotCommand { + return []tg.BotCommand{ + { + Command: "freeburger", + Description: "trolling", + }, + } +} + +func testCommandsBotAPI() []oas.BotCommand { + return []oas.BotCommand{ + { + Command: "freeburger", + Description: "trolling", + }, + } +} + +func setFlags(b bin.Object) { + if v, ok := b.(interface { + SetFlags() + }); ok { + v.SetFlags() + } +} diff --git a/internal/botapi/chat.go b/internal/botapi/chat.go new file mode 100644 index 0000000..60fae29 --- /dev/null +++ b/internal/botapi/chat.go @@ -0,0 +1,92 @@ +package botapi + +import ( + "context" + + "github.com/go-faster/errors" + + "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) { + 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. +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) { + 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.resolveChatID(ctx, req.ChatID) + if err != nil { + return oas.Result{}, errors.Wrap(err, "resolve chatID") + } + if !p.Left() { + if err := p.Leave(ctx); err != nil { + return oas.Result{}, err + } + } + return resultOK(true), nil +} diff --git a/internal/botapi/chat_member.go b/internal/botapi/chat_member.go new file mode 100644 index 0000000..d412806 --- /dev/null +++ b/internal/botapi/chat_member.go @@ -0,0 +1,59 @@ +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) { + 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. +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_member_test.go b/internal/botapi/chat_member_test.go new file mode 100644 index 0000000..ee4d1b3 --- /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() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + r, err := api.GetChatMemberCount(ctx, oas.GetChatMemberCount{ + ChatID: oas.NewInt64ID(testChatID()), + }) + a.NoError(err) + a.Equal(oas.ResultInt{ + Result: oas.NewOptInt(10), + Ok: true, + }, r) + }) +} 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/chat_test.go b/internal/botapi/chat_test.go new file mode 100644 index 0000000..fb86c31 --- /dev/null +++ b/internal/botapi/chat_test.go @@ -0,0 +1,83 @@ +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 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(), + []tg.UserClass{ + testUser(), + }, + []tg.ChatClass{ + testChat(), + }, + )) + + cb(a, mock, api) +} + +func TestBotAPI_SetChatDescription(t *testing.T) { + ctx := context.Background() + 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(testChatID()), + 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(testChatID()), + Description: oas.NewOptString("aboba"), + }) + a.NoError(err) + }) +} + +func TestBotAPI_SetChatTitle(t *testing.T) { + ctx := context.Background() + 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(testChatID()), + Title: "aboba", + }) + a.NoError(err) + }) +} + +func TestBotAPI_LeaveChat(t *testing.T) { + ctx := context.Background() + 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(testChatID()), + }) + a.NoError(err) + }) +} diff --git a/internal/botapi/command.go b/internal/botapi/command.go new file mode 100644 index 0000000..7fadcb9 --- /dev/null +++ b/internal/botapi/command.go @@ -0,0 +1,154 @@ +package botapi + +import ( + "context" + + "github.com/go-faster/errors" + + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/oas" +) + +func (b *BotAPI) convertToBotCommandScopeClass( + 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.Wrap(err, "resolve chatID") + } + 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.InputPeer()}, nil + case oas.BotCommandScopeChatMemberBotCommandScope: + userID := scope.BotCommandScopeChatMember.UserID + user, err := b.resolveUserID(ctx, userID) + if err != nil { + return nil, errors.Wrap(err, "resolve userID") + } + + chatID := scope.BotCommandScopeChatMember.ChatID + p, err := b.resolveID(ctx, chatID) + if err != nil { + return nil, errors.Wrap(err, "resolve chatID") + } + return &tg.BotCommandScopePeerUser{ + Peer: p.InputPeer(), + UserID: user.AsInput(), + }, 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.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.raw.BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{ + Scope: scope, + LangCode: langCode, + }) + 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) { + scope, err := b.convertToBotCommandScopeClass(ctx, req.Scope) + if err != nil { + return oas.Result{}, errors.Wrap(err, "convert scope") + } + + 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.raw.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.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.raw.BotsResetBotCommands(ctx, &tg.BotsResetBotCommandsRequest{ + Scope: scope, + LangCode: langCode, + }) + if err != nil { + return oas.Result{}, err + } + + return resultOK(r), nil +} 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.go b/internal/botapi/convert_message.go new file mode 100644 index 0000000..41260c0 --- /dev/null +++ b/internal/botapi/convert_message.go @@ -0,0 +1,516 @@ +package botapi + +import ( + "context" + "strconv" + + "github.com/go-faster/errors" + "go.uber.org/zap" + + "github.com/gotd/td/fileid" + "github.com/gotd/td/tg" + + "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) { + 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 + e.URL.SetTo(entity.URL) + case *tg.MessageEntityMentionName: + e.Type = oas.MessageEntityTypeTextMention + user, err := b.resolveUserID(ctx, entity.UserID) + if err == nil { + e.User.SetTo(convertToBotAPIUser(user)) + b.logger.Warn("Resolve user", zap.Int64("user_id", entity.UserID)) + } + 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 +} + +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, + }) + } + result, err := b.getStickerSet(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: result.Set.Animated, + Thumb: thumb, + Emoji: oas.NewOptString(attr.Alt), + SetName: oas.NewOptString(result.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 + } + r.Location.SetTo(convertToBotAPILocation(p)) + 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 + } + location := convertToBotAPILocation(p) + resultVenue := oas.Venue{ + Location: location, + 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) + } + r.Venue.SetTo(resultVenue) + // Set for backward compatibility. + r.Location.SetTo(location) + 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 + } + 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 + 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 { + resultPoll.ExplanationEntities = b.convertToBotAPIEntities(ctx, e) + } + + // 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.Wrap(err, "get user") + } + user.SetTo(convertToBotAPIUser(u)) + case *tg.PeerChat, *tg.PeerChannel: + ch, err := b.getChatByPeer(ctx, fromID) + if err != nil { + return errors.Wrap(err, "get chat") + } + chat.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), + HasProtectedContent: ch.HasProtectedContent, + // TODO(tdakkota): generate media album ids + MediaGroupID: oas.OptString{}, + AuthorSignature: optString(m.GetPostAuthor), + } + if m.Out { + self, err := b.peers.Self(ctx) + if err == nil { + r.From.SetTo(convertToBotAPIUser(self.Raw())) + } + } 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(convertToBotAPIUser(u)) + } + + if text := m.Message; text != "" { + r.Text.SetTo(text) + } + if len(m.Entities) > 0 { + r.Entities = b.convertToBotAPIEntities(ctx, m.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/convert_message_test.go b/internal/botapi/convert_message_test.go new file mode 100644 index 0000000..fa11850 --- /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(convertToBotAPIUser(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) { + 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 new file mode 100644 index 0000000..aac0bea --- /dev/null +++ b/internal/botapi/errors.go @@ -0,0 +1,161 @@ +package botapi + +import ( + "context" + "net/http" + + "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" + + "github.com/gotd/botapi/internal/oas" +) + +// NotImplementedError is stub error for not implemented methods. +type NotImplementedError struct { + Message string +} + +// Error implements error. +func (n *NotImplementedError) Error() string { + if n.Message == "" { + return "method not implemented yet" + } + return n.Message +} + +// 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 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: description, + }, + } +} + +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) (r oas.ErrorStatusCode) { + // TODO(tdakkota): pass request context info. + 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 + peerNotFound *peers.PeerNotFoundError + badRequest *BadRequestError + ) + // TODO(tdakkota): better error mapping. + switch { + case errors.As(err, ¬Implemented): + return errorOf(http.StatusNotImplemented) + case errors.As(err, &peerNotFound): + return errorStatusCode(http.StatusBadRequest, "Bad Request: chat not found") + case errors.As(err, &badRequest): + 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) + 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/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/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..d6596fd --- /dev/null +++ b/internal/botapi/live_location.go @@ -0,0 +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 new file mode 100644 index 0000000..2d9fa53 --- /dev/null +++ b/internal/botapi/markup.go @@ -0,0 +1,179 @@ +package botapi + +import ( + "context" + + "github.com/go-faster/errors" + + "github.com/gotd/td/telegram/message/markup" + "github.com/gotd/td/telegram/peers" + "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), 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.SwitchInlineQueryCurrentChat.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.peers.ResolveDomain(ctx, loginURL.BotUsername.Value) + if err != nil { + return nil, errors.Wrap(err, "resolve bot") + } + + u, ok := p.(peers.User) + if !ok { + return nil, &BadRequestError{Message: "given username is not bot"} + } + user = u.InputUser() + } + + return &tg.InputKeyboardButtonURLAuth{ + RequestWriteAccess: loginURL.RequestWriteAccess.Value, + Text: button.Text, + FwdText: loginURL.ForwardText.Value, + URL: loginURL.URL, + Bot: user, + }, nil + default: + return nil, &BadRequestError{Message: "Text buttons are unallowed in the inline keyboard"} + } +} + +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 + switch { + case button.RequestLocation.Set: + return markup.RequestGeoLocation(button.Text) + case button.RequestContact.Set: + 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 i, button := range row { + resultRow[i] = 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 { + resultRow[i] = convertToBotAPIInlineButton(b) + } + resultRows[i] = resultRow + } + + return oas.InlineKeyboardMarkup{ + InlineKeyboard: resultRows, + } +} 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 new file mode 100644 index 0000000..459877a --- /dev/null +++ b/internal/botapi/me.go @@ -0,0 +1,51 @@ +package botapi + +import ( + "context" + + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/oas" +) + +func convertToBotAPIUser(user *tg.User) oas.User { + return oas.User{ + ID: user.ID, + IsBot: user.Bot, + FirstName: user.FirstName, + LastName: optString(user.GetLastName), + Username: optString(user.GetUsername), + LanguageCode: optString(user.GetLangCode), + 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) { + me, err := b.peers.Self(ctx) + if err != nil { + return oas.ResultUser{}, err + } + + return oas.ResultUser{ + Result: oas.NewOptUser(convertToBotAPIUser(me.Raw())), + 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) { + if _, err := b.raw.AuthLogOut(ctx); err != nil { + return oas.Result{}, err + } + + return resultOK(true), nil +} diff --git a/internal/botapi/me_test.go b/internal/botapi/me_test.go new file mode 100644 index 0000000..c80f364 --- /dev/null +++ b/internal/botapi/me_test.go @@ -0,0 +1,57 @@ +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 := testUser() + + 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) +} + +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/message.go b/internal/botapi/message.go new file mode 100644 index 0000000..2e31e89 --- /dev/null +++ b/internal/botapi/message.go @@ -0,0 +1,47 @@ +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{} +} + +// 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{} +} + +// 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..78a7aac --- /dev/null +++ b/internal/botapi/optional.go @@ -0,0 +1,19 @@ +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 optInt(getter func() (int, bool)) oas.OptInt { + v, ok := getter() + if !ok { + return oas.OptInt{} + } + return oas.NewOptInt(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 new file mode 100644 index 0000000..a3715d5 --- /dev/null +++ b/internal/botapi/peers.go @@ -0,0 +1,122 @@ +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" +) + +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) + } + + tdlibID := peer.TDLibPeerID() + if user, ok := peer.(peers.User); ok { + return oas.Chat{ + ID: int64(tdlibID), + Type: oas.ChatTypePrivate, + Username: optString(user.Username), + FirstName: optString(user.FirstName), + LastName: optString(user.LastName), + }, nil + } + + r := oas.Chat{ + ID: int64(tdlibID), + Type: oas.ChatTypeGroup, + Title: oas.NewOptString(peer.VisibleName()), + HasProtectedContent: oas.OptBool{}, + } + 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.Username) + r.HasProtectedContent.SetTo(ch.NoForwards()) + } + + return r, 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 +} + +// Chat is generic interface for peers.Chat, peers.Channel and friends. +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) + } + + username := id.String + switch { + case len(username) < 1: + return nil, &BadRequestError{Message: "Bad Request: chat_id is empty"} + case username[0] != '@': + parsedID, err := strconv.ParseInt(username, 10, 64) + if err != nil { + return nil, chatNotFound() + } + return b.resolveIntID(ctx, parsedID) + } + // Cut @. + username = username[1:] + + p, err := b.peers.ResolveDomain(ctx, username) + if err != nil { + return nil, errors.Wrapf(err, "resolve %q", username) + } + return p, nil +} + +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, nil +} 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/result.go b/internal/botapi/result.go new file mode 100644 index 0000000..8603ba9 --- /dev/null +++ b/internal/botapi/result.go @@ -0,0 +1,13 @@ +package botapi + +import "github.com/gotd/botapi/internal/oas" + +func resultOK(v bool) oas.Result { + return oas.Result{ + Result: oas.OptBool{ + Value: v, + Set: v, + }, + Ok: true, + } +} diff --git a/internal/botapi/send.go b/internal/botapi/send.go new file mode 100644 index 0000000..aaa8956 --- /dev/null +++ b/internal/botapi/send.go @@ -0,0 +1,169 @@ +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" +) + +// 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) { + 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.InputPeer()).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): random_id unpacking. + 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) + } + + 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 + } + if msg.PeerID == nil { + switch p := p.InputPeer().(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 { + return oas.ResultMessage{}, errors.Wrap(err, "get message") + } + return oas.ResultMessage{ + Result: oas.NewOptMessage(resultMsg), + Ok: true, + }, nil +} + +// 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/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) + } +} 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) + } +} 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..621576d --- /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.OptDeleteWebhook) (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/botstorage/bbolt.go b/internal/botstorage/bbolt.go new file mode 100644 index 0000000..e4cab2f --- /dev/null +++ b/internal/botstorage/bbolt.go @@ -0,0 +1,493 @@ +package botstorage + +import ( + "context" + "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" +) + +// 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) 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)) + 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.viewBucket(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 errors.Errorf("got invalid value %+x", val) + } + 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 { + key := formatInt(id) + data := b.Get(key) + if data == nil { + return false + } + buf := bin.Buffer{Buf: data} + if err := d.Decode(&buf); err != nil { + // Ignore decode errors. + return false + } + return true +} + +// 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.viewBucket(userPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.User) + found = getMTProtoKey(b, id, e) + return nil + }) + 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.viewBucket(userFullPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.UserFull) + found = getMTProtoKey(b, id, e) + return nil + }) + 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.viewBucket(chatPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.Chat) + found = getMTProtoKey(b, id, e) + return nil + }) + return e, found, err +} + +// FindChatFull implements BBoltStorage. +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 = getMTProtoKey(b, id, e) + return nil + }) + 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.viewBucket(channelPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + e = new(tg.Channel) + found = getMTProtoKey(b, id, e) + return nil + }) + return e, found, err +} + +// FindChannelFull implements BBoltStorage. +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 = getMTProtoKey(b, id, e) + return nil + }) + 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, errors.Errorf("decode %q", key) + } + *v = int(p) + return true, nil +} + +// GetState implements updates.StateStorage. +func (b *BBoltStorage) GetState(_ int64) (state updates.State, found bool, err 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 + } + 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.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 +} + +// 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.viewBucket(channelsPtsPrefix, func(b *bbolt.Bucket, tx *bbolt.Tx) error { + return b.ForEach(func(k, v []byte) error { + channelID, ok := parseInt(k) + if !ok { + // Ignore invalid entries. + return nil + } + pts, ok := parseInt(v) + if !ok { + // Ignore invalid entries. + return nil + } + 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/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/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"` diff --git a/internal/pool/client.go b/internal/pool/client.go new file mode 100644 index 0000000..2708276 --- /dev/null +++ b/internal/pool/client.go @@ -0,0 +1,39 @@ +package pool + +import ( + "context" + "sync" + "time" + + "github.com/gotd/td/telegram" + + "github.com/gotd/botapi/internal/botapi" +) + +type client struct { + ctx context.Context + cancel context.CancelFunc + + mux sync.Mutex + api *botapi.BotAPI + client *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.IsZero() && 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 new file mode 100644 index 0000000..6eeb2d5 --- /dev/null +++ b/internal/pool/pool.go @@ -0,0 +1,270 @@ +// Package pool implements client pool. +package pool + +import ( + "context" + "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" + updhook "github.com/gotd/td/telegram/updates/hook" + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/botapi" + "github.com/gotd/botapi/internal/botstorage" +) + +// Pool of clients. +type Pool struct { + appID int + appHash string + debug bool + log *zap.Logger + + statePath string + + clients map[Token]*client + clientsMux sync.Mutex +} + +func (p *Pool) tick(deadline time.Time) { + p.clientsMux.Lock() + var toRemove []Token + for token, c := range p.clients { + if c.Deadline(deadline) { + toRemove = append(toRemove, token) + c.Kill() + } + } + for _, token := range toRemove { + delete(p.clients, token) + } + p.clientsMux.Unlock() +} + +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, +// authentication error or context cancelled. +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() + + if ok { + // Happy path. + 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)) + + 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 + } + 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 + } + pClient := new(tg.Client) + peerManager := peers.Options{ + Storage: storage, + Cache: storage, + Logger: log.Named("peers"), + }.Build(pClient) + gaps := updates.New(updates.Config{ + Handler: handler, + OnChannelTooLong: func(channelID int64) { + log.Warn("Got channel too long", zap.Int64("channel_id", channelID)) + }, + Storage: storage, + AccessHasher: peerManager, + Logger: log.Named("gaps"), + }) + h := peerManager.UpdateHook(gaps) + options := telegram.Options{ + Logger: log.Named("client"), + UpdateHandler: h, + SessionStorage: storage, + Middlewares: []telegram.Middleware{ + updhook.UpdateHook(h.Handle), + }, + } + tgClient := telegram.NewClient(p.appID, p.appHash, options) + // FIXME(tdakkota): fix this + *pClient = *tgClient.API() + + tgContext, tgCancel := context.WithCancel(context.Background()) + c := &client{ + ctx: tgContext, + cancel: tgCancel, + api: botapi.NewBotAPI(tgClient.API(), gaps, peerManager, botapi.Options{ + Debug: p.debug, + Logger: log.Named("botapi"), + }), + client: tgClient, + token: token, + lastUsed: time.Time{}, + } + + // Wait for initialization. + go func() { + defer func() { + // Removing client from client list on close. + p.clientsMux.Lock() + found, ok := p.clients[token] + if ok && found.client == c.client { + delete(p.clients, token) + } + p.clientsMux.Unlock() + // Kill client. + c.Kill() + _ = db.Close() + // Stop waiting for result. + close(initializationResult) + }() + + 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 := c.client.Auth().Bot(ctx, token.String()); err != nil { + return err + } + } + + if err := c.api.Init(ctx); err != nil { + return errors.Wrap(err, "init BotAPI") + } + defer func() { + _ = gaps.Logout() + }() + + // Done. + select { + case initializationResult <- nil: + // Update lastUsed, because it is zero during initialization. + c.Use(p.now()) + default: + } + + <-ctx.Done() + return ctx.Err() + }); err != nil { + // Failed. + select { + case initializationResult <- err: + log.Warn("Initialize", zap.Error(err)) + default: + } + } + }() + + return c, nil +} + +type Options struct { + AppID int + AppHash string + Log *zap.Logger + Debug bool +} + +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{}, + 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/token.go b/internal/pool/token.go new file mode 100644 index 0000000..87418bc --- /dev/null +++ b/internal/pool/token.go @@ -0,0 +1,38 @@ +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 +} + +// ParseToken parses bot token from given string. +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) +} diff --git a/pool/pool.go b/pool/pool.go deleted file mode 100644 index c7bf7d5..0000000 --- a/pool/pool.go +++ /dev/null @@ -1,322 +0,0 @@ -// Package pool implements client pool. -package pool - -import ( - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "os" - "strconv" - "strings" - "sync" - "time" - - "github.com/ogen-go/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 - appHash string - log *zap.Logger - - storage StateStorage - clientsMux sync.Mutex - clients map[Token]*client -} - -func (p *Pool) tick(deadline time.Time) { - p.clientsMux.Lock() - var toRemove []Token - for token, c := range p.clients { - if c.Deadline(deadline) { - toRemove = append(toRemove, token) - c.cancel() - } - } - for _, token := range toRemove { - delete(p.clients, token) - } - p.clientsMux.Unlock() -} - -func (p *Pool) now() time.Time { - return time.Now() -} - -// Do acquires telegram client by 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 { - p.clientsMux.Lock() - c, ok := p.clients[token] - p.clientsMux.Unlock() - - if ok { - // Happy path. - c.Use(p.now()) - return fn(c.telegram) - } - - options := telegram.Options{ - Logger: p.log.Named("client").With(zap.Int("id", token.ID)), - } - if p.storage != nil { - options.SessionStorage = clientStorage{ - id: fmt.Sprintf("%x:%x", token.ID, sha256.Sum256([]byte(token.Secret))), - storage: p.storage, - } - } - tgClient := telegram.NewClient(p.appID, p.appHash, options) - - tgContext, tgCancel := context.WithCancel(context.Background()) - c = &client{ - ctx: tgContext, - cancel: tgCancel, - telegram: tgClient, - token: token, - lastUsed: p.now(), - } - - // 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.telegram == tgClient { - delete(p.clients, token) - } - p.clientsMux.Unlock() - }() - - if err := tgClient.Run(c.ctx, func(ctx context.Context) error { - status, err := tgClient.Auth().Status(ctx) - if err != nil { - return err - } - if status.Authorized { - // Ok. - } else { - if _, err := tgClient.Auth().Bot(ctx, token.String()); err != nil { - return err - } - } - - // Done. - select { - case initializationResult <- nil: - default: - } - - <-ctx.Done() - return ctx.Err() - }); err != nil { - // Failed. - select { - case initializationResult <- err: - default: - } - } - }() - - // Waiting for initialization. - select { - case err := <-initializationResult: - if err != nil { - 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.telegram) - 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) - } -} - -type Options struct { - AppID int - AppHash string - Log *zap.Logger - Storage StateStorage -} - -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) { - p := &Pool{ - appID: opt.AppID, - appHash: opt.AppHash, - log: opt.Log, - clients: map[Token]*client{}, - storage: opt.Storage, - } - return p, nil -}