mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-04-28 03:39:46 +00:00
Security hardening and admin check tests
- Webhook paths now include token-derived secret (prevents spoofed updates) - HTTP server with Read/Write/Idle timeouts (prevents slowloris) - Shared HTTP client with 60s timeout for all uploads/downloads - Removed tokens and sensitive data from debug logs - Retry loop respects context cancellation instead of blocking sleep - Pending bridge keys expire after 1 hour (migration 000003) - Increased bridge key entropy from 32 to 64 bits (16 hex chars) - Docker container runs as non-root user - Extracted admin check helpers with unit tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cd625155ef
commit
62a4233027
15 changed files with 225 additions and 39 deletions
|
|
@ -12,6 +12,9 @@ RUN CGO_ENABLED=1 go build -o /max-telegram-bridge-bot .
|
|||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN adduser -D -h /app bridge
|
||||
USER bridge
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /max-telegram-bridge-bot /usr/local/bin/max-telegram-bridge-bot
|
||||
|
||||
|
|
|
|||
28
admin.go
Normal file
28
admin.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package main
|
||||
|
||||
import maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||||
|
||||
// isTgGroup returns true if the TG chat type indicates a group.
|
||||
func isTgGroup(chatType string) bool {
|
||||
return chatType == "group" || chatType == "supergroup"
|
||||
}
|
||||
|
||||
// isTgAdmin returns true if the TG ChatMember status indicates admin rights.
|
||||
func isTgAdmin(memberStatus string) bool {
|
||||
return memberStatus == "creator" || memberStatus == "administrator"
|
||||
}
|
||||
|
||||
// isMaxGroup returns true if the MAX chat type indicates a group.
|
||||
func isMaxGroup(chatType maxschemes.ChatType) bool {
|
||||
return chatType == maxschemes.CHAT || chatType == maxschemes.CHANNEL
|
||||
}
|
||||
|
||||
// isMaxUserAdmin returns true if userID is found in the admin members list.
|
||||
func isMaxUserAdmin(members []maxschemes.ChatMember, userID int64) bool {
|
||||
for _, m := range members {
|
||||
if m.UserId == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
109
admin_test.go
Normal file
109
admin_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||||
)
|
||||
|
||||
func TestIsTgGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
chatType string
|
||||
want bool
|
||||
}{
|
||||
{"group", true},
|
||||
{"supergroup", true},
|
||||
{"private", false},
|
||||
{"channel", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.chatType, func(t *testing.T) {
|
||||
if got := isTgGroup(tt.chatType); got != tt.want {
|
||||
t.Errorf("isTgGroup(%q) = %v, want %v", tt.chatType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTgAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{"creator", true},
|
||||
{"administrator", true},
|
||||
{"member", false},
|
||||
{"restricted", false},
|
||||
{"left", false},
|
||||
{"kicked", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.status, func(t *testing.T) {
|
||||
if got := isTgAdmin(tt.status); got != tt.want {
|
||||
t.Errorf("isTgAdmin(%q) = %v, want %v", tt.status, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMaxGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chatType maxschemes.ChatType
|
||||
want bool
|
||||
}{
|
||||
{"chat", maxschemes.CHAT, true},
|
||||
{"channel", maxschemes.CHANNEL, true},
|
||||
{"dialog", maxschemes.DIALOG, false},
|
||||
{"empty", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isMaxGroup(tt.chatType); got != tt.want {
|
||||
t.Errorf("isMaxGroup(%q) = %v, want %v", tt.chatType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMaxUserAdmin(t *testing.T) {
|
||||
admins := []maxschemes.ChatMember{
|
||||
{UserId: 100, Name: "Owner", IsOwner: true, IsAdmin: true},
|
||||
{UserId: 200, Name: "Admin", IsAdmin: true},
|
||||
{UserId: 300, Name: "Bot", IsBot: true, IsAdmin: true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userID int64
|
||||
want bool
|
||||
}{
|
||||
{"owner is admin", 100, true},
|
||||
{"admin is admin", 200, true},
|
||||
{"bot admin", 300, true},
|
||||
{"non-admin user", 999, false},
|
||||
{"zero id", 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isMaxUserAdmin(admins, tt.userID); got != tt.want {
|
||||
t.Errorf("isMaxUserAdmin(admins, %d) = %v, want %v", tt.userID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMaxUserAdmin_EmptyList(t *testing.T) {
|
||||
if isMaxUserAdmin(nil, 100) {
|
||||
t.Error("isMaxUserAdmin(nil, 100) = true, want false")
|
||||
}
|
||||
if isMaxUserAdmin([]maxschemes.ChatMember{}, 100) {
|
||||
t.Error("isMaxUserAdmin([], 100) = true, want false")
|
||||
}
|
||||
}
|
||||
42
bridge.go
42
bridge.go
|
|
@ -2,6 +2,8 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
|
@ -14,31 +16,49 @@ import (
|
|||
|
||||
// Config — настройки bridge, читаемые из env.
|
||||
type Config struct {
|
||||
MaxToken string // токен MAX API (нужен для direct-send/upload)
|
||||
TgBotURL string // ссылка на TG-бота для /help
|
||||
MaxBotURL string // ссылка на MAX-бота для /help
|
||||
MaxToken string // токен MAX API (нужен для direct-send/upload)
|
||||
TgBotURL string // ссылка на TG-бота для /help
|
||||
MaxBotURL string // ссылка на MAX-бота для /help
|
||||
WebhookURL string // базовый URL для webhook (если пусто — long polling)
|
||||
WebhookPort string // порт для webhook сервера
|
||||
}
|
||||
|
||||
// Bridge — основная структура, объединяющая зависимости.
|
||||
type Bridge struct {
|
||||
cfg Config
|
||||
repo Repository
|
||||
tgBot *tgbotapi.BotAPI
|
||||
maxApi *maxbot.Api
|
||||
cfg Config
|
||||
repo Repository
|
||||
tgBot *tgbotapi.BotAPI
|
||||
maxApi *maxbot.Api
|
||||
httpClient *http.Client
|
||||
whSecret string // random path segment for webhook URLs
|
||||
}
|
||||
|
||||
// NewBridge создаёт экземпляр Bridge.
|
||||
func NewBridge(cfg Config, repo Repository, tgBot *tgbotapi.BotAPI, maxApi *maxbot.Api) *Bridge {
|
||||
// Derive webhook secret from tokens (stable across restarts)
|
||||
h := sha256.Sum256([]byte(cfg.MaxToken + tgBot.Token))
|
||||
secret := hex.EncodeToString(h[:8])
|
||||
|
||||
return &Bridge{
|
||||
cfg: cfg,
|
||||
repo: repo,
|
||||
tgBot: tgBot,
|
||||
maxApi: maxApi,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
whSecret: secret,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) tgWebhookPath() string {
|
||||
return "/tg-webhook-" + b.whSecret
|
||||
}
|
||||
|
||||
func (b *Bridge) maxWebhookPath() string {
|
||||
return "/max-webhook-" + b.whSecret
|
||||
}
|
||||
|
||||
// Run запускает TG и MAX listener'ы + периодическую очистку.
|
||||
func (b *Bridge) Run(ctx context.Context) {
|
||||
go func() {
|
||||
|
|
@ -57,8 +77,14 @@ func (b *Bridge) Run(ctx context.Context) {
|
|||
if b.cfg.WebhookURL != "" {
|
||||
go func() {
|
||||
addr := ":" + b.cfg.WebhookPort
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
slog.Info("Webhook server starting", "addr", addr)
|
||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("Webhook server failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -32,7 +32,7 @@ func envOr(key, fallback string) string {
|
|||
}
|
||||
|
||||
func genKey() string {
|
||||
b := make([]byte, 4)
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ func TestEnvOr(t *testing.T) {
|
|||
|
||||
func TestGenKey(t *testing.T) {
|
||||
key := genKey()
|
||||
if len(key) != 8 {
|
||||
t.Errorf("genKey() length = %d, want 8", len(key))
|
||||
if len(key) != 16 {
|
||||
t.Errorf("genKey() length = %d, want 16", len(key))
|
||||
}
|
||||
|
||||
// Keys should be unique
|
||||
|
|
|
|||
16
max.go
16
max.go
|
|
@ -17,15 +17,16 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
var updates <-chan maxschemes.UpdateInterface
|
||||
|
||||
if b.cfg.WebhookURL != "" {
|
||||
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + "/max-webhook"
|
||||
whPath := b.maxWebhookPath()
|
||||
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + whPath
|
||||
ch := make(chan maxschemes.UpdateInterface, 100)
|
||||
http.HandleFunc("/max-webhook", b.maxApi.GetHandler(ch))
|
||||
http.HandleFunc(whPath, b.maxApi.GetHandler(ch))
|
||||
if _, err := b.maxApi.Subscriptions.Subscribe(ctx, whURL, nil, ""); err != nil {
|
||||
slog.Error("MAX webhook subscribe failed", "err", err)
|
||||
return
|
||||
}
|
||||
updates = ch
|
||||
slog.Info("MAX webhook mode", "url", whURL)
|
||||
slog.Info("MAX webhook mode")
|
||||
} else {
|
||||
updates = b.maxApi.GetUpdates(ctx)
|
||||
slog.Info("MAX polling mode")
|
||||
|
|
@ -122,17 +123,12 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
}
|
||||
|
||||
// Проверка прав админа в группах
|
||||
isGroup := msgUpd.Message.Recipient.ChatType == maxschemes.CHAT || msgUpd.Message.Recipient.ChatType == maxschemes.CHANNEL
|
||||
isGroup := isMaxGroup(msgUpd.Message.Recipient.ChatType)
|
||||
isAdmin := false
|
||||
if isGroup {
|
||||
admins, err := b.maxApi.Chats.GetChatAdmins(ctx, chatID)
|
||||
if err == nil {
|
||||
for _, m := range admins.Members {
|
||||
if m.UserId == msgUpd.Message.Sender.UserId {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
isAdmin = isMaxUserAdmin(admins.Members, msgUpd.Message.Sender.UserId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1
migrations/postgres/000003_pending_created_at.down.sql
Normal file
1
migrations/postgres/000003_pending_created_at.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE pending DROP COLUMN created_at;
|
||||
1
migrations/postgres/000003_pending_created_at.up.sql
Normal file
1
migrations/postgres/000003_pending_created_at.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE pending ADD COLUMN created_at BIGINT NOT NULL DEFAULT 0;
|
||||
4
migrations/sqlite/000003_pending_created_at.down.sql
Normal file
4
migrations/sqlite/000003_pending_created_at.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
CREATE TABLE pending_backup (key TEXT PRIMARY KEY, platform TEXT NOT NULL, chat_id INTEGER NOT NULL);
|
||||
INSERT INTO pending_backup SELECT key, platform, chat_id FROM pending;
|
||||
DROP TABLE pending;
|
||||
ALTER TABLE pending_backup RENAME TO pending;
|
||||
1
migrations/sqlite/000003_pending_created_at.up.sql
Normal file
1
migrations/sqlite/000003_pending_created_at.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE pending ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -40,7 +40,7 @@ func (r *pgRepo) Register(key, platform string, chatID int64) (bool, string, err
|
|||
return false, existing, nil
|
||||
}
|
||||
generated := genKey()
|
||||
_, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id) VALUES ($1, $2, $3)", generated, platform, chatID)
|
||||
_, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id, created_at) VALUES ($1, $2, $3, $4)", generated, platform, chatID, time.Now().Unix())
|
||||
return false, generated, err
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +105,7 @@ func (r *pgRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) {
|
|||
|
||||
func (r *pgRepo) CleanOldMessages() {
|
||||
r.db.Exec("DELETE FROM messages WHERE created_at < $1", time.Now().Unix()-48*3600)
|
||||
r.db.Exec("DELETE FROM pending WHERE created_at > 0 AND created_at < $1", time.Now().Unix()-3600)
|
||||
}
|
||||
|
||||
func (r *pgRepo) HasPrefix(platform string, chatID int64) bool {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ func (r *sqliteRepo) Register(key, platform string, chatID int64) (bool, string,
|
|||
return false, existing, nil
|
||||
}
|
||||
generated := genKey()
|
||||
_, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id) VALUES (?, ?, ?)", generated, platform, chatID)
|
||||
_, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id, created_at) VALUES (?, ?, ?, ?)", generated, platform, chatID, time.Now().Unix())
|
||||
return false, generated, err
|
||||
}
|
||||
|
||||
|
|
@ -96,6 +96,7 @@ func (r *sqliteRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) {
|
|||
|
||||
func (r *sqliteRepo) CleanOldMessages() {
|
||||
r.db.Exec("DELETE FROM messages WHERE created_at < ?", time.Now().Unix()-48*3600)
|
||||
r.db.Exec("DELETE FROM pending WHERE created_at > 0 AND created_at < ?", time.Now().Unix()-3600)
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) HasPrefix(platform string, chatID int64) bool {
|
||||
|
|
|
|||
11
telegram.go
11
telegram.go
|
|
@ -16,7 +16,8 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
var updates tgbotapi.UpdatesChannel
|
||||
|
||||
if b.cfg.WebhookURL != "" {
|
||||
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + "/tg-webhook"
|
||||
whPath := b.tgWebhookPath()
|
||||
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + whPath
|
||||
wh, err := tgbotapi.NewWebhook(whURL)
|
||||
if err != nil {
|
||||
slog.Error("TG webhook config error", "err", err)
|
||||
|
|
@ -26,8 +27,8 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
slog.Error("TG set webhook failed", "err", err)
|
||||
return
|
||||
}
|
||||
updates = b.tgBot.ListenForWebhook("/tg-webhook")
|
||||
slog.Info("TG webhook mode", "url", whURL)
|
||||
updates = b.tgBot.ListenForWebhook(whPath)
|
||||
slog.Info("TG webhook mode")
|
||||
} else {
|
||||
// Удаляем webhook если был, переключаемся на polling
|
||||
b.tgBot.Request(tgbotapi.DeleteWebhookConfig{})
|
||||
|
|
@ -103,7 +104,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
}
|
||||
|
||||
// Проверка прав админа в группах
|
||||
isGroup := msg.Chat.Type == "group" || msg.Chat.Type == "supergroup"
|
||||
isGroup := isTgGroup(msg.Chat.Type)
|
||||
isAdmin := false
|
||||
if isGroup && msg.From != nil {
|
||||
member, err := b.tgBot.GetChatMember(tgbotapi.GetChatMemberConfig{
|
||||
|
|
@ -113,7 +114,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
},
|
||||
})
|
||||
if err == nil {
|
||||
isAdmin = member.Status == "creator" || member.Status == "administrator"
|
||||
isAdmin = isTgAdmin(member.Status)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
36
upload.go
36
upload.go
|
|
@ -25,7 +25,7 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
|
|||
}
|
||||
req.Header.Set("Authorization", b.cfg.MaxToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := b.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get upload url: %w", err)
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
|
|||
if err := json.NewDecoder(resp.Body).Decode(&endpoint); err != nil {
|
||||
return nil, fmt.Errorf("decode upload endpoint: %w", err)
|
||||
}
|
||||
slog.Debug("MAX upload endpoint", "url", endpoint.Url, "token", endpoint.Token)
|
||||
slog.Debug("MAX upload endpoint", "url", endpoint.Url)
|
||||
|
||||
// 2. Загружаем файл на CDN (multipart)
|
||||
var buf bytes.Buffer
|
||||
|
|
@ -53,14 +53,20 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
|
|||
}
|
||||
writer.Close()
|
||||
|
||||
cdnResp, err := http.Post(endpoint.Url, writer.FormDataContentType(), &buf)
|
||||
cdnReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.Url, &buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create CDN request: %w", err)
|
||||
}
|
||||
cdnReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
cdnResp, err := b.httpClient.Do(cdnReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upload to CDN: %w", err)
|
||||
}
|
||||
defer cdnResp.Body.Close()
|
||||
|
||||
cdnBody, _ := io.ReadAll(cdnResp.Body)
|
||||
slog.Debug("MAX CDN response", "status", cdnResp.StatusCode, "body", string(cdnBody))
|
||||
slog.Debug("MAX CDN response", "status", cdnResp.StatusCode)
|
||||
|
||||
// 3. Парсим CDN ответ (fileId в camelCase)
|
||||
var cdnResult struct {
|
||||
|
|
@ -68,11 +74,11 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
|
|||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(cdnBody, &cdnResult); err == nil && cdnResult.Token != "" {
|
||||
slog.Debug("MAX token from CDN", "token", cdnResult.Token, "fileId", cdnResult.FileID)
|
||||
slog.Debug("MAX upload ok", "fileId", cdnResult.FileID)
|
||||
return &maxschemes.UploadedInfo{Token: cdnResult.Token, FileID: cdnResult.FileID}, nil
|
||||
}
|
||||
if endpoint.Token != "" {
|
||||
slog.Debug("MAX token from endpoint", "token", endpoint.Token)
|
||||
slog.Debug("MAX upload ok (endpoint token)")
|
||||
return &maxschemes.UploadedInfo{Token: endpoint.Token}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no token: endpoint and CDN both empty")
|
||||
|
|
@ -84,9 +90,13 @@ func (b *Bridge) uploadTgMediaToMax(ctx context.Context, fileID string, uploadTy
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("tg getFileURL: %w", err)
|
||||
}
|
||||
slog.Debug("TG file URL", "url", fileURL)
|
||||
|
||||
resp, err := http.Get(fileURL)
|
||||
dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := b.httpClient.Do(dlReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download: %w", err)
|
||||
}
|
||||
|
|
@ -96,7 +106,7 @@ func (b *Bridge) uploadTgMediaToMax(ctx context.Context, fileID string, uploadTy
|
|||
return nil, fmt.Errorf("tg download status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
slog.Debug("TG file downloaded", "status", resp.StatusCode, "size", resp.ContentLength, "contentType", resp.Header.Get("Content-Type"))
|
||||
slog.Debug("TG file downloaded", "size", resp.ContentLength)
|
||||
|
||||
return b.customUploadToMax(ctx, uploadType, resp.Body, fileName)
|
||||
}
|
||||
|
|
@ -141,7 +151,11 @@ func (b *Bridge) sendMaxDirect(ctx context.Context, chatID int64, text string, a
|
|||
for attempt := 0; attempt < 10; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := time.Duration(1+attempt) * time.Second
|
||||
time.Sleep(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
slog.Warn("MAX retry", "attempt", attempt+1, "maxAttempts", 10)
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +166,7 @@ func (b *Bridge) sendMaxDirect(ctx context.Context, chatID int64, text string, a
|
|||
req.Header.Set("Authorization", b.cfg.MaxToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := b.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue