Route crosspost error notifications to owner DM, not to the channel

For crosspost TG→MAX (a post in a TG channel), any delivery error
message used to be posted back into the same channel (visible to all
subscribers). The only person who can act on it is the crosspost owner.

New helper notifyTgUser(ctx, srcMsg, maxChatID, text, isCrosspost):
- crosspost: resolves tg_owner_id via GetCrosspostOwner and DMs that
  user; drops with a warn if the crosspost has no owner (legacy).
- bridge: keeps the existing behavior (post into the source chat,
  preserving forum thread).

All upload/size/circuit-breaker notifications in forwardTgToMax and
flushMediaGroup now go through notifyTgUser. Bridge flows are
unchanged; crosspost flows stop spamming channels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrey Lugovskoy 2026-04-24 16:29:52 +04:00
parent 63b95e4c8c
commit 8b80bb8ff1
3 changed files with 51 additions and 33 deletions

View file

@ -204,6 +204,29 @@ func (b *Bridge) isSelfTgBot(from *UserInfo) bool {
return from != nil && from.IsBot && from.UserName == b.tg.BotUsername()
}
// notifyTgUser отправляет пользовательское уведомление (например, об ошибке загрузки).
// Для bridge-режима — в чат, где пришло сообщение (в нужный тред, если форум).
// Для crosspost — в ЛС владельцу связки (tg_owner_id), чтобы не мусорить в канал.
// Если владелец не задан (legacy) — уведомление дропается с warn-логом.
func (b *Bridge) notifyTgUser(ctx context.Context, srcChat *TGMessage, maxChatID int64, text string, isCrosspost bool) {
if isCrosspost {
_, tgOwner := b.repo.GetCrosspostOwner(maxChatID)
if tgOwner == 0 {
slog.Warn("crosspost notify skipped: no tg owner", "maxChat", maxChatID, "text", text)
return
}
if _, err := b.tg.SendMessage(ctx, tgOwner, text, nil); err != nil {
slog.Warn("crosspost notify DM failed", "err", err, "tgOwner", tgOwner)
}
return
}
var opts *SendOpts
if srcChat != nil && srcChat.MessageThreadID != 0 {
opts = &SendOpts{ThreadID: srcChat.MessageThreadID}
}
b.tg.SendMessage(ctx, srcChat.Chat.ID, text, opts)
}
// uploadErrHint превращает техническую ошибку загрузки в короткий текст для юзера.
// Возвращает пустую строку для неизвестных ошибок — вызывающий код тогда
// отправит только generic-сообщение без технической мути.

View file

@ -165,8 +165,8 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
}
}
if photoFailErr != nil && photosSent == 0 {
b.tg.SendMessage(ctx, items[0].msg.Chat.ID,
uploadErrMsg("Не удалось отправить альбом в MAX", photoFailErr), nil)
b.notifyTgUser(ctx, items[0].msg, maxChatID,
uploadErrMsg("Не удалось отправить альбом в MAX", photoFailErr), isCrosspost)
}
// Загружаем видео из альбома через direct API
@ -198,8 +198,8 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
if err != nil {
slog.Error("TG→MAX media group send failed", "err", err)
if b.cbFail(maxChatID) {
b.tg.SendMessage(ctx, items[0].msg.Chat.ID,
fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), nil)
b.notifyTgUser(ctx, items[0].msg, maxChatID,
fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost)
}
// Fallback — по одному
for _, it := range items {

View file

@ -580,7 +580,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
warn = fmt.Sprintf("⚠️ Файл \"%s\" слишком большой для пересылки (%s). Максимальный размер файла %d МБ.",
fileName, formatFileSize(fileSize), limit)
}
b.tg.SendMessage(ctx, msg.Chat.ID, warn, nil)
b.notifyTgUser(ctx, msg, maxChatID, warn, isCrosspost)
return true
}
@ -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, uploadErrMsg("Не удалось отправить фото в MAX", err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить фото в MAX", err), isCrosspost)
return
}
} else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil {
@ -628,12 +628,12 @@ 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, uploadErrMsg("Не удалось отправить фото в MAX", err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить фото в MAX", err), isCrosspost)
return
}
} else {
slog.Error("TG→MAX photo upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg("Не удалось отправить фото в MAX", err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить фото в MAX", err), isCrosspost)
return
}
if msg.ReplyToMessage != nil {
@ -646,8 +646,8 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
if err != nil {
slog.Error("TG→MAX send failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
if b.cbFail(maxChatID) {
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), nil)
b.notifyTgUser(ctx, msg, maxChatID,
fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost)
}
} else {
b.cbSuccess(maxChatID)
@ -669,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, uploadErrMsg(fmt.Sprintf("Не удалось отправить GIF \"%s\" в MAX", name), err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg(fmt.Sprintf("Не удалось отправить GIF \"%s\" в MAX", name), err), isCrosspost)
return
}
} else if msg.Sticker != nil {
@ -683,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, uploadErrMsg("Не удалось отправить стикер в MAX", err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
return
}
} else {
@ -701,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, uploadErrMsg("Не удалось отправить стикер в MAX", err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
} else {
slog.Info("TG→MAX sent", "mid", result.Body.Mid)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid, msg.MessageThreadID)
@ -709,12 +709,12 @@ 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, uploadErrMsg("Не удалось отправить стикер в MAX", err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
return
}
} else {
slog.Error("TG→MAX sticker getFileURL failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg("Не удалось отправить стикер в MAX", err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
return
}
}
@ -731,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, uploadErrMsg(fmt.Sprintf("Не удалось отправить видео \"%s\" в MAX", name), err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg(fmt.Sprintf("Не удалось отправить видео \"%s\" в MAX", name), err), isCrosspost)
// Видео не залилось — не блокируем отправку текста/подписи, fallback ниже подмешает [Видео].
}
} else if msg.VideoNote != nil {
@ -743,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, uploadErrMsg("Не удалось отправить кружок в MAX", err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить кружок в MAX", err), isCrosspost)
return
}
} else if msg.Document != nil {
@ -768,8 +768,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
if b.cfg.MaxAllowedExts != nil && attType == "file" {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
if _, ok := b.cfg.MaxAllowedExts[ext]; !ok {
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), nil)
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), isCrosspost)
return
}
}
@ -779,13 +778,12 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
} else {
var e *ErrForbiddenExtension
if errors.As(err, &e) {
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), nil)
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), isCrosspost)
return
}
slog.Error("TG→MAX file upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID,
uploadErrMsg(fmt.Sprintf("Не удалось отправить файл \"%s\" в MAX", name), err), nil)
b.notifyTgUser(ctx, msg, maxChatID,
uploadErrMsg(fmt.Sprintf("Не удалось отправить файл \"%s\" в MAX", name), err), isCrosspost)
return
}
} else if msg.Voice != nil {
@ -798,12 +796,11 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
} else {
var e *ErrForbiddenExtension
if errors.As(err, &e) {
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", e.Name), nil)
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", e.Name), isCrosspost)
return
}
slog.Error("TG→MAX voice upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg("Не удалось отправить голосовое сообщение в MAX", err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить голосовое сообщение в MAX", err), isCrosspost)
return
}
} else if msg.Audio != nil {
@ -818,8 +815,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
if b.cfg.MaxAllowedExts != nil {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
if _, ok := b.cfg.MaxAllowedExts[ext]; !ok {
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), nil)
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), isCrosspost)
return
}
}
@ -829,12 +825,11 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
} else {
var e *ErrForbiddenExtension
if errors.As(err, &e) {
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), nil)
b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), isCrosspost)
return
}
slog.Error("TG→MAX audio upload failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, uploadErrMsg(fmt.Sprintf("Не удалось отправить аудио \"%s\" в MAX", name), err), nil)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg(fmt.Sprintf("Не удалось отправить аудио \"%s\" в MAX", name), err), isCrosspost)
return
}
}
@ -935,8 +930,8 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
b.enqueueTg2Max(msg.Chat.ID, msg.MessageID, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format)
}
if b.cbFail(maxChatID) {
b.tg.SendMessage(ctx, msg.Chat.ID,
"MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", nil)
b.notifyTgUser(ctx, msg, maxChatID,
"MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", isCrosspost)
}
} else {
b.cbSuccess(maxChatID)