relay_test.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
maxbot "github.com/max-messenger/max-bot-api-client-go/v2"
)
// sent is a message that reached the fake MAX.
type sent struct {
chatID string
body struct {
Text string `json:"text"`
Format string `json:"format"`
Notify *bool `json:"notify"`
Attachments []struct {
Type string `json:"type"`
Payload struct {
Token string `json:"token"`
} `json:"payload"`
} `json:"attachments"`
}
}
// fake is a fake Telegram and MAX with a relay and a source wired to them:
// posts of channel -100 go to MAX channel -42.
type fake struct {
ctx context.Context
relay *relay
source *source
tgAdmin, maxAdmin, maxRejects atomic.Bool
uploads atomic.Int32
tgWebhook atomic.Pointer[string]
mu sync.Mutex
sent []sent
alerts []string
}
func (f *fake) messages() []sent {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.sent)
}
func (f *fake) alerted() []string {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.alerts)
}
func (f *fake) forward(msgs ...*models.Message) {
f.relay.forward(f.ctx, f.source, &post{messages: msgs}, nil)
}
func fakes(t *testing.T) *fake {
t.Helper()
f := &fake{ctx: t.Context()}
f.tgAdmin.Store(true)
f.maxAdmin.Store(true)
tgServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseMultipartForm(1 << 20)
switch {
case strings.HasSuffix(r.URL.Path, "/getWebhookInfo"):
address := ""
if webhook := f.tgWebhook.Load(); webhook != nil {
address = *webhook
}
fmt.Fprintf(w, `{"ok":true,"result":{"url":%q}}`, address)
case strings.HasSuffix(r.URL.Path, "/getFile"):
// A local Bot API server answers with an absolute path on the shared volume
path := "files/" + r.FormValue("file_id")
if strings.HasPrefix(r.FormValue("file_id"), "/") {
path = r.FormValue("file_id")
}
fmt.Fprintf(w, `{"ok":true,"result":{"file_id":%q,"file_path":%q}}`, r.FormValue("file_id"), path)
case strings.Contains(r.URL.Path, "/file/bot"):
fmt.Fprint(w, "bytes of "+r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:])
case strings.HasSuffix(r.URL.Path, "/getChatMember"):
status := "left"
if f.tgAdmin.Load() {
status = "administrator"
}
fmt.Fprintf(w, `{"ok":true,"result":{"status":%q,"user":{"id":123,"is_bot":true,"first_name":"b"}}}`, status)
case strings.HasSuffix(r.URL.Path, "/sendMessage"):
if r.FormValue("chat_id") != "777" {
t.Errorf("alert sent to %q", r.FormValue("chat_id"))
}
f.mu.Lock()
f.alerts = append(f.alerts, r.FormValue("text"))
f.mu.Unlock()
fmt.Fprint(w, `{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":777,"type":"private"}}}`)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(tgServer.Close)
var maxServer *httptest.Server
maxServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
kind := r.URL.Query().Get("type")
switch {
case r.URL.Path == "/uploads":
fmt.Fprintf(w, `{"url":%q,"token":"%s-token"}`, maxServer.URL+"/upload/"+kind, kind)
case strings.HasPrefix(r.URL.Path, "/upload/"):
f.uploads.Add(1)
_, _ = io.Copy(io.Discard, r.Body)
switch r.URL.Path {
case "/upload/image":
fmt.Fprint(w, `{"photos":{"a":{"token":"image-token"}}}`)
case "/upload/file":
fmt.Fprint(w, `{"token":"file-token"}`)
}
case strings.HasSuffix(r.URL.Path, "/members/me"):
fmt.Fprintf(w, `{"user_id":1,"is_admin":%t,"permissions":["write"]}`, f.maxAdmin.Load())
case r.URL.Path == "/messages":
if f.maxRejects.Load() {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"code":"proto.payload","message":"text is too long"}`)
return
}
m := sent{chatID: r.URL.Query().Get("chat_id")}
if err := json.NewDecoder(r.Body).Decode(&m.body); err != nil {
t.Errorf("message body: %v", err)
}
// MAX channels reject silent messages, including continuation parts.
if m.body.Notify != nil && !*m.body.Notify {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"code":"proto.payload","message":"errors.send-message.channel-notify"}`)
return
}
f.mu.Lock()
f.sent = append(f.sent, m)
f.mu.Unlock()
fmt.Fprint(w, `{"message":{"body":{"mid":"mid.test"}}}`)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(maxServer.Close)
tg, err := bot.New("123:secret", bot.WithSkipGetMe(), bot.WithServerURL(tgServer.URL))
if err != nil {
t.Fatal(err)
}
api, err := maxbot.NewApi("max-token", maxbot.WithBaseURL(maxServer.URL))
if err != nil {
t.Fatal(err)
}
f.relay = &relay{
tg: tg,
max: &maxClient{api: api, http: maxServer.Client(), publish: maxServer.Client(), baseURL: maxServer.URL, token: "max-token"},
http: tgServer.Client(),
adminID: 777,
fileLimit: tgDownloadLimit,
}
f.source = &source{
channel: &models.ChatFullInfo{ID: -100, Title: "Канал", Username: "channel"},
to: []maxChat{{id: -42, title: "Канал MAX"}},
}
return f
}
func TestCancelledForwardAndChangesDoNotAlertAdmin(t *testing.T) {
f := fakes(t)
ctx, cancel := context.WithCancel(t.Context())
cancel()
f.relay.forward(ctx, f.source, &post{messages: []*models.Message{{ID: 9, Text: "post"}}}, nil)
f.relay.changeError(ctx, &deliveryBook{source: channelKey{telegramPlatform, -100}, root: "message:9"}, "MAX", context.Canceled)
if err := f.relay.alert(ctx, "cancelled"); !errors.Is(err, context.Canceled) {
t.Fatalf("alert did not preserve cancellation: %v", err)
}
if alerts := f.alerted(); len(alerts) != 0 {
t.Fatalf("shutdown sent false alerts: %v", alerts)
}
}
func TestAlertStopsWithItsParentContext(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
started := make(chan struct{})
tg, err := bot.New("123:secret", bot.WithSkipGetMe(), bot.WithHTTPClient(time.Minute, &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
close(started)
<-req.Context().Done()
return nil, req.Context().Err()
}),
}))
if err != nil {
t.Fatal(err)
}
r := &relay{tg: tg, adminID: 777}
done := make(chan error, 1)
go func() { done <- r.alert(ctx, "test") }()
<-started
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("alert did not stop on cancellation: %v", err)
}
case <-time.After(time.Second):
t.Fatal("alert outlived its parent context")
}
}
func TestFinishedDeliveryDoesNotDownloadMediaAgain(t *testing.T) {
db := testStore(t)
book := &deliveryBook{store: db, source: channelKey{telegramPlatform, -100}, post: "album:a"}
dst := channelKey{maxPlatform, -42}
if err := book.set(t.Context(), dst, "done", nil); err != nil {
t.Fatal(err)
}
// No clients are configured: reaching the media code would panic.
r := &relay{}
r.forward(t.Context(), &source{channel: &models.ChatFullInfo{ID: -100}, to: []maxChat{{id: dst.id}}},
&post{messages: []*models.Message{{ID: 1, Photo: []models.PhotoSize{{FileID: "photo"}}}}}, book)
if book.err != nil {
t.Fatal(book.err)
}
}
func TestAlbumGoesAsOneMessageWithCaption(t *testing.T) {
f := fakes(t)
f.forward(
&models.Message{ID: 11, MediaGroupID: "g", Video: &models.Video{FileID: "v1"}},
&models.Message{ID: 10, MediaGroupID: "g", Photo: []models.PhotoSize{{FileID: "small"}, {FileID: "big"}},
Caption: "Афиша недели", CaptionEntities: []models.MessageEntity{entity(models.MessageEntityTypeBold, 0, 5)}},
)
got := f.messages()
if len(got) != 1 {
t.Fatalf("want 1 message, got %d", len(got))
}
m := got[0]
if m.body.Text != "<b>Афиша</b> недели" || m.body.Format != "html" || m.chatID != "-42" {
t.Fatalf("text %q format %q chat %q", m.body.Text, m.body.Format, m.chatID)
}
var kinds []string
for _, a := range m.body.Attachments {
kinds = append(kinds, a.Type+":"+a.Payload.Token)
}
if strings.Join(kinds, ",") != "image:image-token,video:video-token" {
t.Fatalf("attachments %v", kinds)
}
}
func TestFilesGoSeparatelyWithChannelNotifications(t *testing.T) {
f := fakes(t)
f.forward(
&models.Message{ID: 1, MediaGroupID: "d", Document: &models.Document{FileID: "d1", FileName: "a.pdf"}, Caption: "Программа"},
&models.Message{ID: 2, MediaGroupID: "d", Document: &models.Document{FileID: "d2", FileName: "b.pdf"}},
)
got := f.messages()
if len(got) != 2 {
t.Fatalf("want 2 messages, got %d", len(got))
}
if got[0].body.Text != "Программа" || len(got[0].body.Attachments) != 1 || got[0].body.Notify != nil {
t.Fatalf("first message: %+v", got[0].body)
}
if got[1].body.Text != "" || len(got[1].body.Attachments) != 1 || got[1].body.Notify != nil {
t.Fatalf("second message: %+v", got[1].body)
}
}
func TestLongPostSendsEveryPartWithChannelNotifications(t *testing.T) {
f := fakes(t)
paragraph := strings.Repeat("я", 2000)
f.forward(&models.Message{ID: 1, Text: strings.Join([]string{paragraph, paragraph, paragraph}, "\n\n")})
got := f.messages()
if len(got) != 3 {
t.Fatalf("want 3 messages, got %d; alerts: %v", len(got), f.alerted())
}
for i, msg := range got {
if msg.body.Text != paragraph || msg.body.Notify != nil {
t.Fatalf("part %d: %+v", i+1, msg.body)
}
}
if alerts := f.alerted(); len(alerts) != 0 {
t.Fatalf("alerts: %v", alerts)
}
}
func TestPostGoesToEveryDestination(t *testing.T) {
f := fakes(t)
f.source.to = append(f.source.to, maxChat{id: -43, title: "Второй канал MAX"})
f.forward(&models.Message{ID: 5, Caption: "Фото", Photo: []models.PhotoSize{{FileID: "p"}}})
got := f.messages()
if len(got) != 2 || got[0].chatID != "-42" || got[1].chatID != "-43" {
t.Fatalf("got %+v", got)
}
// Downloaded once, uploaded for each destination
if n := f.uploads.Load(); n != 2 {
t.Fatalf("uploads %d", n)
}
}
func TestUnsupportedContentLeavesLinkToPost(t *testing.T) {
f := fakes(t)
f.forward(&models.Message{ID: 7, Poll: &models.Poll{Question: "Куда идём?"}})
got := f.messages()
want := `<a href="https://t.me/channel/7">Открыть пост в Telegram</a>`
if len(got) != 1 || got[0].body.Text != want {
t.Fatalf("got %+v", got)
}
}
func TestServiceMessageIsSkipped(t *testing.T) {
f := fakes(t)
f.forward(&models.Message{ID: 3, NewChatTitle: "Новое название"})
if got := f.messages(); len(got) != 0 {
t.Fatalf("got %+v", got)
}
}
func TestRepostSaysWhereItCameFrom(t *testing.T) {
f := fakes(t)
f.forward(&models.Message{
ID: 8,
Text: "Чужая новость",
ForwardOrigin: &models.MessageOrigin{
Type: models.MessageOriginTypeChannel,
MessageOriginChannel: &models.MessageOriginChannel{
Chat: models.Chat{Title: "Источник", Username: "source"},
MessageID: 5,
},
},
})
got := f.messages()
want := "<i>Переслано из <a href=\"https://t.me/source/5\">Источник</a></i>\n\nЧужая новость"
if len(got) != 1 || got[0].body.Text != want {
t.Fatalf("got %+v", got)
}
}
func TestFailedPostIsReportedToAdmin(t *testing.T) {
f := fakes(t)
f.maxRejects.Store(true)
f.forward(&models.Message{ID: 9, Text: "Пост"})
alerts := f.alerted()
if len(alerts) != 1 || !strings.Contains(alerts[0], "пост https://t.me/channel/9 перенесён с ошибками") ||
!strings.Contains(alerts[0], "«Канал MAX»") || !strings.Contains(alerts[0], "text is too long") {
t.Fatalf("alerts %q", alerts)
}
}
func TestTooBigVideoIsLinkedAndReported(t *testing.T) {
f := fakes(t)
f.forward(&models.Message{ID: 4, Caption: "Запись лекции", Video: &models.Video{FileID: "v", FileSize: 300 << 20}})
got := f.messages()
want := "Запись лекции\n\n<a href=\"https://t.me/channel/4\">Открыть пост в Telegram</a>"
if len(got) != 1 || got[0].body.Text != want {
t.Fatalf("got %+v", got)
}
if alerts := f.alerted(); len(alerts) != 1 || !strings.Contains(alerts[0], "video.mp4") {
t.Fatalf("alerts %q", alerts)
}
}
func TestLocalBotAPIFileIsStreamedFromDiskAndRemoved(t *testing.T) {
f := fakes(t)
f.relay.fileLimit = 0 // own Bot API server: no 20 MB limit
path := filepath.Join(t.TempDir(), "bot-token", "videos", "file_0.mp4")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("video bytes"), 0o600); err != nil {
t.Fatal(err)
}
f.forward(&models.Message{ID: 12, Caption: "Запись", Video: &models.Video{FileID: path, FileSize: 200 << 20}})
got := f.messages()
if len(got) != 1 || got[0].body.Text != "Запись" || len(got[0].body.Attachments) != 1 || got[0].body.Attachments[0].Type != "video" {
t.Fatalf("got %+v", got)
}
if alerts := f.alerted(); len(alerts) != 0 {
t.Fatalf("alerts %q", alerts)
}
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("downloaded file is still on the volume: %v", err)
}
}
func TestMissingLocalFileErrorHidesThePath(t *testing.T) {
f := fakes(t)
f.relay.fileLimit = 0
path := filepath.Join(t.TempDir(), "123:secret-token", "videos", "gone.mp4")
f.forward(&models.Message{ID: 13, Caption: "Запись", Video: &models.Video{FileID: path}})
alerts := f.alerted()
if len(alerts) != 1 || strings.Contains(alerts[0], "secret-token") || !strings.Contains(alerts[0], "общем томе") {
t.Fatalf("alerts %q", alerts)
}
}