Repository: mr-linch/go-tg (go)

Issue:
WebhookRequest and WebhookResponse abstractions
For avoid rewriting of Webhook for different routers or environments (e.g. lambda functions) we should provide: 
 - `WebhookRequest` struct 
 - `WebhookResponse` struct 
 - `Webhook.ServeRequest` method 

Pull request description:
feat(tgb): add abstractions around webhook req/res
close #94 

Patch:
diff --git a/README.md b/README.md
index 50a5111..087e150 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@ func run(ctx context.Context) error {
     }, tgb.Command("start", tgb.WithCommandAlias("help"))).
     // handles gopher image
     Message(func(ctx context.Context, msg *tgb.MessageUpdate) error {
-      if err := msg.Update.Respond(ctx, msg.AnswerChatAction(tg.ChatActionUploadPhoto)); err != nil {
+      if err := msg.Update.Reply(ctx, msg.AnswerChatAction(tg.ChatActionUploadPhoto)); err != nil {
         return fmt.Errorf("answer chat action: %w", err)
       }
 
diff --git a/examples/README.md b/examples/README.md
index 8104b9a..41a844d 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -8,7 +8,7 @@ Listen port set to `:8000` by default, you can override it with the `-webhook-li
 | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------- |
 | [calculator](https://github.com/mr-linch/go-tg/tree/main/examples/calculator)             | Inline Keyboard Calculator                                        | Router, Regexp filter, Button Layout                           |
 | [chat-type-filter](https://github.com/mr-linch/go-tg/tree/main/examples/chat-type-filter) | Handle messages from different chats, in different handlers       | ChatType filter                                                |
-| [echo-bot](https://github.com/mr-linch/go-tg/tree/main/examples/echo-bot)                 | Copy original messages, with some special cases                   | ParseMode, Webhook Respond, File Uploading, Regexp filter      |
+| [echo-bot](https://github.com/mr-linch/go-tg/tree/main/examples/echo-bot)                 | Copy original messages, with some special cases                   | ParseMode, Webhook Reply, File Uploading, Regexp filter        |
 | [grayscale-image](https://github.com/mr-linch/go-tg/tree/main/examples/grayscale-image)   | Bot grayscale user sended image                                   | File download, file upload, Message type filter, Custom filter |
 | [media-group](https://github.com/mr-linch/go-tg/tree/main/examples/media-group)           | Send Media group                                                  | File Uploading, Media Group, Regexp filter, Default handler    |
 | [quote-bot](https://github.com/mr-linch/go-tg/tree/main/examples/quote-bot)               | Search via Inline Mode                                            | Inline Mode, Inline Keyboard Markup                            |
diff --git a/examples/echo-bot/main.go b/examples/echo-bot/main.go
index 69f09cb..c6491e4 100644
--- a/examples/echo-bot/main.go
+++ b/examples/echo-bot/main.go
@@ -33,7 +33,7 @@ func main() {
 		}, tgb.Command("start", tgb.WithCommandAlias("help"))).
 		Message(func(ctx context.Context, msg *tgb.MessageUpdate) error {
 			// handles gopher image
-			if err := msg.Update.Respond(ctx, msg.AnswerChatAction(tg.ChatActionUploadPhoto)); err != nil {
+			if err := msg.Update.Reply(ctx, msg.AnswerChatAction(tg.ChatActionUploadPhoto)); err != nil {
 				return fmt.Errorf("answer chat action: %w", err)
 			}
 
@@ -46,7 +46,7 @@ func main() {
 		}, tgb.Regexp(regexp.MustCompile(`(?mi)(go|golang|gopher)[$\s+]?`))).
 		Message(func(ctx context.Context, msg *tgb.MessageUpdate) error {
 			// handle other messages
-			return msg.Copy(msg.Chat).DoVoid(ctx)
+			return msg.Update.Reply(ctx, msg.Copy(msg.Chat))
 		}),
 	)
 }
diff --git a/examples/grayscale-image/main.go b/examples/grayscale-image/main.go
index cc8af03..61f9dfc 100644
--- a/examples/grayscale-image/main.go
+++ b/examples/grayscale-image/main.go
@@ -33,7 +33,7 @@ func main() {
 
 			photo := sizes[0]
 
-			if err := mu.Update.Respond(ctx,
+			if err := mu.Update.Reply(ctx,
 				tg.NewSendChatActionCall(mu.Message.Chat, tg.ChatActionUploadPhoto),
 			); err != nil {
 				return fmt.Errorf("send chat action: %w", err)
diff --git a/examples/session-filter/main.go b/examples/session-filter/main.go
index 2cbf410..5576ad4 100644
--- a/examples/session-filter/main.go
+++ b/examples/session-filter/main.go
@@ -58,11 +58,11 @@ func main() {
 		Message(func(ctx context.Context, msg *tgb.MessageUpdate) error {
 			// handle /start command
 			sessionManager.Get(ctx).Step = SessionStepName
-			return msg.Update.Respond(ctx, msg.Answer("Hi, what is your name?"))
+			return msg.Update.Reply(ctx, msg.Answer("Hi, what is your name?"))
 		}, tgb.Command("start")).
 		Message(func(ctx context.Context, mu *tgb.MessageUpdate) error {
 			// handle no command with SessionStepInitial
-			return mu.Update.Respond(ctx, mu.Answer("Press /start to fill the form"))
+			return mu.Update.Reply(ctx, mu.Answer("Press /start to fill the form"))
 		}, isSessionStep(SessionStepInit)).
 		Message(func(ctx context.Context, msg *tgb.MessageUpdate) error {
 			// handle name input
@@ -71,11 +71,11 @@ func main() {
 			session.Name = msg.Text
 			session.Step = SessionStepAge
 
-			return msg.Update.Respond(ctx, msg.Answer("What is your age?"))
+			return msg.Update.Reply(ctx, msg.Answer("What is your age?"))
 		}, isSessionStep(SessionStepName)).
 		Message(func(ctx context.Context, msg *tgb.MessageUpdate) error {
 			// handle no digit input when state is SessionStepAge
-			return msg.Update.Respond(ctx, msg.Answer("Please, send me just number"))
+			return msg.Update.Reply(ctx, msg.Answer("Please, send me just number"))
 		}, isSessionStep(SessionStepAge), tgb.Not(isDigit)).
 		Message(func(ctx context.Context, msg *tgb.MessageUpdate) error {
 			// handle correct age input
@@ -93,7 +93,7 @@ func main() {
 				buttonLayout.Insert(tg.NewKeyboardButton(gender))
 			}
 
-			return msg.Update.Respond(ctx, msg.Answer("What is your gender?").ReplyMarkup(
+			return msg.Update.Reply(ctx, msg.Answer("What is your gender?").ReplyMarkup(
 				tg.NewReplyKeyboardMarkup(
 					buttonLayout.Keyboard()...,
 				).WithResizeKeyboardMarkup(),
@@ -116,10 +116,10 @@ func main() {
 
 			sessionManager.Reset(session)
 
-			return mu.Update.Respond(ctx, answer)
+			return mu.Update.Reply(ctx, answer)
 		}, isSessionStep(SessionStepGender), tgb.TextIn(genders)).
 		Message(func(ctx context.Context, msg *tgb.MessageUpdate) error {
-			return msg.Update.Respond(ctx, msg.Answer("Please, choose one of the buttons below 👇"))
+			return msg.Update.Reply(ctx, msg.Answer("Please, choose one of the buttons below 👇"))
 		}, isSessionStep(SessionStepGender), tgb.Not(tgb.TextIn(genders))),
 	)
 }
diff --git a/tgb/update.go b/tgb/update.go
index 1d634f3..0993ba1 100644
--- a/tgb/update.go
+++ b/tgb/update.go
@@ -14,9 +14,9 @@ type Update struct {
 	*tg.Update
 	Client *tg.Client
 
-	webhookResponseLock sync.Mutex
-	webhookResponse     chan json.Marshaler
-	webhookResponseSent bool
+	webhookReplyLock sync.Mutex
+	webhookReply     chan json.Marshaler
+	webhookReplySent bool
 }
 
 func newUpdateWebhook(update *tg.Update, client *tg.Client) *Update {
@@ -24,37 +24,45 @@ func newUpdateWebhook(update *tg.Update, client *tg.Client) *Update {
 		Update: update,
 		Client: client,
 
-		webhookResponse:     make(chan json.Marshaler),
-		webhookResponseSent: false,
+		webhookReply:     make(chan json.Marshaler),
+		webhookReplySent: false,
 	}
 }
 
-// UpdateRespond defines interface for responding to an update via Webhook.
-type UpdateRespond interface {
+// UpdateReply defines interface for responding to an update via Webhook.
+type UpdateReply interface {
 	json.Marshaler
 	DoVoid(ctx context.Context) error
 	Bind(client *tg.Client)
 }
 
-// Respond to Webhook, if possible or make usual call via Client.
-func (update *Update) Respond(ctx context.Context, v UpdateRespond) error {
-	update.webhookResponseLock.Lock()
-	defer update.webhookResponseLock.Unlock()
+// Deprecated: use UpdateReply instead.
+type UpdateRespond = UpdateReply
+
+// Reply to Webhook, if possible or make usual call via Client.
+func (update *Update) Reply(ctx context.Context, v UpdateReply) error {
+	update.webhookReplyLock.Lock()
+	defer update.webhookReplyLock.Unlock()
 
-	if update.webhookResponse != nil && !update.webhookResponseSent {
-		update.webhookResponseSent = true
-		update.webhookResponse <- v
+	if update.webhookReply != nil && !update.webhookReplySent {
+		update.webhookReplySent = true
+		update.webhookReply <- v
 		return nil
 	}
 
 	return tg.BindClient(v, update.Client).DoVoid(ctx)
 }
 
-func (update *Update) disableWebhookResponse() {
-	update.webhookResponseLock.Lock()
-	defer update.webhookResponseLock.Unlock()
+// Deprecated: use Reply instead.
+func (update *Update) Respond(ctx context.Context, v UpdateRespond) error {
+	return update.Reply(ctx, v)
+}
+
+func (update *Update) disableWebhookReply() {
+	update.webhookReplyLock.Lock()
+	defer update.webhookReplyLock.Unlock()
 
-	update.webhookResponseSent = true
+	update.webhookReplySent = true
 }
 
 // MessageUpdate it's extend wrapper around tg.Message.
diff --git a/tgb/webhook.go b/tgb/webhook.go
index efab007..80140cf 100644
--- a/tgb/webhook.go
+++ b/tgb/webhook.go
@@ -16,6 +16,9 @@ import (
 	"golang.org/x/exp/slices"
 )
 
+// Webhook is a Telegram bot webhook handler.
+// It handles incoming webhook requests and calls the handler function.
+// It implements the http.Handler interface, but can be adapted to any other handlers, see [Webhook.ServeRequest]
 type Webhook struct {
 	url     string
 	handler Handler
@@ -194,62 +197,83 @@ func (webhook *Webhook) isAllowedIP(ip netip.Addr) bool {
 
 const securityTokenHeader = "X-Telegram-Bot-Api-Secret-Token"
 
-func (webhook *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// check method
+// WebhookRequest is the request received from Telegram.
+type WebhookRequest struct {
+	Method      string
+	ContentType string
+
+	IP            netip.Addr
+	SecurityToken string
+
+	Body io.Reader
+}
+
+// WebhookResponse is the response to be sent to WebhookRequest.
+type WebhookResponse struct {
+	Status      int
+	ContentType string
+	Body        []byte
+}
+
+func (webhook *Webhook) checkRequest(r *WebhookRequest) *WebhookResponse {
 	if r.Method != http.MethodPost {
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-		webhook.log("http error: method not allowed: %s", r.Method)
-		return
+		webhook.log("request with method '%s' was refused", r.Method)
+		return &WebhookResponse{
+			Status:      http.StatusMethodNotAllowed,
+			ContentType: "text/plain",
+			Body:        []byte("method not allowed"),
+		}
 	}
 
-	// check IP
-	ip, err := netip.ParseAddr(realip.FromRequest(r))
-	if err != nil {
-		http.Error(w, "invalid ip", http.StatusBadRequest)
-		webhook.log("http error: invalid ip: %v", err)
-		return
+	if r.ContentType != "application/json" {
+		webhook.log("invalid content type '%s'", r.ContentType)
+		return &WebhookResponse{
+			Status:      http.StatusUnsupportedMediaType,
+			ContentType: "text/plain",
+			Body:        []byte("unsupported media type"),
+		}
 	}
 
-	if !webhook.isAllowedIP(ip) {
-		http.Error(w, "security check failed", http.StatusUnauthorized)
-		webhook.log("http error: security check failed: ip '%s' is not allowed", ip)
-		return
+	if !webhook.isAllowedIP(r.IP) {
+		webhook.log("request from '%s' was refused", r.IP)
+		return &WebhookResponse{
+			Status:      http.StatusForbidden,
+			ContentType: "text/plain",
+			Body:        []byte("security check failed"),
+		}
 	}
 
-	// check token
-	if webhook.securityToken != "" {
-		if securityToken := r.Header.Get(securityTokenHeader); securityToken != webhook.securityToken {
-			http.Error(w, "security check failed", http.StatusUnauthorized)
-			webhook.log("http error: security check failed: token '%s' is not allowed", securityToken)
-			return
+	if webhook.securityToken != "" && r.SecurityToken != webhook.securityToken {
+		webhook.log("request with token '%s' was refused", r.SecurityToken)
+		return &WebhookResponse{
+			Status:      http.StatusForbidden,
+			ContentType: "text/plain",
+			Body:        []byte("security check failed"),
 		}
 	}
 
-	// check content type
-	if r.Header.Get("Content-Type") != "application/json" {
-		http.Error(w, "content type not supported", http.StatusUnsupportedMediaType)
-		webhook.log("http error: content type not supported: %s", r.Header.Get("Content-Type"))
-		return
-	}
+	return nil
+}
 
-	// read body
-	body, err := io.ReadAll(r.Body)
-	if err != nil {
-		http.Error(w, "failed to read body", http.StatusBadRequest)
-		webhook.log("http error: read body failed: %v", err)
-		return
+// ServeRequest is the generic for webhook requests from Telegram.
+func (webhook *Webhook) ServeRequest(ctx context.Context, r *WebhookRequest) *WebhookResponse {
+	if response := webhook.checkRequest(r); response != nil {
+		return response
 	}
 
 	// parse body
 	baseUpdate := &tg.Update{}
-	if err := json.Unmarshal(body, &baseUpdate); err != nil {
-		http.Error(w, "failed to parse body", http.StatusBadRequest)
-		webhook.log("http error: parse body failed: %v", err)
-		return
+	if err := json.NewDecoder(r.Body).Decode(baseUpdate); err != nil {
+		webhook.log("invalid body: %s", err)
+		return &WebhookResponse{
+			Status:      http.StatusBadRequest,
+			ContentType: "text/plain",
+			Body:        []byte("failed to parse body"),
+		}
 	}
 
 	update := newUpdateWebhook(baseUpdate, webhook.client)
-	defer update.disableWebhookResponse()
+	defer update.disableWebhookReply()
 
 	done := make(chan struct{})
 
@@ -266,30 +290,67 @@ func (webhook *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}()
 
 	select {
-	case <-r.Context().Done():
-		return
-	case response := <-update.webhookResponse:
-		w.Header().Set("Content-Type", "application/json")
-		w.WriteHeader(http.StatusOK)
-		if response != nil {
-			body, err := json.Marshal(response)
+	case <-ctx.Done():
+		return &WebhookResponse{
+			Status: http.StatusOK,
+		}
+	case reply := <-update.webhookReply:
+		response := &WebhookResponse{
+			Status: http.StatusOK,
+		}
+
+		if reply != nil {
+			body, err := json.Marshal(reply)
 
 			if err != nil {
 				webhook.log("marshal webhook response error: %v", err)
-				return
+				return response
 			}
 
-			_, err = w.Write(body)
-			if err != nil {
-				webhook.log("write webhook response error: %v", err)
-				return
-			}
+			response.ContentType = "application/json"
+			response.Body = body
 		}
-		return
+
+		return response
 	case <-done:
-		w.WriteHeader(http.StatusOK)
+		return &WebhookResponse{
+			Status: http.StatusOK,
+		}
+	}
+}
+
+// ServeHTTP is the HTTP handler for webhook requests.
+// Implementation of http.Handler.
+func (webhook *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ip, err := netip.ParseAddr(realip.FromRequest(r))
+	if err != nil {
+		webhook.log("failed to parse ip: %s", err)
+		http.Error(w, "failed to parse ip", http.StatusBadRequest)
 		return
 	}
+
+	request := &WebhookRequest{
+		Method:        r.Method,
+		ContentType:   r.Header.Get("Content-Type"),
+		IP:            ip,
+		SecurityToken: r.Header.Get(securityTokenHeader),
+		Body:          r.Body,
+	}
+
+	response := webhook.ServeRequest(r.Context(), request)
+
+	if response.ContentType != "" {
+		w.Header().Set("Content-Type", response.ContentType)
+	}
+
+	if response.Status != 0 {
+		w.WriteHeader(response.Status)
+	}
+
+	if response.Body != nil {
+		_, _ = w.Write(response.Body)
+	}
+
 }
 
 // Run starts the webhook server.


Test patch:
diff --git a/tgb/update_test.go b/tgb/update_test.go
index b78a182..b68bff6 100644
--- a/tgb/update_test.go
+++ b/tgb/update_test.go
@@ -10,64 +10,81 @@ import (
 	"github.com/stretchr/testify/mock"
 )
 
-var _ UpdateRespond = (*MockUpdateRespond)(nil)
+var _ UpdateReply = (*MockUpdateReply)(nil)
 
-type MockUpdateRespond struct {
+type MockUpdateReply struct {
 	mock.Mock
 }
 
-func (mock *MockUpdateRespond) Bind(client *tg.Client) {
+func (mock *MockUpdateReply) Bind(client *tg.Client) {
 	mock.Called(client)
 }
 
-func (mock *MockUpdateRespond) MarshalJSON() ([]byte, error) {
+func (mock *MockUpdateReply) MarshalJSON() ([]byte, error) {
 	args := mock.Called()
 	return args.Get(0).([]byte), args.Error(1)
 }
 
-func (mock *MockUpdateRespond) DoVoid(ctx context.Context) error {
+func (mock *MockUpdateReply) DoVoid(ctx context.Context) error {
 	args := mock.Called(ctx)
 	return args.Error(0)
 }
 
-func TestUpdate_Respond(t *testing.T) {
+func TestUpdate_Reply(t *testing.T) {
 	t.Run("NoWebhook", func(t *testing.T) {
 		client := &tg.Client{}
-		updateRespond := &MockUpdateRespond{}
+		updateReply := &MockUpdateReply{}
 
-		updateRespond.On("Bind", client).Return()
-		updateRespond.On("DoVoid", mock.Anything).Return(nil)
+		updateReply.On("Bind", client).Return()
+		updateReply.On("DoVoid", mock.Anything).Return(nil)
 
 		update := &Update{
 			Client: client,
 		}
 
-		err := update.Respond(context.Background(), updateRespond)
+		err := update.Reply(context.Background(), updateReply)
 		assert.NoError(t, err)
 
-		updateRespond.AssertExpectations(t)
+		updateReply.AssertExpectations(t)
+	})
+
+	t.Run("NoWebhookUseRespond", func(t *testing.T) {
+		client := &tg.Client{}
+		updateReply := &MockUpdateReply{}
+
+		updateReply.On("Bind", client).Return()
+		updateReply.On("DoVoid", mock.Anything).Return(nil)
+
+		update := &Update{
+			Client: client,
+		}
+
+		err := update.Respond(context.Background(), updateReply)
+		assert.NoError(t, err)
+
+		updateReply.AssertExpectations(t)
 	})
 
 	t.Run("Webhook", func(t *testing.T) {
-		updateRespond := &MockUpdateRespond{}
+		updateReply := &MockUpdateReply{}
 
-		updateRespond.On("MarshalJSON", mock.Anything).Return([]byte{}, nil)
+		updateReply.On("MarshalJSON", mock.Anything).Return([]byte{}, nil)
 
 		update := &Update{
-			webhookResponse: make(chan json.Marshaler, 1),
+			webhookReply: make(chan json.Marshaler, 1),
 		}
 
-		err := update.Respond(context.Background(), updateRespond)
+		err := update.Reply(context.Background(), updateReply)
 		assert.NoError(t, err)
 
-		obj := <-update.webhookResponse
+		obj := <-update.webhookReply
 
 		assert.NotNil(t, obj)
 
 		_, err = obj.MarshalJSON()
 		assert.NoError(t, err)
 
-		updateRespond.AssertExpectations(t)
+		updateReply.AssertExpectations(t)
 
 	})
 
diff --git a/tgb/webhook_test.go b/tgb/webhook_test.go
index 0130782..0b4b046 100644
--- a/tgb/webhook_test.go
+++ b/tgb/webhook_test.go
@@ -56,6 +56,7 @@ func TestWebhook_ServeHTTP(t *testing.T) {
 
 		req, err := http.NewRequest(http.MethodGet, "/", strings.NewReader(""))
 		assert.NoError(t, err)
+		req.RemoteAddr = "1.1.1.1"
 
 		webhook := NewWebhook(
 			HandlerFunc(func(ctx context.Context, update *Update) error { return nil }),
@@ -93,6 +94,7 @@ func TestWebhook_ServeHTTP(t *testing.T) {
 		req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(""))
 		assert.NoError(t, err)
 
+		req.Header.Set("Content-Type", "application/json")
 		req.Header.Set("X-Forwarded-For", "1.1.1.1")
 
 		webhook := NewWebhook(
@@ -103,7 +105,7 @@ func TestWebhook_ServeHTTP(t *testing.T) {
 
 		webhook.ServeHTTP(w, req)
 
-		assert.Equal(t, http.StatusUnauthorized, w.Code)
+		assert.Equal(t, http.StatusForbidden, w.Code)
 	})
 
 	t.Run("SecurityCheckToken", func(t *testing.T) {
@@ -114,6 +116,7 @@ func TestWebhook_ServeHTTP(t *testing.T) {
 
 		req.Header.Set(securityTokenHeader, "secret")
 		req.Header.Set("X-Forwarded-For", "1.1.1.1")
+		req.Header.Set("Content-Type", "application/json")
 
 		webhook := NewWebhook(
 			HandlerFunc(func(ctx context.Context, update *Update) error { return nil }),
@@ -124,7 +127,7 @@ func TestWebhook_ServeHTTP(t *testing.T) {
 
 		webhook.ServeHTTP(w, req)
 
-		assert.Equal(t, http.StatusUnauthorized, w.Code)
+		assert.Equal(t, http.StatusForbidden, w.Code)
 	})
 
 	t.Run("InvalidContentType", func(t *testing.T) {
@@ -214,7 +217,7 @@ func TestWebhook_ServeHTTP(t *testing.T) {
 		assert.Equal(t, http.StatusOK, w.Code)
 	})
 
-	t.Run("HandleOKOneRespondCall", func(t *testing.T) {
+	t.Run("HandleOKOneReplyCall", func(t *testing.T) {
 		w := httptest.NewRecorder()
 
 		req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id": 123456, "message": {"chat": {"id": 1234}}}`))
@@ -228,7 +231,7 @@ func TestWebhook_ServeHTTP(t *testing.T) {
 		webhook := NewWebhook(
 			HandlerFunc(func(ctx context.Context, update *Update) error {
 				isHandlerCalled = true
-				return update.Respond(ctx, tg.NewSendMessageCall(update.Message.Chat, "test"))
+				return update.Reply(ctx, tg.NewSendMessageCall(update.Message.Chat, "test"))
 			}),
 			&tg.Client{},
 			"http://test.io/",
@@ -248,7 +251,7 @@ func TestWebhook_ServeHTTP(t *testing.T) {
 		assert.Equal(t, `{"chat_id":"1234","method":"sendMessage","text":"test"}`, string(body))
 	})
 
-	t.Run("HandleOKTwoRespondCall", func(t *testing.T) {
+	t.Run("HandleOKTwoReplyCall", func(t *testing.T) {
 
 		isHandlerCalled := false
 
@@ -257,16 +260,16 @@ func TestWebhook_ServeHTTP(t *testing.T) {
 		webhook := NewWebhook(
 			HandlerFunc(func(ctx context.Context, update *Update) error {
 				// first call should be send response to webhook
-				err := update.Respond(ctx, tg.NewSendMessageCall(update.Message.Chat, "test"))
+				err := update.Reply(ctx, tg.NewSendMessageCall(update.Message.Chat, "test"))
 				assert.NoError(t, err)
 
-				// second call should call UpdateRespond.DoVoid()
-				ur := &MockUpdateRespond{}
+				// second call should call UpdateReply.DoVoid()
+				ur := &MockUpdateReply{}
 
 				ur.On("Bind", mock.Anything).Return()
 				ur.On("DoVoid", mock.Anything).Return(nil)
 
-				err = update.Respond(ctx, ur)
+				err = update.Reply(ctx, ur)
 				assert.NoError(t, err)
 
 				ur.AssertExpectations(t)