fix: retry on dimension failure for tg media upload (#1409)
This commit is contained in:
parent
cefa140bd2
commit
f12c09b767
2 changed files with 149 additions and 1 deletions
|
|
@ -3,6 +3,7 @@ package telegram
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -367,6 +368,20 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
Caption: part.Caption,
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
_, err = c.bot.SendPhoto(ctx, params)
|
_, err = c.bot.SendPhoto(ctx, params)
|
||||||
|
if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") {
|
||||||
|
if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil {
|
||||||
|
file.Close()
|
||||||
|
return fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
|
||||||
|
docParams := &telego.SendDocumentParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
|
Document: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendDocument(ctx, docParams)
|
||||||
|
}
|
||||||
case "audio":
|
case "audio":
|
||||||
params := &telego.SendAudioParams{
|
params := &telego.SendAudioParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
|
@ -14,6 +17,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
|
const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
|
||||||
|
|
@ -37,6 +41,11 @@ func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData)
|
||||||
// stubConstructor implements ta.RequestConstructor for testing.
|
// stubConstructor implements ta.RequestConstructor for testing.
|
||||||
type stubConstructor struct{}
|
type stubConstructor struct{}
|
||||||
|
|
||||||
|
type multipartCall struct {
|
||||||
|
Parameters map[string]string
|
||||||
|
FileSizes map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
|
func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
|
||||||
return &ta.RequestData{}, nil
|
return &ta.RequestData{}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +57,36 @@ func (s *stubConstructor) MultipartRequest(
|
||||||
return &ta.RequestData{}, nil
|
return &ta.RequestData{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type multipartRecordingConstructor struct {
|
||||||
|
stubConstructor
|
||||||
|
calls []multipartCall
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *multipartRecordingConstructor) MultipartRequest(
|
||||||
|
parameters map[string]string,
|
||||||
|
files map[string]ta.NamedReader,
|
||||||
|
) (*ta.RequestData, error) {
|
||||||
|
call := multipartCall{
|
||||||
|
Parameters: make(map[string]string, len(parameters)),
|
||||||
|
FileSizes: make(map[string]int, len(files)),
|
||||||
|
}
|
||||||
|
for k, v := range parameters {
|
||||||
|
call.Parameters[k] = v
|
||||||
|
}
|
||||||
|
for field, file := range files {
|
||||||
|
if file == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
call.FileSizes[field] = len(data)
|
||||||
|
}
|
||||||
|
s.calls = append(s.calls, call)
|
||||||
|
return &ta.RequestData{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
|
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
|
||||||
func successResponse(t *testing.T) *ta.Response {
|
func successResponse(t *testing.T) *ta.Response {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
@ -59,11 +98,19 @@ func successResponse(t *testing.T) *ta.Response {
|
||||||
|
|
||||||
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
|
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
|
||||||
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
|
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
|
||||||
|
return newTestChannelWithConstructor(t, caller, &stubConstructor{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestChannelWithConstructor(
|
||||||
|
t *testing.T,
|
||||||
|
caller *stubCaller,
|
||||||
|
constructor ta.RequestConstructor,
|
||||||
|
) *TelegramChannel {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
bot, err := telego.NewBot(testToken,
|
bot, err := telego.NewBot(testToken,
|
||||||
telego.WithAPICaller(caller),
|
telego.WithAPICaller(caller),
|
||||||
telego.WithRequestConstructor(&stubConstructor{}),
|
telego.WithRequestConstructor(constructor),
|
||||||
telego.WithDiscardLogger(),
|
telego.WithDiscardLogger(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -80,6 +127,92 @@ func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
|
||||||
|
constructor := &multipartRecordingConstructor{}
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(url, "sendPhoto"):
|
||||||
|
return nil, errors.New(`api: 400 "Bad Request: PHOTO_INVALID_DIMENSIONS"`)
|
||||||
|
case strings.Contains(url, "sendDocument"):
|
||||||
|
return successResponse(t), nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected API call: %s", url)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "woodstock-en-10s.png")
|
||||||
|
content := []byte("fake-png-content")
|
||||||
|
require.NoError(t, os.WriteFile(localPath, content, 0o644))
|
||||||
|
|
||||||
|
ref, err := store.Store(
|
||||||
|
localPath,
|
||||||
|
media.MediaMeta{Filename: "woodstock-en-10s.png", ContentType: "image/png"},
|
||||||
|
"scope-1",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Parts: []bus.MediaPart{{
|
||||||
|
Type: "image",
|
||||||
|
Ref: ref,
|
||||||
|
Caption: "caption",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, caller.calls, 2)
|
||||||
|
assert.Contains(t, caller.calls[0].URL, "sendPhoto")
|
||||||
|
assert.Contains(t, caller.calls[1].URL, "sendDocument")
|
||||||
|
require.Len(t, constructor.calls, 2)
|
||||||
|
assert.Equal(t, len(content), constructor.calls[0].FileSizes["photo"])
|
||||||
|
assert.Equal(t, len(content), constructor.calls[1].FileSizes["document"])
|
||||||
|
assert.Equal(t, "caption", constructor.calls[1].Parameters["caption"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
|
||||||
|
constructor := &multipartRecordingConstructor{}
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return nil, errors.New("api: 500 \"server exploded\"")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannelWithConstructor(t, caller, constructor)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
localPath := filepath.Join(tmpDir, "image.png")
|
||||||
|
require.NoError(t, os.WriteFile(localPath, []byte("fake-png-content"), 0o644))
|
||||||
|
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Parts: []bus.MediaPart{{
|
||||||
|
Type: "image",
|
||||||
|
Ref: ref,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, channels.ErrTemporary)
|
||||||
|
require.Len(t, caller.calls, 1)
|
||||||
|
assert.Contains(t, caller.calls[0].URL, "sendPhoto")
|
||||||
|
require.Len(t, constructor.calls, 1)
|
||||||
|
assert.NotContains(t, caller.calls[0].URL, "sendDocument")
|
||||||
|
}
|
||||||
|
|
||||||
func TestSend_EmptyContent(t *testing.T) {
|
func TestSend_EmptyContent(t *testing.T) {
|
||||||
caller := &stubCaller{
|
caller := &stubCaller{
|
||||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue