(new): rich message support
Golang lint / lint (pull_request) Successful in 1m20s
Golang lint / lint (push) Successful in 4m8s

(fix): runtime reliability
(tests): regression coverage
(doc): v1.1 release notes
This commit is contained in:
2026-08-12 16:34:44 +03:00
parent 48ddf66540
commit f03a081ed6
83 changed files with 6122 additions and 1925 deletions
+192
View File
@@ -1,6 +1,7 @@
package tgapi
import (
"context"
"errors"
"fmt"
"io"
@@ -105,6 +106,17 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
}
}
func TestUploaderRejectsDirectRichMessageDraftUpload(t *testing.T) {
uploader := &Uploader{}
_, err := uploader.SendRichMessageDraft(
SendRichMessageDraft{ChatID: 42, DraftID: 1},
NewUploaderFile("photo.jpg", []byte("photo")),
)
if !errors.Is(err, ErrRichMessageDraftUploadUnsupported) {
t.Fatalf("expected ErrRichMessageDraftUploadUnsupported, got %v", err)
}
}
func TestUploaderSurfacesResponseErrorForTelegramFailure(t *testing.T) {
const responseBody = `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
@@ -177,6 +189,141 @@ func TestNewUploaderFileDetectsFileTypeCaseInsensitively(t *testing.T) {
}
}
func TestUploaderSendLivePhotoUsesRequiredMultipartFields(t *testing.T) {
tests := []struct {
name string
send func(*Uploader) (Message, error)
}{
{
name: "background context",
send: func(uploader *Uploader) (Message, error) {
return uploader.SendLivePhoto(
UploadLivePhoto{ChatID: 42},
NewUploaderFile("live.mp4", []byte("video")),
NewUploaderFile("photo.jpg", []byte("image")),
)
},
},
{
name: "explicit context",
send: func(uploader *Uploader) (Message, error) {
return uploader.SendLivePhotoWithContext(
context.Background(),
UploadLivePhoto{ChatID: 42},
NewUploaderFile("live.mp4", []byte("video")),
NewUploaderFile("photo.jpg", []byte("image")),
)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
gotPath string
gotFiles map[string]multipartFile
parseErr error
)
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotPath = req.URL.Path
gotFiles, parseErr = readMultipartFiles(req)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":5,"date":1}}`)),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Errorf("Close API returned error: %v", err)
}
}()
uploader := NewUploader(api)
defer func() {
if err := uploader.Close(); err != nil {
t.Errorf("Close uploader returned error: %v", err)
}
}()
if _, err := tt.send(uploader); err != nil {
t.Fatalf("SendLivePhoto returned error: %v", err)
}
if parseErr != nil {
t.Fatalf("multipart parse failed: %v", parseErr)
}
if gotPath != "/bottoken/sendLivePhoto" {
t.Fatalf("unexpected request path: %q", gotPath)
}
assertMultipartFile(t, gotFiles, "live_photo", "live.mp4", "video")
assertMultipartFile(t, gotFiles, "photo", "photo.jpg", "image")
})
}
}
func TestPrepareMultipartRichMessageUsesAttachName(t *testing.T) {
params := SendRichMessage{
ChatID: 42,
RichMessage: InputRichMessage{Blocks: []InputRichBlock{
InputRichBlockAnimation{
Type: InputRichTypeAnimation,
Animation: InputMedia{Type: InputMediaTypeAnimation, Media: "attach://animation"},
},
}},
}
buf, contentType, err := prepareMultipart(
[]UploaderFile{NewUploaderFile("animation.mp4", []byte("animation")).SetAttachName("animation")},
params,
)
if err != nil {
t.Fatalf("prepareMultipart returned error: %v", err)
}
_, contentTypeParams, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("ParseMediaType returned error: %v", err)
}
reader := multipart.NewReader(buf, contentTypeParams["boundary"])
parts := make(map[string]string)
var fileData []byte
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("NextPart returned error: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
if part.FileName() != "" {
if part.FormName() != "animation" {
t.Errorf("file form name = %q, want animation", part.FormName())
}
fileData = data
continue
}
parts[part.FormName()] = string(data)
}
if string(fileData) != "animation" {
t.Errorf("file data = %q, want animation", fileData)
}
if got := parts["rich_message"]; !strings.Contains(got, `"media":"attach://animation"`) {
t.Errorf("rich_message = %s, want attach reference", got)
}
}
func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) {
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil {
@@ -209,3 +356,48 @@ func readMultipartRequest(req *http.Request) (map[string]string, string, []byte,
fields[part.FormName()] = string(data)
}
}
type multipartFile struct {
name string
data string
}
func readMultipartFiles(req *http.Request) (map[string]multipartFile, error) {
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil {
return nil, err
}
reader := multipart.NewReader(req.Body, params["boundary"])
files := make(map[string]multipartFile)
for {
part, err := reader.NextPart()
if err == io.EOF {
return files, nil
}
if err != nil {
return nil, err
}
if part.FileName() == "" {
continue
}
data, err := io.ReadAll(part)
if err != nil {
return nil, err
}
files[part.FormName()] = multipartFile{name: part.FileName(), data: string(data)}
}
}
func assertMultipartFile(t *testing.T, files map[string]multipartFile, field, name, data string) {
t.Helper()
file, ok := files[field]
if !ok {
t.Fatalf("multipart field %q is missing", field)
}
if file.name != name {
t.Errorf("multipart field %q filename = %q, want %q", field, file.name, name)
}
if file.data != data {
t.Errorf("multipart field %q data = %q, want %q", field, file.data, data)
}
}