From e396b0317573fa00f22dd8bbb69bc17587ced9f6 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 8 Jan 2022 03:27:23 +0300 Subject: [PATCH 1/8] fix(botdoc): use int64 for "suggested_tip_amounts" field --- botdoc/oas.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/botdoc/oas.go b/botdoc/oas.go index 19190a2..d0a004e 100644 --- a/botdoc/oas.go +++ b/botdoc/oas.go @@ -492,8 +492,7 @@ Schemas: } p["/"+m.Name] = item } - addMissedProperties(c.Schemas) - return &ogen.Spec{ + return patchSchema(&ogen.Spec{ OpenAPI: "3.0.3", Info: ogen.Info{ Title: "Telegram Bot API", @@ -509,7 +508,33 @@ Schemas: }, Paths: p, Components: c, - }, nil + }), nil +} + +func patchSchema(spec *ogen.Spec) *ogen.Spec { + c := spec.Components + addMissedProperties(c.Schemas) + setItemsFormat := func(typeName, propName, format string) { + sendInvoice := c.Schemas[typeName] + props := sendInvoice.Properties + + var handled bool + for i := range props { + p := props[i] + if p.Name == propName { + props[i].Schema.Items.Format = format + handled = true + break + } + } + if !handled { + panic(fmt.Sprintf("property %q of %q not found", propName, typeName)) + } + } + // MTProto uses int64, use it in BotAPI too to reduce copying. + setItemsFormat("sendInvoice", "suggested_tip_amounts", "int64") + setItemsFormat("InputInvoiceMessageContent", "suggested_tip_amounts", "int64") + return spec } func addMissedProperties(schemas map[string]*ogen.Schema) { From 5bff04a881c569a60c55806cd956744a27bf9a50 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 8 Jan 2022 09:18:51 +0300 Subject: [PATCH 2/8] chore: commit generated files --- _oas/openapi.json | 6 ++++-- internal/oas/oas_json_gen.go | 16 ++++++++-------- internal/oas/oas_schemas_gen.go | 4 ++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/_oas/openapi.json b/_oas/openapi.json index c23da7c..01cd88a 100644 --- a/_oas/openapi.json +++ b/_oas/openapi.json @@ -4634,7 +4634,8 @@ "description": "A JSON-serialized array of suggested amounts of tip in the smallest units of the currency (integer, not float/double). At most 4 suggested tip amounts can be specified. The suggested tip amounts must be positive, passed in a strictly increased order and must not exceed max_tip_amount", "type": "array", "items": { - "type": "integer" + "type": "integer", + "format": "int64" } }, "provider_data": { @@ -8760,7 +8761,8 @@ "description": "A JSON-serialized array of suggested amounts of tips in the smallest units of the currency (integer, not float/double). At most 4 suggested tip amounts can be specified. The suggested tip amounts must be positive, passed in a strictly increased order and must not exceed max_tip_amount", "type": "array", "items": { - "type": "integer" + "type": "integer", + "format": "int64" } }, "start_parameter": { diff --git a/internal/oas/oas_json_gen.go b/internal/oas/oas_json_gen.go index 9d3f774..18f6f10 100644 --- a/internal/oas/oas_json_gen.go +++ b/internal/oas/oas_json_gen.go @@ -7691,7 +7691,7 @@ func (s InputInvoiceMessageContent) Encode(e *jx.Encoder) { e.FieldStart("suggested_tip_amounts") e.ArrStart() for _, elem := range s.SuggestedTipAmounts { - e.Int(elem) + e.Int64(elem) } e.ArrEnd() } @@ -7803,9 +7803,9 @@ func (s *InputInvoiceMessageContent) Decode(d *jx.Decoder) error { case "suggested_tip_amounts": s.SuggestedTipAmounts = nil if err := d.Arr(func(d *jx.Decoder) error { - var elem int - v, err := d.Int() - elem = int(v) + var elem int64 + v, err := d.Int64() + elem = int64(v) if err != nil { return err } @@ -15041,7 +15041,7 @@ func (s SendInvoice) Encode(e *jx.Encoder) { e.FieldStart("suggested_tip_amounts") e.ArrStart() for _, elem := range s.SuggestedTipAmounts { - e.Int(elem) + e.Int64(elem) } e.ArrEnd() } @@ -15181,9 +15181,9 @@ func (s *SendInvoice) Decode(d *jx.Decoder) error { case "suggested_tip_amounts": s.SuggestedTipAmounts = nil if err := d.Arr(func(d *jx.Decoder) error { - var elem int - v, err := d.Int() - elem = int(v) + var elem int64 + v, err := d.Int64() + elem = int64(v) if err != nil { return err } diff --git a/internal/oas/oas_schemas_gen.go b/internal/oas/oas_schemas_gen.go index 78b0ba5..32f01f8 100644 --- a/internal/oas/oas_schemas_gen.go +++ b/internal/oas/oas_schemas_gen.go @@ -2068,7 +2068,7 @@ type InputInvoiceMessageContent struct { Currency string `json:"currency"` Prices []LabeledPrice `json:"prices"` MaxTipAmount OptInt `json:"max_tip_amount"` - SuggestedTipAmounts []int `json:"suggested_tip_amounts"` + SuggestedTipAmounts []int64 `json:"suggested_tip_amounts"` ProviderData OptString `json:"provider_data"` PhotoURL OptString `json:"photo_url"` PhotoSize OptInt `json:"photo_size"` @@ -6596,7 +6596,7 @@ type SendInvoice struct { Currency string `json:"currency"` Prices []LabeledPrice `json:"prices"` MaxTipAmount OptInt `json:"max_tip_amount"` - SuggestedTipAmounts []int `json:"suggested_tip_amounts"` + SuggestedTipAmounts []int64 `json:"suggested_tip_amounts"` StartParameter OptString `json:"start_parameter"` ProviderData OptString `json:"provider_data"` PhotoURL OptString `json:"photo_url"` From 3fc7e795844725fd34454c05765ebc46664ff715 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 8 Jan 2022 10:18:14 +0300 Subject: [PATCH 3/8] refactor(botdoc): add updateProperty function --- botdoc/oas.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/botdoc/oas.go b/botdoc/oas.go index d0a004e..02e9277 100644 --- a/botdoc/oas.go +++ b/botdoc/oas.go @@ -514,22 +514,22 @@ Schemas: func patchSchema(spec *ogen.Spec) *ogen.Spec { c := spec.Components addMissedProperties(c.Schemas) - setItemsFormat := func(typeName, propName, format string) { - sendInvoice := c.Schemas[typeName] - props := sendInvoice.Properties + updateProperty := func(typeName, propName string, cb func(p *ogen.Property)) { + schema := c.Schemas[typeName] + props := schema.Properties - var handled bool for i := range props { - p := props[i] - if p.Name == propName { - props[i].Schema.Items.Format = format - handled = true - break + if props[i].Name == propName { + cb(&props[i]) + return } } - if !handled { - panic(fmt.Sprintf("property %q of %q not found", propName, typeName)) - } + panic(fmt.Sprintf("property %q of %q not found", propName, typeName)) + } + setItemsFormat := func(typeName, propName, format string) { + updateProperty(typeName, propName, func(p *ogen.Property) { + p.Schema.Items.Format = format + }) } // MTProto uses int64, use it in BotAPI too to reduce copying. setItemsFormat("sendInvoice", "suggested_tip_amounts", "int64") From 4595ff9192debd33f94a265d5fd5c4027158a2e0 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 8 Jan 2022 10:19:04 +0300 Subject: [PATCH 4/8] feat(botapi): implement DeleteChatPhoto --- internal/botapi/chat.go | 25 ++++++++++++++++++++++++- internal/botapi/chat_test.go | 20 ++++++++++++++++++-- internal/botapi/unimplemented_test.go | 6 ------ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/internal/botapi/chat.go b/internal/botapi/chat.go index 3b3038e..da22698 100644 --- a/internal/botapi/chat.go +++ b/internal/botapi/chat.go @@ -56,7 +56,30 @@ func (b *BotAPI) DeclineChatJoinRequest(ctx context.Context, req oas.DeclineChat // DeleteChatPhoto implements oas.Handler. func (b *BotAPI) DeleteChatPhoto(ctx context.Context, req oas.DeleteChatPhoto) (oas.Result, error) { - return oas.Result{}, &NotImplementedError{} + p, err := b.resolveIDToChat(ctx, req.ChatID) + if err != nil { + return oas.Result{}, errors.Wrap(err, "resolve chatID") + } + + switch p := p.(type) { + case peers.Channel: + _, err = b.raw.ChannelsEditPhoto(ctx, &tg.ChannelsEditPhotoRequest{ + Channel: p.InputChannel(), + Photo: &tg.InputChatPhotoEmpty{}, + }) + case peers.Chat: + _, err = b.raw.MessagesEditChatPhoto(ctx, &tg.MessagesEditChatPhotoRequest{ + ChatID: p.ID(), + Photo: &tg.InputChatPhotoEmpty{}, + }) + default: + return oas.Result{}, errors.Errorf("unexpected type %T", p) + } + if err != nil { + return oas.Result{}, errors.Wrap(err, "delete photo") + } + + return resultOK(true), nil } // DeleteChatStickerSet implements oas.Handler. diff --git a/internal/botapi/chat_test.go b/internal/botapi/chat_test.go index 7f9b0d9..2a444dc 100644 --- a/internal/botapi/chat_test.go +++ b/internal/botapi/chat_test.go @@ -4,10 +4,9 @@ import ( "context" "testing" - "github.com/stretchr/testify/require" - "github.com/gotd/td/tg" "github.com/gotd/td/tgmock" + "github.com/stretchr/testify/require" "github.com/gotd/botapi/internal/oas" ) @@ -195,3 +194,20 @@ func TestBotAPI_LeaveChat(t *testing.T) { a.NoError(err) }) } + +func TestBotAPI_DeleteChatPhoto(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + mock.ExpectCall(&tg.ChannelsEditPhotoRequest{ + Channel: &tg.InputChannel{ + ChannelID: testChannel().ID, + AccessHash: testChannel().AccessHash, + }, + Photo: &tg.InputChatPhotoEmpty{}, + }).ThenTrue() + _, err := api.DeleteChatPhoto(ctx, oas.DeleteChatPhoto{ + ChatID: oas.NewInt64ID(testChannelID()), + }) + a.NoError(err) + }) +} diff --git a/internal/botapi/unimplemented_test.go b/internal/botapi/unimplemented_test.go index 92e5485..299bf19 100644 --- a/internal/botapi/unimplemented_test.go +++ b/internal/botapi/unimplemented_test.go @@ -70,12 +70,6 @@ func TestUnimplemented(t *testing.T) { a.ErrorAs(err, &implErr) } - { - _, err := b.DeleteChatPhoto(ctx, oas.DeleteChatPhoto{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - { _, err := b.DeleteMessage(ctx, oas.DeleteMessage{}) var implErr *NotImplementedError From af6e8f0c23323e9cffecf38dfe545212efd6554a Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 8 Jan 2022 11:41:52 +0300 Subject: [PATCH 5/8] test(botapi): fix returning type --- internal/botapi/chat_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/botapi/chat_test.go b/internal/botapi/chat_test.go index 2a444dc..2e3a433 100644 --- a/internal/botapi/chat_test.go +++ b/internal/botapi/chat_test.go @@ -204,7 +204,7 @@ func TestBotAPI_DeleteChatPhoto(t *testing.T) { AccessHash: testChannel().AccessHash, }, Photo: &tg.InputChatPhotoEmpty{}, - }).ThenTrue() + }).ThenResult(&tg.Updates{}) _, err := api.DeleteChatPhoto(ctx, oas.DeleteChatPhoto{ ChatID: oas.NewInt64ID(testChannelID()), }) From 1ce11f11a3f07553adefbe6fbf15d8fc3070470d Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 8 Jan 2022 12:17:57 +0300 Subject: [PATCH 6/8] feat(botapi): implement some sending methods without uploading --- internal/botapi/convert_message.go | 49 +-- internal/botapi/message.go | 5 - internal/botapi/send.go | 169 +---------- internal/botapi/send_action.go | 53 ++++ .../{send_test.go => send_action_test.go} | 3 +- internal/botapi/send_media.go | 52 ++++ internal/botapi/send_other.go | 274 +++++++++++++++++ internal/botapi/send_other_test.go | 287 ++++++++++++++++++ internal/botapi/unimplemented_test.go | 24 -- 9 files changed, 670 insertions(+), 246 deletions(-) create mode 100644 internal/botapi/send_action.go rename internal/botapi/{send_test.go => send_action_test.go} (99%) create mode 100644 internal/botapi/send_media.go create mode 100644 internal/botapi/send_other.go create mode 100644 internal/botapi/send_other_test.go diff --git a/internal/botapi/convert_message.go b/internal/botapi/convert_message.go index 3903484..404bc88 100644 --- a/internal/botapi/convert_message.go +++ b/internal/botapi/convert_message.go @@ -2,7 +2,6 @@ package botapi import ( "context" - "strconv" "github.com/go-faster/errors" "go.uber.org/zap" @@ -360,54 +359,10 @@ func (b *BotAPI) convertMessageMedia(ctx context.Context, media tg.MessageMediaC 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), - ) + resultPoll, ok := b.convertToBotAPIPoll(ctx, media) + if !ok { 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{ diff --git a/internal/botapi/message.go b/internal/botapi/message.go index f00a821..587de66 100644 --- a/internal/botapi/message.go +++ b/internal/botapi/message.go @@ -40,8 +40,3 @@ func (b *BotAPI) EditMessageText(ctx context.Context, req oas.EditMessageText) ( 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/send.go b/internal/botapi/send.go index 3242262..c8ea9b7 100644 --- a/internal/botapi/send.go +++ b/internal/botapi/send.go @@ -4,11 +4,10 @@ import ( "context" "github.com/go-faster/errors" - "github.com/gotd/td/telegram/message" + "github.com/gotd/td/telegram/message/html" "github.com/gotd/td/telegram/peers" - "github.com/gotd/td/telegram/message/html" "github.com/gotd/td/telegram/message/unpack" "github.com/gotd/td/tg" @@ -95,137 +94,6 @@ func (b *BotAPI) prepareSend( return s, p, nil } -// 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) { - p, err := b.resolveID(ctx, req.ChatID) - if err != nil { - return oas.Result{}, errors.Wrap(err, "resolve chatID") - } - - s := b.sender.To(p.InputPeer()).TypingAction() - progress := 0 - switch req.Action { - case "cancel": - err = s.Cancel(ctx) - case "typing": - err = s.Typing(ctx) - case "record_video": - err = s.RecordVideo(ctx) - case "upload_video": - err = s.UploadVideo(ctx, progress) - case "record_audio", "record_voice": - err = s.RecordAudio(ctx) - case "upload_audio", "upload_voice": - err = s.UploadVideo(ctx, progress) - case "upload_photo": - err = s.UploadPhoto(ctx, progress) - case "upload_document": - err = s.UploadDocument(ctx, progress) - case "choose_sticker": - err = s.ChooseSticker(ctx) - case "pick_up_location", "find_location": - err = s.GeoLocation(ctx) - case "record_video_note": - err = s.RecordRound(ctx) - case "upload_video_note": - err = s.UploadRound(ctx, progress) - default: - return oas.Result{}, &BadRequestError{"Wrong parameter action in request"} - } - if err != nil { - return oas.Result{}, errors.Wrap(err, "send action") - } - - return resultOK(true), nil -} - -// 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) { - s, p, err := b.prepareSend( - ctx, - sendOpts{ - To: req.ChatID, - DisableNotification: req.DisableNotification, - ProtectContent: req.ProtectContent, - ReplyToMessageID: req.ReplyToMessageID, - AllowSendingWithoutReply: req.AllowSendingWithoutReply, - ReplyMarkup: req.ReplyMarkup, - }, - ) - if err != nil { - return oas.ResultMessage{}, errors.Wrap(err, "prepare send") - } - resp, err := s.Media(ctx, message.MediaDice(req.Emoji.Or("🎲"))) - return b.sentMessage(ctx, p, resp, err) -} - -// 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) { - var markup oas.OptSendReplyMarkup - if m, ok := req.ReplyMarkup.Get(); ok { - markup.SetTo(oas.SendReplyMarkup{ - Type: oas.InlineKeyboardMarkupSendReplyMarkup, - InlineKeyboardMarkup: m, - }) - } - - s, p, err := b.prepareSend( - ctx, - sendOpts{ - To: oas.NewInt64ID(req.ChatID), - DisableNotification: req.DisableNotification, - ProtectContent: req.ProtectContent, - ReplyToMessageID: req.ReplyToMessageID, - AllowSendingWithoutReply: req.AllowSendingWithoutReply, - ReplyMarkup: markup, - }, - ) - if err != nil { - return oas.ResultMessage{}, errors.Wrap(err, "prepare send") - } - - resp, err := s.Media(ctx, message.Game(&tg.InputGameShortName{ - BotID: &tg.InputUserSelf{}, - ShortName: req.GameShortName, - })) - return b.sentMessage(ctx, p, resp, err) -} - -// 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() @@ -260,38 +128,3 @@ func (b *BotAPI) SendMessage(ctx context.Context, req oas.SendMessage) (oas.Resu return b.sentMessage(ctx, p, resp, err) } - -// 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/send_action.go b/internal/botapi/send_action.go new file mode 100644 index 0000000..ebfeaeb --- /dev/null +++ b/internal/botapi/send_action.go @@ -0,0 +1,53 @@ +package botapi + +import ( + "context" + + "github.com/go-faster/errors" + + "github.com/gotd/botapi/internal/oas" +) + +// SendChatAction implements oas.Handler. +func (b *BotAPI) SendChatAction(ctx context.Context, req oas.SendChatAction) (oas.Result, error) { + p, err := b.resolveID(ctx, req.ChatID) + if err != nil { + return oas.Result{}, errors.Wrap(err, "resolve chatID") + } + + s := b.sender.To(p.InputPeer()).TypingAction() + progress := 0 + switch req.Action { + case "cancel": + err = s.Cancel(ctx) + case "typing": + err = s.Typing(ctx) + case "record_video": + err = s.RecordVideo(ctx) + case "upload_video": + err = s.UploadVideo(ctx, progress) + case "record_audio", "record_voice": + err = s.RecordAudio(ctx) + case "upload_audio", "upload_voice": + err = s.UploadVideo(ctx, progress) + case "upload_photo": + err = s.UploadPhoto(ctx, progress) + case "upload_document": + err = s.UploadDocument(ctx, progress) + case "choose_sticker": + err = s.ChooseSticker(ctx) + case "pick_up_location", "find_location": + err = s.GeoLocation(ctx) + case "record_video_note": + err = s.RecordRound(ctx) + case "upload_video_note": + err = s.UploadRound(ctx, progress) + default: + return oas.Result{}, &BadRequestError{"Wrong parameter action in request"} + } + if err != nil { + return oas.Result{}, errors.Wrap(err, "send action") + } + + return resultOK(true), nil +} diff --git a/internal/botapi/send_test.go b/internal/botapi/send_action_test.go similarity index 99% rename from internal/botapi/send_test.go rename to internal/botapi/send_action_test.go index b6c56a5..bebbd13 100644 --- a/internal/botapi/send_test.go +++ b/internal/botapi/send_action_test.go @@ -4,10 +4,9 @@ import ( "context" "testing" - "github.com/stretchr/testify/require" - "github.com/gotd/td/tg" "github.com/gotd/td/tgmock" + "github.com/stretchr/testify/require" "github.com/gotd/botapi/internal/oas" ) diff --git a/internal/botapi/send_media.go b/internal/botapi/send_media.go new file mode 100644 index 0000000..7cc86e4 --- /dev/null +++ b/internal/botapi/send_media.go @@ -0,0 +1,52 @@ +package botapi + +import ( + "context" + + "github.com/gotd/botapi/internal/oas" +) + +// SendAnimation implements oas.Handler. +func (b *BotAPI) SendAnimation(ctx context.Context, req oas.SendAnimation) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendAudio implements oas.Handler. +func (b *BotAPI) SendAudio(ctx context.Context, req oas.SendAudio) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} + +// SendDocument implements oas.Handler. +func (b *BotAPI) SendDocument(ctx context.Context, req oas.SendDocument) (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{} +} + +// SendPhoto implements oas.Handler. +func (b *BotAPI) SendPhoto(ctx context.Context, req oas.SendPhoto) (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{} +} + +// 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/send_other.go b/internal/botapi/send_other.go new file mode 100644 index 0000000..4bab3a8 --- /dev/null +++ b/internal/botapi/send_other.go @@ -0,0 +1,274 @@ +package botapi + +import ( + "context" + + "github.com/go-faster/errors" + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/tg" + + "github.com/gotd/botapi/internal/oas" +) + +// SendContact implements oas.Handler. +func (b *BotAPI) SendContact(ctx context.Context, req oas.SendContact) (oas.ResultMessage, error) { + s, p, err := b.prepareSend( + ctx, + sendOpts{ + To: req.ChatID, + DisableNotification: req.DisableNotification, + ProtectContent: req.ProtectContent, + ReplyToMessageID: req.ReplyToMessageID, + AllowSendingWithoutReply: req.AllowSendingWithoutReply, + ReplyMarkup: req.ReplyMarkup, + }, + ) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "prepare send") + } + resp, err := s.Media(ctx, message.Contact(tg.InputMediaContact{ + PhoneNumber: req.PhoneNumber, + FirstName: req.FirstName, + LastName: req.LastName.Value, + Vcard: req.Vcard.Value, + })) + return b.sentMessage(ctx, p, resp, err) +} + +// SendDice implements oas.Handler. +func (b *BotAPI) SendDice(ctx context.Context, req oas.SendDice) (oas.ResultMessage, error) { + s, p, err := b.prepareSend( + ctx, + sendOpts{ + To: req.ChatID, + DisableNotification: req.DisableNotification, + ProtectContent: req.ProtectContent, + ReplyToMessageID: req.ReplyToMessageID, + AllowSendingWithoutReply: req.AllowSendingWithoutReply, + ReplyMarkup: req.ReplyMarkup, + }, + ) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "prepare send") + } + resp, err := s.Media(ctx, message.MediaDice(req.Emoji.Or("🎲"))) + return b.sentMessage(ctx, p, resp, err) +} + +func parseInlineKeyboardMarkup(r oas.OptInlineKeyboardMarkup) oas.OptSendReplyMarkup { + var markup oas.OptSendReplyMarkup + if m, ok := r.Get(); ok { + markup.SetTo(oas.SendReplyMarkup{ + Type: oas.InlineKeyboardMarkupSendReplyMarkup, + InlineKeyboardMarkup: m, + }) + } + return markup +} + +// SendGame implements oas.Handler. +func (b *BotAPI) SendGame(ctx context.Context, req oas.SendGame) (oas.ResultMessage, error) { + s, p, err := b.prepareSend( + ctx, + sendOpts{ + To: oas.NewInt64ID(req.ChatID), + DisableNotification: req.DisableNotification, + ProtectContent: req.ProtectContent, + ReplyToMessageID: req.ReplyToMessageID, + AllowSendingWithoutReply: req.AllowSendingWithoutReply, + ReplyMarkup: parseInlineKeyboardMarkup(req.ReplyMarkup), + }, + ) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "prepare send") + } + resp, err := s.Media(ctx, message.Game(&tg.InputGameShortName{ + // TDLib uses self user. + // + // See https://github.com/tdlib/td/blob/fa8feefed70d64271945e9d5fd010b957d93c8cd/td/telegram/Game.cpp#L93. + BotID: &tg.InputUserSelf{}, + ShortName: req.GameShortName, + })) + return b.sentMessage(ctx, p, resp, err) +} + +// SendInvoice implements oas.Handler. +func (b *BotAPI) SendInvoice(ctx context.Context, req oas.SendInvoice) (oas.ResultMessage, error) { + s, p, err := b.prepareSend( + ctx, + sendOpts{ + To: req.ChatID, + DisableNotification: req.DisableNotification, + ProtectContent: req.ProtectContent, + ReplyToMessageID: req.ReplyToMessageID, + AllowSendingWithoutReply: req.AllowSendingWithoutReply, + ReplyMarkup: parseInlineKeyboardMarkup(req.ReplyMarkup), + }, + ) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "prepare send") + } + invoice := tg.Invoice{ + Test: false, + NameRequested: req.NeedName.Value, + PhoneRequested: req.NeedPhoneNumber.Value, + EmailRequested: req.NeedEmail.Value, + ShippingAddressRequested: req.NeedShippingAddress.Value, + Flexible: req.IsFlexible.Value, + PhoneToProvider: req.SendPhoneNumberToProvider.Value, + EmailToProvider: req.SendEmailToProvider.Value, + Currency: req.Currency, + Prices: make([]tg.LabeledPrice, len(req.Prices)), + MaxTipAmount: 0, + SuggestedTipAmounts: req.SuggestedTipAmounts, + } + invoice.SetFlags() + { + to := invoice.Prices + from := req.Prices + + for i := range from { + to[i] = tg.LabeledPrice{ + Label: from[i].Label, + Amount: int64(from[i].Amount), + } + } + invoice.Prices = to + } + + if v, ok := req.MaxTipAmount.Get(); ok { + invoice.SetMaxTipAmount(int64(v)) + } + media := &tg.InputMediaInvoice{ + Title: req.Title, + Description: req.Description, + Photo: tg.InputWebDocument{}, + Invoice: invoice, + Payload: []byte(req.Payload), + Provider: req.ProviderToken, + ProviderData: tg.DataJSON{ + Data: req.ProviderData.Value, + }, + } + if u, ok := req.PhotoURL.Get(); ok { + doc := tg.InputWebDocument{ + URL: u, + Size: req.PhotoSize.Value, + // TODO(tdakkota): Port TDLib extension parser. + // + // See https://github.com/tdlib/td/blob/fa8feefed70d64271945e9d5fd010b957d93c8cd/td/telegram/Payments.cpp#L877 + MimeType: "image/jpeg", + } + if w, h := req.PhotoWidth.Value, req.PhotoHeight.Value; w != 0 && h != 0 { + doc.Attributes = append(doc.Attributes, &tg.DocumentAttributeImageSize{ + W: req.PhotoWidth.Value, + H: req.PhotoHeight.Value, + }) + } + media.SetPhoto(doc) + } + if v, ok := req.StartParameter.Get(); ok { + media.SetStartParam(v) + } + resp, err := s.Media(ctx, message.Media(media)) + return b.sentMessage(ctx, p, resp, err) +} + +// SendLocation implements oas.Handler. +func (b *BotAPI) SendLocation(ctx context.Context, req oas.SendLocation) (oas.ResultMessage, error) { + s, p, err := b.prepareSend( + ctx, + sendOpts{ + To: req.ChatID, + DisableNotification: req.DisableNotification, + ProtectContent: req.ProtectContent, + ReplyToMessageID: req.ReplyToMessageID, + AllowSendingWithoutReply: req.AllowSendingWithoutReply, + ReplyMarkup: req.ReplyMarkup, + }, + ) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "prepare send") + } + + point := &tg.InputGeoPoint{ + Lat: req.Latitude, + Long: req.Longitude, + AccuracyRadius: 0, + } + if v, ok := req.HorizontalAccuracy.Get(); ok { + point.SetAccuracyRadius(int(v)) + } + var media tg.InputMediaClass + if livePeriod, ok := req.LivePeriod.Get(); ok { + live := &tg.InputMediaGeoLive{ + Stopped: false, + GeoPoint: point, + Heading: 0, + Period: 0, + ProximityNotificationRadius: 0, + } + live.SetPeriod(livePeriod) + if v, ok := req.Heading.Get(); ok { + live.SetHeading(v) + } + if v, ok := req.ProximityAlertRadius.Get(); ok { + live.SetProximityNotificationRadius(v) + } + media = live + } else { + media = &tg.InputMediaGeoPoint{ + GeoPoint: point, + } + } + + resp, err := s.Media(ctx, message.Media(media)) + return b.sentMessage(ctx, p, resp, err) +} + +// SendVenue implements oas.Handler. +func (b *BotAPI) SendVenue(ctx context.Context, req oas.SendVenue) (oas.ResultMessage, error) { + s, p, err := b.prepareSend( + ctx, + sendOpts{ + To: req.ChatID, + DisableNotification: req.DisableNotification, + ProtectContent: req.ProtectContent, + ReplyToMessageID: req.ReplyToMessageID, + AllowSendingWithoutReply: req.AllowSendingWithoutReply, + ReplyMarkup: req.ReplyMarkup, + }, + ) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "prepare send") + } + + point := &tg.InputGeoPoint{ + Lat: req.Latitude, + Long: req.Longitude, + AccuracyRadius: 0, + } + media := &tg.InputMediaVenue{ + GeoPoint: point, + Title: req.Title, + Address: req.Address, + } + if id, typ := req.FoursquareID.Value, req.FoursquareType.Value; id != "" || typ != "" { + media.Provider = "foursquare" + media.VenueID = id + media.VenueType = typ + } + if id, typ := req.GooglePlaceID.Value, req.GooglePlaceType.Value; id != "" || typ != "" { + media.Provider = "gplaces" + media.VenueID = id + media.VenueType = typ + } + + resp, err := s.Media(ctx, message.Media(media)) + return b.sentMessage(ctx, p, resp, err) +} + +// SendPoll implements oas.Handler. +func (b *BotAPI) SendPoll(ctx context.Context, req oas.SendPoll) (oas.ResultMessage, error) { + return oas.ResultMessage{}, &NotImplementedError{} +} diff --git a/internal/botapi/send_other_test.go b/internal/botapi/send_other_test.go new file mode 100644 index 0000000..06b8d87 --- /dev/null +++ b/internal/botapi/send_other_test.go @@ -0,0 +1,287 @@ +package botapi + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgmock" + "github.com/stretchr/testify/require" + + "github.com/gotd/botapi/internal/oas" +) + +func testSentMedia(a *require.Assertions, mock *tgmock.Mock, media tg.InputMediaClass) { + mock.ExpectFunc(func(b bin.Encoder) { + r := b.(*tg.MessagesSendMediaRequest) + a.Equal(&tg.InputPeerChat{ChatID: testChat().ID}, r.Peer) + + setFlags(media) + setFlags(r.Media) + a.Equal(media, r.Media) + }).ThenResult(&tg.Updates{ + Updates: []tg.UpdateClass{ + &tg.UpdateNewMessage{ + Message: &tg.Message{ + Out: false, + ID: 10, + PeerID: &tg.PeerChat{ChatID: testChat().ID}, + }, + }, + }, + }) +} + +func TestBotAPI_SendContact(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + testSentMedia(a, mock, &tg.InputMediaContact{ + PhoneNumber: "aboba", + FirstName: "aboba", + LastName: "aboba", + Vcard: "aboba", + }) + + msg, err := api.SendContact(ctx, oas.SendContact{ + ChatID: oas.NewInt64ID(testChatID()), + PhoneNumber: "aboba", + FirstName: "aboba", + LastName: oas.NewOptString("aboba"), + Vcard: oas.NewOptString("aboba"), + }) + a.NoError(err) + a.Equal(10, msg.Result.Value.MessageID) + }) +} + +func TestBotAPI_SendDice(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + testSendDice := func(expect string, input oas.OptString) { + testSentMedia(a, mock, &tg.InputMediaDice{ + Emoticon: expect, + }) + + msg, err := api.SendDice(ctx, oas.SendDice{ + ChatID: oas.NewInt64ID(testChatID()), + Emoji: input, + }) + a.NoError(err) + a.Equal(10, msg.Result.Value.MessageID) + } + + // Ensure setting default. + testSendDice(message.DiceEmoticon, oas.OptString{}) + testSendDice(message.BowlingEmoticon, oas.NewOptString(message.BowlingEmoticon)) + }) +} + +func TestBotAPI_SendInvoice(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + invoice := tg.Invoice{ + Test: false, + NameRequested: true, + PhoneRequested: true, + EmailRequested: true, + ShippingAddressRequested: true, + Flexible: true, + PhoneToProvider: true, + EmailToProvider: true, + Currency: "currency", + Prices: []tg.LabeledPrice{ + { + Label: "label", + Amount: 10, + }, + }, + MaxTipAmount: 10, + SuggestedTipAmounts: []int64{1, 2, 3}, + } + invoice.SetFlags() + testSentMedia(a, mock, &tg.InputMediaInvoice{ + Title: "title", + Description: "description", + Photo: tg.InputWebDocument{ + URL: "photo URL", + Size: 10, + MimeType: "image/jpeg", + Attributes: []tg.DocumentAttributeClass{ + &tg.DocumentAttributeImageSize{ + W: 10, + H: 10, + }, + }, + }, + Invoice: invoice, + Payload: []byte(`payload`), + Provider: "provider", + ProviderData: tg.DataJSON{ + Data: "provider data", + }, + StartParam: "start parameter", + }) + + msg, err := api.SendInvoice(ctx, oas.SendInvoice{ + ChatID: oas.NewInt64ID(testChatID()), + Title: "title", + Description: "description", + Payload: "payload", + ProviderToken: "provider", + Currency: "currency", + Prices: []oas.LabeledPrice{ + { + Label: "label", + Amount: 10, + }, + }, + MaxTipAmount: oas.NewOptInt(10), + SuggestedTipAmounts: []int64{1, 2, 3}, + StartParameter: oas.NewOptString("start parameter"), + ProviderData: oas.NewOptString("provider data"), + PhotoURL: oas.NewOptString("photo URL"), + PhotoSize: oas.NewOptInt(10), + PhotoWidth: oas.NewOptInt(10), + PhotoHeight: oas.NewOptInt(10), + NeedName: oas.NewOptBool(true), + NeedPhoneNumber: oas.NewOptBool(true), + NeedEmail: oas.NewOptBool(true), + NeedShippingAddress: oas.NewOptBool(true), + SendPhoneNumberToProvider: oas.NewOptBool(true), + SendEmailToProvider: oas.NewOptBool(true), + IsFlexible: oas.NewOptBool(true), + DisableNotification: oas.OptBool{}, + ProtectContent: oas.OptBool{}, + ReplyToMessageID: oas.OptInt{}, + AllowSendingWithoutReply: oas.OptBool{}, + ReplyMarkup: oas.OptInlineKeyboardMarkup{}, + }) + a.NoError(err) + a.Equal(10, msg.Result.Value.MessageID) + }) +} + +func TestBotAPI_SendLocation(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + testSendLocation := func(expect tg.InputMediaClass, input oas.SendLocation) { + testSentMedia(a, mock, expect) + + msg, err := api.SendLocation(ctx, input) + a.NoError(err) + a.Equal(10, msg.Result.Value.MessageID) + } + + p := &tg.InputGeoPoint{ + Lat: 10, + Long: 10, + AccuracyRadius: 10, + } + p.SetFlags() + testSendLocation(&tg.InputMediaGeoPoint{ + GeoPoint: p, + }, oas.SendLocation{ + ChatID: oas.NewInt64ID(testChatID()), + Latitude: 10, + Longitude: 10, + HorizontalAccuracy: oas.NewOptFloat64(10), + }) + testSendLocation(&tg.InputMediaGeoLive{ + Stopped: false, + GeoPoint: p, + Heading: 10, + Period: 10, + ProximityNotificationRadius: 10, + }, oas.SendLocation{ + ChatID: oas.NewInt64ID(testChatID()), + Latitude: 10, + Longitude: 10, + HorizontalAccuracy: oas.NewOptFloat64(10), + LivePeriod: oas.NewOptInt(10), + Heading: oas.NewOptInt(10), + ProximityAlertRadius: oas.NewOptInt(10), + DisableNotification: oas.OptBool{}, + ProtectContent: oas.OptBool{}, + ReplyToMessageID: oas.OptInt{}, + AllowSendingWithoutReply: oas.OptBool{}, + ReplyMarkup: oas.OptSendReplyMarkup{}, + }) + }) +} + +func TestBotAPI_SendVenue(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + type testOptions struct { + FoursquareID oas.OptString + FoursquareType oas.OptString + GooglePlaceID oas.OptString + GooglePlaceType oas.OptString + } + testSendVenue := func(expect *tg.InputMediaVenue, opts testOptions) { + testSentMedia(a, mock, expect) + + msg, err := api.SendVenue(ctx, oas.SendVenue{ + ChatID: oas.NewInt64ID(testChatID()), + Latitude: 10, + Longitude: 10, + Title: "title", + Address: "address", + FoursquareID: opts.FoursquareID, + FoursquareType: opts.FoursquareType, + GooglePlaceID: opts.GooglePlaceID, + GooglePlaceType: opts.GooglePlaceType, + DisableNotification: oas.OptBool{}, + ProtectContent: oas.OptBool{}, + ReplyToMessageID: oas.OptInt{}, + AllowSendingWithoutReply: oas.OptBool{}, + ReplyMarkup: oas.OptSendReplyMarkup{}, + }) + a.NoError(err) + a.Equal(10, msg.Result.Value.MessageID) + } + + p := &tg.InputGeoPoint{ + Lat: 10, + Long: 10, + } + p.SetFlags() + testSendVenue(&tg.InputMediaVenue{ + GeoPoint: p, + Title: "title", + Address: "address", + Provider: "foursquare", + VenueID: "venue_id", + VenueType: "venue_type", + }, testOptions{ + FoursquareID: oas.NewOptString("venue_id"), + FoursquareType: oas.NewOptString("venue_type"), + }) + testSendVenue(&tg.InputMediaVenue{ + GeoPoint: p, + Title: "title", + Address: "address", + Provider: "gplaces", + VenueID: "venue_id", + VenueType: "venue_type", + }, testOptions{ + GooglePlaceID: oas.NewOptString("venue_id"), + GooglePlaceType: oas.NewOptString("venue_type"), + }) + testSendVenue(&tg.InputMediaVenue{ + GeoPoint: p, + Title: "title", + Address: "address", + Provider: "gplaces", + VenueID: "venue_id", + VenueType: "venue_type", + }, testOptions{ + FoursquareID: oas.NewOptString("venue_id"), + FoursquareType: oas.NewOptString("venue_type"), + GooglePlaceID: oas.NewOptString("venue_id"), + GooglePlaceType: oas.NewOptString("venue_type"), + }) + }) +} diff --git a/internal/botapi/unimplemented_test.go b/internal/botapi/unimplemented_test.go index 299bf19..a95aecb 100644 --- a/internal/botapi/unimplemented_test.go +++ b/internal/botapi/unimplemented_test.go @@ -196,30 +196,12 @@ func TestUnimplemented(t *testing.T) { a.ErrorAs(err, &implErr) } - { - _, err := b.SendContact(ctx, oas.SendContact{}) - var implErr *NotImplementedError - a.ErrorAs(err, &implErr) - } - { _, err := b.SendDocument(ctx, oas.SendDocument{}) 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 @@ -244,12 +226,6 @@ func TestUnimplemented(t *testing.T) { 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 From 5ca5bae673116cd1d9e2e22d928046baa0fb8f72 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sat, 8 Jan 2022 12:18:46 +0300 Subject: [PATCH 7/8] feat(botapi): implement sendPoll --- internal/botapi/poll.go | 156 ++++++++++++++++++++++++++ internal/botapi/poll_test.go | 11 ++ internal/botapi/send_other.go | 5 - internal/botapi/unimplemented_test.go | 6 - 4 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 internal/botapi/poll.go create mode 100644 internal/botapi/poll_test.go diff --git a/internal/botapi/poll.go b/internal/botapi/poll.go new file mode 100644 index 0000000..add00c0 --- /dev/null +++ b/internal/botapi/poll.go @@ -0,0 +1,156 @@ +package botapi + +import ( + "context" + "strconv" + "strings" + + "github.com/go-faster/errors" + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/telegram/message/entity" + "github.com/gotd/td/telegram/message/html" + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "github.com/gotd/botapi/internal/oas" +) + +var _pollOptions = []byte(`0123456789`) + +func pollOption(i int) []byte { + if i < len(_pollOptions) { + return _pollOptions[i : i+1] + } + return []byte{byte(i)} +} + +func (b *BotAPI) convertToBotAPIPoll(ctx context.Context, media *tg.MessageMediaPoll) (oas.Poll, bool) { + 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.Int64("poll_id", poll.ID), + zap.Int("answers", a), + zap.Int("results", r), + ) + return oas.Poll{}, false + } + + 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, + }) + } + + return resultPoll, true +} + +// SendPoll implements oas.Handler. +func (b *BotAPI) SendPoll(ctx context.Context, req oas.SendPoll) (oas.ResultMessage, error) { + s, p, err := b.prepareSend( + ctx, + sendOpts{ + To: req.ChatID, + DisableNotification: req.DisableNotification, + ProtectContent: req.ProtectContent, + ReplyToMessageID: req.ReplyToMessageID, + AllowSendingWithoutReply: req.AllowSendingWithoutReply, + ReplyMarkup: req.ReplyMarkup, + }, + ) + if err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "prepare send") + } + + answers := make([]tg.PollAnswer, len(req.Options)) + for i, opt := range req.Options { + answers[i] = tg.PollAnswer{ + Text: opt, + Option: pollOption(i), + } + } + + poll := tg.Poll{ + Closed: req.IsClosed.Value, + PublicVoters: !req.IsAnonymous.Value, + MultipleChoice: req.AllowsMultipleAnswers.Value, + Quiz: req.Type.Value == "quiz", + Question: req.Question, + Answers: answers, + CloseDate: 0, + } + if v, ok := req.CloseDate.Get(); ok { + poll.SetCloseDate(v) + } + media := &tg.InputMediaPoll{ + Poll: poll, + CorrectAnswers: nil, + Solution: "", + SolutionEntities: nil, + } + if v, ok := req.CorrectOptionID.Get(); ok { + if v < len(answers) { + // See https://github.com/tdlib/td/blob/fa8feefed70d64271945e9d5fd010b957d93c8cd/td/telegram/MessageContent.cpp#L1898. + return oas.ResultMessage{}, &BadRequestError{Message: "Wrong correct option ID specified"} + } + media.CorrectAnswers = [][]byte{answers[v].Option} + } + if v, ok := req.Explanation.Get(); ok { + // FIXME(tdakkota): get entities from request. + parseMode, isParseModeSet := req.ExplanationParseMode.Get() + if isParseModeSet && parseMode != "HTML" { + return oas.ResultMessage{}, &NotImplementedError{Message: "only HTML formatting is supported"} + } + + var builder entity.Builder + if err := html.HTML(strings.NewReader(v), &builder, html.Options{ + UserResolver: b.peers.UserResolveHook(ctx), + DisableTelegramEscape: false, + }); err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "parse explanation") + } + text, entities := builder.Complete() + media.SetSolution(text) + media.SetSolutionEntities(entities) + } + + resp, err := s.Media(ctx, message.Media(media)) + return b.sentMessage(ctx, p, resp, err) +} + +// 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/poll_test.go b/internal/botapi/poll_test.go new file mode 100644 index 0000000..779de86 --- /dev/null +++ b/internal/botapi/poll_test.go @@ -0,0 +1,11 @@ +package botapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_pollOption(t *testing.T) { + require.Equal(t, []byte{'0'}, pollOption(0)) +} diff --git a/internal/botapi/send_other.go b/internal/botapi/send_other.go index 4bab3a8..f9ed7ba 100644 --- a/internal/botapi/send_other.go +++ b/internal/botapi/send_other.go @@ -267,8 +267,3 @@ func (b *BotAPI) SendVenue(ctx context.Context, req oas.SendVenue) (oas.ResultMe resp, err := s.Media(ctx, message.Media(media)) return b.sentMessage(ctx, p, resp, err) } - -// SendPoll implements oas.Handler. -func (b *BotAPI) SendPoll(ctx context.Context, req oas.SendPoll) (oas.ResultMessage, error) { - return oas.ResultMessage{}, &NotImplementedError{} -} diff --git a/internal/botapi/unimplemented_test.go b/internal/botapi/unimplemented_test.go index a95aecb..18a60ef 100644 --- a/internal/botapi/unimplemented_test.go +++ b/internal/botapi/unimplemented_test.go @@ -214,12 +214,6 @@ func TestUnimplemented(t *testing.T) { 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 From 5e3625fb445509281b7bad8eac21f0ed0b9703d1 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Sun, 9 Jan 2022 03:05:58 +0300 Subject: [PATCH 8/8] test(botapi): add sendPoll test --- internal/botapi/chat_member_test.go | 5 +++ internal/botapi/chat_test.go | 26 ++++++++++-- internal/botapi/errors_map.go | 14 +++--- internal/botapi/errors_test.go | 18 ++++++++ internal/botapi/poll.go | 40 +++++++++++------ internal/botapi/poll_test.go | 66 +++++++++++++++++++++++++++++ internal/botapi/send.go | 1 + internal/botapi/send_action_test.go | 3 +- internal/botapi/send_other.go | 1 + internal/botapi/send_other_test.go | 3 +- 10 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 internal/botapi/errors_test.go diff --git a/internal/botapi/chat_member_test.go b/internal/botapi/chat_member_test.go index ee4d1b3..3e46dc8 100644 --- a/internal/botapi/chat_member_test.go +++ b/internal/botapi/chat_member_test.go @@ -14,6 +14,11 @@ import ( func TestBotAPI_GetChatMemberCount(t *testing.T) { ctx := context.Background() testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + _, err := api.GetChatMemberCount(ctx, oas.GetChatMemberCount{ + ChatID: oas.NewStringID(`aboba`), + }) + a.Error(err) + r, err := api.GetChatMemberCount(ctx, oas.GetChatMemberCount{ ChatID: oas.NewInt64ID(testChatID()), }) diff --git a/internal/botapi/chat_test.go b/internal/botapi/chat_test.go index 2e3a433..d404704 100644 --- a/internal/botapi/chat_test.go +++ b/internal/botapi/chat_test.go @@ -4,9 +4,10 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/gotd/td/tg" "github.com/gotd/td/tgmock" - "github.com/stretchr/testify/require" "github.com/gotd/botapi/internal/oas" ) @@ -86,11 +87,17 @@ func Test_convertToBotAPIChatPermissions(t *testing.T) { func TestBotAPI_SetChatDescription(t *testing.T) { ctx := context.Background() testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + _, err := api.SetChatDescription(ctx, oas.SetChatDescription{ + ChatID: oas.NewStringID(`aboba`), + Description: oas.OptString{}, + }) + a.Error(err) + mock.ExpectCall(&tg.MessagesEditChatAboutRequest{ Peer: &tg.InputPeerChat{ChatID: 10}, About: "", }).ThenTrue() - _, err := api.SetChatDescription(ctx, oas.SetChatDescription{ + _, err = api.SetChatDescription(ctx, oas.SetChatDescription{ ChatID: oas.NewInt64ID(testChatID()), Description: oas.OptString{}, }) @@ -169,11 +176,17 @@ func TestBotAPI_GetChat(t *testing.T) { func TestBotAPI_SetChatTitle(t *testing.T) { ctx := context.Background() testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + _, err := api.SetChatTitle(ctx, oas.SetChatTitle{ + ChatID: oas.NewStringID(`aboba`), + Title: "aboba", + }) + a.Error(err) + mock.ExpectCall(&tg.MessagesEditChatTitleRequest{ ChatID: testChat().ID, Title: "aboba", }).ThenResult(&tg.Updates{}) - _, err := api.SetChatTitle(ctx, oas.SetChatTitle{ + _, err = api.SetChatTitle(ctx, oas.SetChatTitle{ ChatID: oas.NewInt64ID(testChatID()), Title: "aboba", }) @@ -184,11 +197,16 @@ func TestBotAPI_SetChatTitle(t *testing.T) { func TestBotAPI_LeaveChat(t *testing.T) { ctx := context.Background() testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + _, err := api.LeaveChat(ctx, oas.LeaveChat{ + ChatID: oas.NewStringID(`aboba`), + }) + a.Error(err) + mock.ExpectCall(&tg.MessagesDeleteChatUserRequest{ ChatID: testChat().ID, UserID: &tg.InputUserSelf{}, }).ThenResult(&tg.Updates{}) - _, err := api.LeaveChat(ctx, oas.LeaveChat{ + _, err = api.LeaveChat(ctx, oas.LeaveChat{ ChatID: oas.NewInt64ID(testChatID()), }) a.NoError(err) diff --git a/internal/botapi/errors_map.go b/internal/botapi/errors_map.go index 7bd0404..8559d47 100644 --- a/internal/botapi/errors_map.go +++ b/internal/botapi/errors_map.go @@ -140,13 +140,15 @@ func (b *BotAPI) NewError(ctx context.Context, err error) (r oas.ErrorStatusCode return resp } -// NotFound is default not found handler. -func NotFound(w http.ResponseWriter, _ *http.Request) { - apiError := errorOf(http.StatusNotFound) - +var encodedNotFoundError = func() []byte { e := jx.GetEncoder() defer jx.PutEncoder(e) - apiError.Encode(e) - _, _ = e.WriteTo(w) + errorOf(http.StatusNotFound).Encode(e) + return e.Bytes() +}() + +// NotFound is default not found handler. +func NotFound(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(encodedNotFoundError) } diff --git a/internal/botapi/errors_test.go b/internal/botapi/errors_test.go new file mode 100644 index 0000000..c3e898a --- /dev/null +++ b/internal/botapi/errors_test.go @@ -0,0 +1,18 @@ +package botapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBadRequestError_Error(t *testing.T) { + msg := "hello" + require.Equal(t, (&BadRequestError{Message: msg}).Error(), msg) +} + +func TestNotImplementedError_Error(t *testing.T) { + msg := "hello" + require.Equal(t, (&NotImplementedError{Message: msg}).Error(), msg) + require.NotEmpty(t, (&NotImplementedError{}).Error()) +} diff --git a/internal/botapi/poll.go b/internal/botapi/poll.go index add00c0..3ed624e 100644 --- a/internal/botapi/poll.go +++ b/internal/botapi/poll.go @@ -6,11 +6,12 @@ import ( "strings" "github.com/go-faster/errors" + "go.uber.org/zap" + "github.com/gotd/td/telegram/message" "github.com/gotd/td/telegram/message/entity" "github.com/gotd/td/telegram/message/html" "github.com/gotd/td/tg" - "go.uber.org/zap" "github.com/gotd/botapi/internal/oas" ) @@ -109,11 +110,20 @@ func (b *BotAPI) SendPoll(ctx context.Context, req oas.SendPoll) (oas.ResultMess Quiz: req.Type.Value == "quiz", Question: req.Question, Answers: answers, + ClosePeriod: 0, CloseDate: 0, } - if v, ok := req.CloseDate.Get(); ok { + poll.SetFlags() + + if v, ok := req.OpenPeriod.Get(); ok { + // Prefer open_period. + // + // See https://github.com/tdlib/td/blob/fa8feefed70d64271945e9d5fd010b957d93c8cd/td/telegram/MessageContent.cpp#L1914-L1916. + poll.SetClosePeriod(v) + } else if v, ok := req.CloseDate.Get(); ok { poll.SetCloseDate(v) } + media := &tg.InputMediaPoll{ Poll: poll, CorrectAnswers: nil, @@ -121,29 +131,33 @@ func (b *BotAPI) SendPoll(ctx context.Context, req oas.SendPoll) (oas.ResultMess SolutionEntities: nil, } if v, ok := req.CorrectOptionID.Get(); ok { - if v < len(answers) { + if v < 0 || v >= len(answers) { // See https://github.com/tdlib/td/blob/fa8feefed70d64271945e9d5fd010b957d93c8cd/td/telegram/MessageContent.cpp#L1898. return oas.ResultMessage{}, &BadRequestError{Message: "Wrong correct option ID specified"} } media.CorrectAnswers = [][]byte{answers[v].Option} } - if v, ok := req.Explanation.Get(); ok { + if explanation, ok := req.Explanation.Get(); ok { // FIXME(tdakkota): get entities from request. parseMode, isParseModeSet := req.ExplanationParseMode.Get() if isParseModeSet && parseMode != "HTML" { return oas.ResultMessage{}, &NotImplementedError{Message: "only HTML formatting is supported"} } - var builder entity.Builder - if err := html.HTML(strings.NewReader(v), &builder, html.Options{ - UserResolver: b.peers.UserResolveHook(ctx), - DisableTelegramEscape: false, - }); err != nil { - return oas.ResultMessage{}, errors.Wrap(err, "parse explanation") + if isParseModeSet { + var builder entity.Builder + if err := html.HTML(strings.NewReader(explanation), &builder, html.Options{ + UserResolver: b.peers.UserResolveHook(ctx), + DisableTelegramEscape: false, + }); err != nil { + return oas.ResultMessage{}, errors.Wrap(err, "parse explanation") + } + text, entities := builder.Complete() + media.SetSolution(text) + media.SetSolutionEntities(entities) + } else { + media.SetSolution(explanation) } - text, entities := builder.Complete() - media.SetSolution(text) - media.SetSolutionEntities(entities) } resp, err := s.Media(ctx, message.Media(media)) diff --git a/internal/botapi/poll_test.go b/internal/botapi/poll_test.go index 779de86..a318709 100644 --- a/internal/botapi/poll_test.go +++ b/internal/botapi/poll_test.go @@ -1,11 +1,77 @@ 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 Test_pollOption(t *testing.T) { require.Equal(t, []byte{'0'}, pollOption(0)) } + +func TestBotAPI_SendPoll(t *testing.T) { + ctx := context.Background() + testWithCache(t, func(a *require.Assertions, mock *tgmock.Mock, api *BotAPI) { + poll := tg.Poll{ + Closed: true, + PublicVoters: false, + MultipleChoice: true, + Quiz: true, + Question: "question", + Answers: []tg.PollAnswer{ + { + Option: pollOption(0), + Text: "0", + }, + { + Option: pollOption(1), + Text: "1", + }, + }, + ClosePeriod: 10, + CloseDate: 0, + } + poll.SetFlags() + testSentMedia(a, mock, &tg.InputMediaPoll{ + Poll: poll, + CorrectAnswers: [][]byte{ + pollOption(0), + }, + Solution: "solution", + SolutionEntities: []tg.MessageEntityClass{ + &tg.MessageEntityBold{ + Offset: 0, + Length: len("solution"), + }, + }, + }) + + _, err := api.SendPoll(ctx, oas.SendPoll{ + ChatID: oas.NewInt64ID(testChatID()), + Question: "question", + Options: []string{"0", "1"}, + IsAnonymous: oas.NewOptBool(true), + Type: oas.NewOptString("quiz"), + AllowsMultipleAnswers: oas.NewOptBool(true), + CorrectOptionID: oas.NewOptInt(0), + Explanation: oas.NewOptString(`solution`), + ExplanationParseMode: oas.NewOptString(`HTML`), + OpenPeriod: oas.NewOptInt(10), + CloseDate: oas.NewOptInt(1337), + IsClosed: oas.NewOptBool(true), + DisableNotification: oas.OptBool{}, + ProtectContent: oas.OptBool{}, + ReplyToMessageID: oas.OptInt{}, + AllowSendingWithoutReply: oas.OptBool{}, + ReplyMarkup: oas.OptSendReplyMarkup{}, + }) + a.NoError(err) + }) +} diff --git a/internal/botapi/send.go b/internal/botapi/send.go index c8ea9b7..d3ee896 100644 --- a/internal/botapi/send.go +++ b/internal/botapi/send.go @@ -4,6 +4,7 @@ import ( "context" "github.com/go-faster/errors" + "github.com/gotd/td/telegram/message" "github.com/gotd/td/telegram/message/html" "github.com/gotd/td/telegram/peers" diff --git a/internal/botapi/send_action_test.go b/internal/botapi/send_action_test.go index bebbd13..b6c56a5 100644 --- a/internal/botapi/send_action_test.go +++ b/internal/botapi/send_action_test.go @@ -4,9 +4,10 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/gotd/td/tg" "github.com/gotd/td/tgmock" - "github.com/stretchr/testify/require" "github.com/gotd/botapi/internal/oas" ) diff --git a/internal/botapi/send_other.go b/internal/botapi/send_other.go index f9ed7ba..0428162 100644 --- a/internal/botapi/send_other.go +++ b/internal/botapi/send_other.go @@ -4,6 +4,7 @@ import ( "context" "github.com/go-faster/errors" + "github.com/gotd/td/telegram/message" "github.com/gotd/td/tg" diff --git a/internal/botapi/send_other_test.go b/internal/botapi/send_other_test.go index 06b8d87..84ccfef 100644 --- a/internal/botapi/send_other_test.go +++ b/internal/botapi/send_other_test.go @@ -4,11 +4,12 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/gotd/td/bin" "github.com/gotd/td/telegram/message" "github.com/gotd/td/tg" "github.com/gotd/td/tgmock" - "github.com/stretchr/testify/require" "github.com/gotd/botapi/internal/oas" )