Skip to content

Commit

Permalink
Interceptors (#141)
Browse files Browse the repository at this point in the history
* feat: client call interceptor mvp

* feat: retry internal and flood interceptor

* finish interceptors concept

* fix coverage
  • Loading branch information
mr-linch authored Mar 12, 2024
1 parent ac8b430 commit 5cf133d
Show file tree
Hide file tree
Showing 11 changed files with 699 additions and 9 deletions.
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- [Helper methods](#helper-methods)
- [Sending files](#sending-files)
- [Downloading files](#downloading-files)
- [Interceptors](#interceptors)
- [Updates](#updates)
- [Handlers](#handlers)
- [Typed Handlers](#typed-handlers)
Expand Down Expand Up @@ -350,6 +351,47 @@ defer f.Close()
// ...
```

### Interceptors

Interceptors are used to modify or process the request before it is sent to the server and the response before it is returned to the caller. It's like a [[tgb.Middleware](https://pkg.go.dev/github.com/mr-linch/go-tg/tgb#Middleware)], but for outgoing requests.

All interceptors should be registered on the client before the request is made.

```go
client := tg.New("<TOKEN>",
tg.WithClientInterceptors(
tg.Interceptor(func(ctx context.Context, req *tg.Request, dst any, invoker tg.InterceptorInvoker) error {
started := time.Now()

// before request
err := invoker(ctx, req, dst)
// after request

log.Print("call %s took %s", req.Method, time.Since(started))

return err
}),
),
)
```


Arguments of the interceptor are:
- `ctx` - context of the request;
- `req` - request object [tg.Request](https://pkg.go.dev/github.com/mr-linch/go-tg#Request);
- `dst` - pointer to destination for the response, can be `nil` if the request is made with `DoVoid` method;
- `invoker` - function for calling the next interceptor or the actual request.

Contrib package has some useful interceptors:
- [InterceptorRetryFloodError](https://pkg.go.dev/github.com/mr-linch/go-tg#NewInterceptorRetryFloodError) - retry request if the server returns a flood error. Parameters can be customized via options;
- [InterceptorRetryInternalServerError](https://pkg.go.dev/github.com/mr-linch/go-tg#NewInterceptorRetryInternalServerError) - retry request if the server returns an error. Parameters can be customized via options;
- [InterceptorMethodFilter](https://pkg.go.dev/github.com/mr-linch/go-tg#NewInterceptorMethodFilter) - call underlying interceptor only for specified methods;
- [InterceptorDefaultParseMethod](https://pkg.go.dev/github.com/mr-linch/go-tg#NewInterceptorDefaultParseMethod) - set default `parse_mode` for messages if not specified.

Interceptors are called in the order they are registered.

Example of using retry flood interceptor: [examples/retry-flood](https://github.com/mr-linch/go-tg/blob/main/examples/retry-flood/main.go)

## Updates

Everything related to receiving and processing updates is in the [`tgb`](https://pkg.go.dev/github.com/mr-linch/go-tg/tgb) package.
Expand Down
32 changes: 31 additions & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type Client struct {
// contains cached bot info
me *User
meLock sync.Mutex

interceptors []Interceptor
invoker InterceptorInvoker
}

// ClientOption is a function that sets some option for Client.
Expand Down Expand Up @@ -62,6 +65,13 @@ func WithClientTestEnv() ClientOption {
}
}

// WithClientInterceptor adds interceptor to client.
func WithClientInterceptors(ints ...Interceptor) ClientOption {
return func(c *Client) {
c.interceptors = append(c.interceptors, ints...)
}
}

// New creates new Client with given token and options.
func New(token string, options ...ClientOption) *Client {
c := &Client{
Expand All @@ -78,9 +88,25 @@ func New(token string, options ...ClientOption) *Client {
option(c)
}

c.invoker = c.buildInvoker()

return c
}

func (client *Client) buildInvoker() InterceptorInvoker {
invoker := client.invoke

for i := len(client.interceptors) - 1; i >= 0; i-- {
invoker = func(next InterceptorInvoker, interceptor Interceptor) InterceptorInvoker {
return func(ctx context.Context, req *Request, dst any) error {
return interceptor(ctx, req, dst, next)
}
}(invoker, client.interceptors[i])
}

return invoker
}

func (client *Client) Token() string {
return client.token
}
Expand Down Expand Up @@ -245,7 +271,7 @@ func (client *Client) executeStreaming(
}
}

func (client *Client) Do(ctx context.Context, req *Request, dst interface{}) error {
func (client *Client) invoke(ctx context.Context, req *Request, dst any) error {
res, err := client.execute(ctx, req)
if err != nil {
return fmt.Errorf("execute: %w", err)
Expand All @@ -268,6 +294,10 @@ func (client *Client) Do(ctx context.Context, req *Request, dst interface{}) err
return nil
}

func (client *Client) Do(ctx context.Context, req *Request, dst interface{}) error {
return client.invoker(ctx, req, dst)
}

// Download file by path from Client.GetFile method.
// Don't forget to close ReadCloser.
func (client *Client) Download(ctx context.Context, path string) (io.ReadCloser, error) {
Expand Down
32 changes: 32 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,35 @@ func TestClient_Execute(t *testing.T) {
}
})
}

func TestClientInterceptors(t *testing.T) {
t.Run("Simple", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/bot1234:secret/getMe", r.URL.Path)

w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":5556648742,"is_bot":true,"first_name":"go_tg_local_bot","username":"go_tg_local_bot","can_join_groups":true,"can_read_all_group_messages":false,"supports_inline_queries":false}}`))
}))

defer ts.Close()

calls := 0

client := New(
"1234:secret",
WithClientDoer(ts.Client()),
WithClientServerURL(ts.URL),
WithClientInterceptors(func(ctx context.Context, req *Request, dst any, invoker InterceptorInvoker) error {
calls++
return invoker(ctx, req, dst)
}),
)
ctx := context.Background()

err := client.Do(ctx, NewRequest("getMe"), nil)

assert.NoError(t, err)
assert.Equal(t, 1, calls)
})
}
8 changes: 6 additions & 2 deletions examples/echo-bot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func main() {
tg.HTML.Line("Send me a message and I will echo it back to you. Also you can send me a reaction and I will react with the same emoji."),
tg.HTML.Italic("🚀 Powered by", tg.HTML.Spoiler("go-tg")),
),
).ParseMode(tg.HTML).LinkPreviewOptions(tg.LinkPreviewOptions{
).LinkPreviewOptions(tg.LinkPreviewOptions{
URL: "https://github.com/mr-linch/go-tg",
PreferLargeMedia: true,
}).DoVoid(ctx)
Expand All @@ -41,7 +41,11 @@ func main() {
return fmt.Errorf("answer chat action: %w", err)
}

time.Sleep(time.Second)
select {
case <-time.After(1 * time.Second):
case <-ctx.Done():
return ctx.Err()
}

return msg.AnswerPhoto(tg.NewFileArgUpload(
tg.NewInputFileBytes("gopher.png", gopherPNG),
Expand Down
74 changes: 74 additions & 0 deletions examples/retry-flood/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"context"
"log"
"sync"
"time"

"github.com/mr-linch/go-tg"
"github.com/mr-linch/go-tg/examples"
"github.com/mr-linch/go-tg/tgb"
)

func main() {
pm := tg.HTML

onStart := func(ctx context.Context, msg *tgb.MessageUpdate) error {
return msg.Answer(pm.Text(
"👋 Hi, I'm retry flood demo, send me /spam command for start.",
"🔁 I will retry when receive flood wait error",
"Stop spam with shutdown bot service",
)).DoVoid(ctx)
}

onSpam := func(ctx context.Context, mu *tgb.MessageUpdate) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
var wg sync.WaitGroup

for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := mu.Answer(pm.Text("🔁 spamming...")).DoVoid(ctx); err != nil {
log.Printf("answer: %v", err)
}
}()
}

wg.Wait()
}
}
}

examples.Run(tgb.NewRouter().
Message(onSpam, tgb.Command("spam")).
ChannelPost(onSpam, tgb.Command("spam")).
Message(onStart).
ChannelPost(onStart).
Error(func(ctx context.Context, update *tgb.Update, err error) error {
log.Printf("error in handler: %v", err)
return nil
}),

tg.WithClientInterceptors(
tg.Interceptor(func(ctx context.Context, req *tg.Request, dst any, invoker tg.InterceptorInvoker) error {
defer func(started time.Time) {
log.Printf("request: %s took: %s", req.Method, time.Since(started))
}(time.Now())
return invoker(ctx, req, dst)
}),
tg.NewInterceptorRetryFloodError(
// we override the default timeAfter function to log the retry flood delay
tg.WithInterceptorRetryFloodErrorTimeAfter(func(sleep time.Duration) <-chan time.Time {
log.Printf("retry flood error after %s", sleep)
return time.After(sleep)
}),
),
),
)
}
19 changes: 13 additions & 6 deletions examples/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,24 @@ import (

// Run runs bot with given router.
// Exit on error.
func Run(handler tgb.Handler) {
func Run(handler tgb.Handler, opts ...tg.ClientOption) {
ctx := context.Background()

ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, os.Kill, syscall.SIGTERM)
defer cancel()

if err := run(ctx, handler); err != nil {
if err := run(ctx, handler, nil, opts...); err != nil {
log.Printf("error: %v", err)
defer os.Exit(1)
}
}

func run(ctx context.Context, handler tgb.Handler) error {
func run(
ctx context.Context,
handler tgb.Handler,
do func(ctx context.Context, client *tg.Client) error,
opts ...tg.ClientOption,
) error {
// define flags
var (
flagToken string
Expand All @@ -49,9 +54,9 @@ func run(ctx context.Context, handler tgb.Handler) error {
return fmt.Errorf("token is required, provide it with -token flag")
}

opts := []tg.ClientOption{
opts = append(opts,
tg.WithClientServerURL(flagServer),
}
)

if flagTestEnv {
opts = append(opts, tg.WithClientTestEnv())
Expand All @@ -66,7 +71,9 @@ func run(ctx context.Context, handler tgb.Handler) error {

log.Printf("authorized as %s", me.Username.Link())

if flagWebhookURL != "" {
if do != nil {
return do(ctx, client)
} else if flagWebhookURL != "" {
err = tgb.NewWebhook(
handler,
client,
Expand Down
Loading

0 comments on commit 5cf133d

Please sign in to comment.