diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 16dbd61..8e86424 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -129,7 +129,7 @@ func (b *Bot) sendMemory(ctx context.Context, mem *search.Memory) { return } - mainText, captionText := formatMemory(mem, b.publicURL) + text := formatMemory(mem, b.publicURL) images := imageAttachments(mem.Memo) var downloaded []imageFile @@ -137,7 +137,7 @@ func (b *Bot) sendMemory(ctx context.Context, mem *search.Memory) { downloaded = b.downloadImages(ctx, images) } - if err := b.sendWithRetry(ctx, mainText, captionText, downloaded); err != nil { + if err := b.sendWithRetry(ctx, text, downloaded); err != nil { b.logger.Error("failed to send telegram message after retries", "error", err) } } @@ -206,12 +206,12 @@ func (b *Bot) downloadImages(ctx context.Context, attachments []memos.Attachment } // sendWithRetry attempts to send the message with up to 3 retries. -func (b *Bot) sendWithRetry(ctx context.Context, mainText, captionText string, images []imageFile) error { +func (b *Bot) sendWithRetry(ctx context.Context, text string, images []imageFile) error { backoffs := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second} var lastErr error for attempt := range 3 { - lastErr = b.send(mainText, captionText, images) + lastErr = b.send(text, images) if lastErr == nil { return nil } @@ -229,35 +229,41 @@ func (b *Bot) sendWithRetry(ctx context.Context, mainText, captionText string, i } // send executes the actual Telegram API calls based on the sending strategy. -func (b *Bot) send(mainText, captionText string, images []imageFile) error { +// Long text is split into multiple messages. Images are sent with a caption +// only if the full text fits within the caption limit. +func (b *Bot) send(text string, images []imageFile) error { switch { case len(images) == 0: - // No images — just text - return b.sendText(mainText) + return b.sendTextParts(splitText(text, maxMessageLen)) - case len(images) == 1 && len(captionText) <= maxCaptionLen: - // Single image with short caption - return b.sendPhoto(images[0], captionText) + case len(text) <= maxCaptionLen: + // Short text — use as caption on image(s) + if len(images) == 1 { + return b.sendPhoto(images[0], text) + } + return b.sendMediaGroup(images, text) - case len(images) > 1 && len(captionText) <= maxCaptionLen: - // Multiple images with short caption - return b.sendMediaGroup(images, captionText) - - case len(images) >= 1 && len(captionText) > maxCaptionLen: - // Images with long text — send text first, then images without caption - if err := b.sendText(mainText); err != nil { + default: + // Long text — send text messages first, then images without caption + if err := b.sendTextParts(splitText(text, maxMessageLen)); err != nil { return err } if len(images) == 1 { return b.sendPhoto(images[0], "") } return b.sendMediaGroup(images, "") - - default: - return b.sendText(mainText) } } +func (b *Bot) sendTextParts(parts []string) error { + for _, part := range parts { + if err := b.sendText(part); err != nil { + return err + } + } + return nil +} + func (b *Bot) sendText(text string) error { msg := tgbotapi.NewMessage(b.chatID, text) msg.ParseMode = tgbotapi.ModeHTML diff --git a/internal/telegram/format.go b/internal/telegram/format.go index cc029cd..f1fda24 100644 --- a/internal/telegram/format.go +++ b/internal/telegram/format.go @@ -14,9 +14,9 @@ const ( maxCaptionLen = 1024 ) -// formatMemory returns (mainText, captionText) formatted as HTML for Telegram. -// mainText is for sendMessage (up to 4096 chars), captionText for photo captions (up to 1024 chars). -func formatMemory(mem *search.Memory, publicURL string) (mainText, captionText string) { +// formatMemory returns the full message text formatted as HTML for Telegram. +// The text is NOT truncated — the caller is responsible for splitting if needed. +func formatMemory(mem *search.Memory, publicURL string) string { var b strings.Builder // Header: date and "ago" text @@ -43,10 +43,7 @@ func formatMemory(mem *search.Memory, publicURL string) (mainText, captionText s memoURL := fmt.Sprintf("%s/%s", publicURL, mem.Memo.Name) b.WriteString("\n\nОригинал") - full := b.String() - mainText = truncateHTML(full, maxMessageLen) - captionText = truncateHTML(full, maxCaptionLen) - return mainText, captionText + return b.String() } // imageAttachments returns image attachments from the memo. @@ -60,22 +57,43 @@ func imageAttachments(memo *memos.Memo) []memos.Attachment { return images } -// truncateHTML truncates text to maxLen, cutting at a word/line boundary and adding "...". -func truncateHTML(text string, maxLen int) string { +// splitText splits text into chunks of at most maxLen bytes, +// breaking at paragraph (\n\n), line (\n), or word boundaries. +func splitText(text string, maxLen int) []string { if len(text) <= maxLen { - return text + return []string{text} } - // Reserve space for "..." - cut := maxLen - 3 + var parts []string + remaining := text - // Find last newline or space before cut point - idx := strings.LastIndexAny(text[:cut], "\n ") - if idx <= 0 { - idx = cut + for len(remaining) > maxLen { + chunk := remaining[:maxLen] + + // Try to break at a paragraph boundary + idx := strings.LastIndex(chunk, "\n\n") + if idx <= 0 { + // Try a line boundary + idx = strings.LastIndex(chunk, "\n") + } + if idx <= 0 { + // Try a word boundary + idx = strings.LastIndexByte(chunk, ' ') + } + if idx <= 0 { + // Hard cut as last resort + idx = maxLen + } + + parts = append(parts, remaining[:idx]) + remaining = strings.TrimLeft(remaining[idx:], "\n ") } - return text[:idx] + "..." + if remaining != "" { + parts = append(parts, remaining) + } + + return parts } // escapeHTML escapes special HTML characters for Telegram HTML parse mode. diff --git a/internal/web/handler.go b/internal/web/handler.go index 04a4c06..16fa57e 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -42,8 +42,7 @@ type Handler struct { service *memory.Service logger *slog.Logger mux *http.ServeMux - memosURL string // internal Memos URL (for attachment files) - publicURL string // public Memos URL (for memo links) + publicURL string // public Memos URL (for images and memo links) allowLoadMore bool } @@ -54,7 +53,6 @@ func NewHandler(service *memory.Service, memosURL, publicURL string, allowLoadMo } h := &Handler{ service: service, - memosURL: strings.TrimRight(memosURL, "/"), publicURL: strings.TrimRight(pub, "/"), allowLoadMore: allowLoadMore, logger: logger, @@ -107,7 +105,7 @@ func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) { if att.ExternalLink != "" { imgURL = att.ExternalLink } else { - imgURL = fmt.Sprintf("%s/file/%s/%s", h.memosURL, att.Name, att.Filename) + imgURL = fmt.Sprintf("%s/file/%s/%s", h.publicURL, att.Name, att.Filename) } images = append(images, imageData{URL: imgURL, Alt: att.Filename}) }