Surface TG→MAX upload errors in chat (not only in log)

Requested by user Victor via bridged group.

- New helper uploadErrHint maps common upload failures to a short
  Russian hint ("файл слишком большой", "Telegram не нашёл файл",
  "MAX CDN не успел обработать файл", etc) and falls back to a truncated
  raw error for the unknown case.
- Every TG→MAX upload failure path now tells the user what happened
  (photo, gif, sticker, video, video note, document, voice, audio,
  edit-with-media, media group).
- Edit path previously failed silently and media group dropped the
  error on the floor — both now notify.
- Media group notifies once with the first failure when no photos
  survived upload (avoids N messages per album).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrey Lugovskoy 2026-04-23 19:01:18 +04:00
parent 02e2ea4f88
commit 825bfee473
3 changed files with 60 additions and 11 deletions

View file

@ -6,6 +6,7 @@ import (
"encoding/hex"
"log/slog"
"net/http"
"strings"
"sync"
"time"
@ -203,6 +204,32 @@ func (b *Bridge) isSelfTgBot(from *UserInfo) bool {
return from != nil && from.IsBot && from.UserName == b.tg.BotUsername()
}
// uploadErrHint превращает техническую ошибку загрузки в короткий русский текст
// для отправки в чат. Для неизвестных ошибок отдаёт усечённое сырое сообщение.
func uploadErrHint(err error) string {
if err == nil {
return ""
}
s := err.Error()
switch {
case strings.Contains(s, "file is too big"):
return "файл слишком большой для Telegram Bot API (лимит без локального сервера — 20 МБ)"
case strings.Contains(s, "file is not found") || strings.Contains(s, "FILE_REFERENCE_EXPIRED"):
return "Telegram не нашёл файл (возможно, он удалён или ссылка протухла)"
case strings.Contains(s, "wrong file id") || strings.Contains(s, "Wrong file"):
return "некорректный file_id"
case strings.Contains(s, "attachment.not.ready"):
return "MAX CDN не успел обработать файл — попробуйте ещё раз"
case strings.Contains(s, "chat.denied"):
return "MAX отклонил отправку: бот не добавлен в чат или не имеет прав"
}
const maxLen = 180
if len(s) > maxLen {
s = s[:maxLen] + "…"
}
return s
}
func (b *Bridge) tgWebhookPath() string {
return "/tg-webhook-" + b.whSecret
}

View file

@ -130,12 +130,14 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
// Загружаем и добавляем все фото
photosSent := 0
var photoFailErr error
for _, it := range items {
if len(it.photoSizes) > 0 {
photo := it.photoSizes[len(it.photoSizes)-1]
fileURL, err := b.tgFileURL(ctx, photo.FileID)
if err != nil {
slog.Error("media group: tgFileURL failed", "err", err)
photoFailErr = err
continue
}
// Если custom TG API — MAX не может скачать по URL, скачиваем сами
@ -143,6 +145,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
uploaded, err := b.uploadTgPhotoToMax(ctx, photo.FileID)
if err != nil {
slog.Error("media group: photo upload failed", "err", err)
photoFailErr = err
continue
}
m.AddPhoto(uploaded)
@ -150,6 +153,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL)
if err != nil {
slog.Error("media group: photo upload failed", "err", err)
photoFailErr = err
continue
}
m.AddPhoto(uploaded)
@ -157,6 +161,10 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
photosSent++
}
}
if photoFailErr != nil && photosSent == 0 {
b.tg.SendMessage(ctx, items[0].msg.Chat.ID,
fmt.Sprintf("Не удалось отправить альбом в MAX: %s", uploadErrHint(photoFailErr)), nil)
}
// Загружаем видео из альбома через direct API
videosSent := 0

View file

@ -620,7 +620,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
m.AddPhoto(uploaded)
} else {
slog.Error("TG→MAX photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить фото в MAX.", nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить фото в MAX: %s", uploadErrHint(err)), nil)
return
}
} else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil {
@ -628,9 +628,13 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
m.AddPhoto(uploaded)
} else {
slog.Error("TG→MAX photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить фото в MAX.", nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить фото в MAX: %s", uploadErrHint(err)), nil)
return
}
} else {
slog.Error("TG→MAX photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить фото в MAX: %s", uploadErrHint(err)), nil)
return
}
if msg.ReplyToMessage != nil {
if maxReplyID, ok := b.repo.LookupMaxMsgID(msg.Chat.ID, msg.ReplyToMessage.MessageID); ok {
@ -665,7 +669,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
mediaAttType = "video"
} else {
slog.Error("TG→MAX gif upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить GIF \"%s\" в MAX.", name), nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить GIF \"%s\" в MAX: %s", name, uploadErrHint(err)), nil)
return
}
} else if msg.Sticker != nil {
@ -679,7 +683,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
mediaAttType = "video"
} else {
slog.Error("TG→MAX sticker upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить стикер в MAX: %s", uploadErrHint(err)), nil)
return
}
} else {
@ -697,7 +701,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
result, err := b.maxApi.Messages.SendWithResult(ctx, m)
if err != nil {
slog.Error("TG→MAX sticker send failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить стикер в MAX: %s", uploadErrHint(err)), nil)
} else {
slog.Info("TG→MAX sent", "mid", result.Body.Mid)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid, msg.MessageThreadID)
@ -705,9 +709,13 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
return
} else {
slog.Error("TG→MAX sticker photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить стикер в MAX: %s", uploadErrHint(err)), nil)
return
}
} else {
slog.Error("TG→MAX sticker getFileURL failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить стикер в MAX: %s", uploadErrHint(err)), nil)
return
}
}
} else if msg.Video != nil {
@ -723,7 +731,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
mediaAttType = "video"
} else {
slog.Error("TG→MAX video upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить видео \"%s\" в MAX.", name), nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить видео \"%s\" в MAX: %s", name, uploadErrHint(err)), nil)
return
}
} else if msg.VideoNote != nil {
@ -735,7 +743,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
mediaAttType = "video"
} else {
slog.Error("TG→MAX video note upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить кружок в MAX.", nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить кружок в MAX: %s", uploadErrHint(err)), nil)
return
}
} else if msg.Document != nil {
@ -777,7 +785,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
}
slog.Error("TG→MAX file upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Не удалось отправить файл \"%s\" в MAX.", name), nil)
fmt.Sprintf("Не удалось отправить файл \"%s\" в MAX: %s", name, uploadErrHint(err)), nil)
return
}
} else if msg.Voice != nil {
@ -795,7 +803,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
return
}
slog.Error("TG→MAX voice upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить голосовое сообщение в MAX.", nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить голосовое сообщение в MAX: %s", uploadErrHint(err)), nil)
return
}
} else if msg.Audio != nil {
@ -826,7 +834,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
return
}
slog.Error("TG→MAX audio upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить аудио \"%s\" в MAX.", name), nil)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить аудио \"%s\" в MAX: %s", name, uploadErrHint(err)), nil)
return
}
}
@ -957,6 +965,7 @@ func (b *Bridge) editTgMediaInMax(ctx context.Context, msg *TGMessage, maxChatID
m.AddPhoto(uploaded)
} else {
slog.Error("TG→MAX edit photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось обновить фото в MAX: %s", uploadErrHint(err)), nil)
return
}
} else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil {
@ -964,8 +973,13 @@ func (b *Bridge) editTgMediaInMax(ctx context.Context, msg *TGMessage, maxChatID
m.AddPhoto(uploaded)
} else {
slog.Error("TG→MAX edit photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось обновить фото в MAX: %s", uploadErrHint(err)), nil)
return
}
} else {
slog.Error("TG→MAX edit photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось обновить фото в MAX: %s", uploadErrHint(err)), nil)
return
}
}