From e1ebb8364119bec7edaf7290daaa19f006c02e0d Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 13 Feb 2026 09:37:54 +0300 Subject: [PATCH] add image conversion for telegram --- Dockerfile | 7 +- internal/media/convert.go | 66 ++++++++++++++++++ internal/memos/types.go | 18 +++++ internal/telegram/bot.go | 133 ++++++++++++++++++++++++++++-------- internal/telegram/format.go | 14 ++-- 5 files changed, 203 insertions(+), 35 deletions(-) create mode 100644 internal/media/convert.go diff --git a/Dockerfile b/Dockerfile index bada236..25cdcfd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,13 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/remembos ./cmd/remembos -FROM gcr.io/distroless/static:nonroot +FROM alpine:3.21 + +RUN apk add --no-cache imagemagick \ + && adduser -D -H appuser COPY --from=build /out/remembos /remembos -USER nonroot:nonroot +USER appuser ENTRYPOINT ["/remembos"] diff --git a/internal/media/convert.go b/internal/media/convert.go new file mode 100644 index 0000000..1ad3eb4 --- /dev/null +++ b/internal/media/convert.go @@ -0,0 +1,66 @@ +package media + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +const maxTelegramPhotoSize = 10 * 1024 * 1024 // 10 MB + +// CompressImage compresses an image if it exceeds Telegram's 10 MB photo limit. +// It uses ImageMagick's magick command to convert via stdin/stdout. +// Returns the (possibly compressed) data, updated filename, and any error. +func CompressImage(ctx context.Context, data []byte, filename string) (out []byte, outName string, err error) { + if len(data) <= maxTelegramPhotoSize { + return data, filename, nil + } + + // First attempt: just re-encode as JPEG with quality 85 + out, err = runMagick(ctx, data, "-quality", "85") + if err != nil { + return nil, "", fmt.Errorf("compress image: %w", err) + } + + newFilename := replaceExt(filename, ".jpg") + + if len(out) <= maxTelegramPhotoSize { + return out, newFilename, nil + } + + // Second attempt: resize to 50% and quality 85 + out, err = runMagick(ctx, data, "-resize", "50%", "-quality", "85") + if err != nil { + return nil, "", fmt.Errorf("compress image with resize: %w", err) + } + + if len(out) > maxTelegramPhotoSize { + return nil, "", fmt.Errorf("image still too large after compression (%d bytes)", len(out)) + } + + return out, newFilename, nil +} + +func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error) { + cmdArgs := append([]string{"-"}, args...) + cmdArgs = append(cmdArgs, "jpeg:-") + + cmd := exec.CommandContext(ctx, "magick", cmdArgs...) + cmd.Stdin = bytes.NewReader(data) + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("magick: %w", err) + } + + return out, nil +} + +func replaceExt(filename, newExt string) string { + if i := strings.LastIndex(filename, "."); i >= 0 { + return filename[:i] + newExt + } + return filename + newExt +} diff --git a/internal/memos/types.go b/internal/memos/types.go index 6fc7e59..0c433a6 100644 --- a/internal/memos/types.go +++ b/internal/memos/types.go @@ -37,6 +37,24 @@ func (a Attachment) IsImage() bool { return false } +// IsVideo returns true if the attachment is a video. +func (a Attachment) IsVideo() bool { + switch a.Type { + case "video/mp4", "video/quicktime", "video/webm", "video/mpeg": + return true + } + return false +} + +// IsAudio returns true if the attachment is an audio file. +func (a Attachment) IsAudio() bool { + switch a.Type { + case "audio/mpeg", "audio/ogg", "audio/wav", "audio/mp4", "audio/aac": + return true + } + return false +} + // UID extracts the uid part from "attachments/{uid}". func (a Attachment) UID() string { const prefix = "attachments/" diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 8e86424..3d40683 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -11,6 +11,7 @@ import ( tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "git.vakhrushev.me/av/remembos/internal/config" + "git.vakhrushev.me/av/remembos/internal/media" "git.vakhrushev.me/av/remembos/internal/memory" "git.vakhrushev.me/av/remembos/internal/memos" "git.vakhrushev.me/av/remembos/internal/search" @@ -130,14 +131,22 @@ func (b *Bot) sendMemory(ctx context.Context, mem *search.Memory) { } text := formatMemory(mem, b.publicURL) - images := imageAttachments(mem.Memo) + imageAtts, videoAtts, audioAtts := mediaAttachments(mem.Memo) - var downloaded []imageFile - if len(images) > 0 { - downloaded = b.downloadImages(ctx, images) + var images []mediaFile + var skipped bool + if len(imageAtts) > 0 { + images, skipped = b.downloadAndCompressImages(ctx, imageAtts) } - if err := b.sendWithRetry(ctx, text, downloaded); err != nil { + videos := b.downloadFiles(ctx, videoAtts) + audios := b.downloadFiles(ctx, audioAtts) + + if skipped { + text += "\n\nПоказаны не все вложения" + } + + if err := b.sendWithRetry(ctx, text, images, videos, audios); err != nil { b.logger.Error("failed to send telegram message after retries", "error", err) } } @@ -183,35 +192,57 @@ func (b *Bot) handleMore(ctx context.Context) { b.sendMemory(ctx, mem) } -type imageFile struct { +type mediaFile struct { filename string data []byte } -// downloadImages downloads image attachments, skipping failures. -func (b *Bot) downloadImages(ctx context.Context, attachments []memos.Attachment) []imageFile { - files := make([]imageFile, 0, len(attachments)) +// downloadAndCompressImages downloads image attachments and compresses them if needed. +// Returns downloaded files and whether any were skipped due to errors. +func (b *Bot) downloadAndCompressImages(ctx context.Context, attachments []memos.Attachment) ([]mediaFile, bool) { + files := make([]mediaFile, 0, len(attachments)) + var skipped bool + for _, att := range attachments { + data, err := b.client.DownloadAttachment(ctx, att) + if err != nil { + b.logger.Warn("failed to download attachment, skipping", "name", att.Name, "error", err) + skipped = true + continue + } + + data, filename, err := media.CompressImage(ctx, data, att.Filename) + if err != nil { + b.logger.Warn("failed to compress image, skipping", "name", att.Name, "error", err) + skipped = true + continue + } + + files = append(files, mediaFile{filename: filename, data: data}) + } + return files, skipped +} + +// downloadFiles downloads attachments, skipping failures. +func (b *Bot) downloadFiles(ctx context.Context, attachments []memos.Attachment) []mediaFile { + files := make([]mediaFile, 0, len(attachments)) for _, att := range attachments { data, err := b.client.DownloadAttachment(ctx, att) if err != nil { b.logger.Warn("failed to download attachment, skipping", "name", att.Name, "error", err) continue } - files = append(files, imageFile{ - filename: att.Filename, - data: data, - }) + files = append(files, mediaFile{filename: att.Filename, data: data}) } return files } // sendWithRetry attempts to send the message with up to 3 retries. -func (b *Bot) sendWithRetry(ctx context.Context, text string, images []imageFile) error { +func (b *Bot) sendWithRetry(ctx context.Context, text string, images, videos, audios []mediaFile) error { backoffs := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second} var lastErr error for attempt := range 3 { - lastErr = b.send(text, images) + lastErr = b.sendWithMedia(text, images, videos, audios) if lastErr == nil { return nil } @@ -228,31 +259,59 @@ func (b *Bot) sendWithRetry(ctx context.Context, text string, images []imageFile return lastErr } -// send executes the actual Telegram API calls based on the sending strategy. -// 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 { +// sendWithMedia executes the actual Telegram API calls for text, images, videos, and audios. +func (b *Bot) sendWithMedia(text string, images, videos, audios []mediaFile) error { + hasMedia := len(images) > 0 || len(videos) > 0 || len(audios) > 0 + + // Send text switch { - case len(images) == 0: + case !hasMedia: return b.sendTextParts(splitText(text, maxMessageLen)) - case len(text) <= maxCaptionLen: + case len(text) <= maxCaptionLen && len(images) > 0: // Short text — use as caption on image(s) if len(images) == 1 { - return b.sendPhoto(images[0], text) + if err := b.sendPhoto(images[0], text); err != nil { + return err + } + } else { + if err := b.sendMediaGroup(images, text); err != nil { + return err + } } - return b.sendMediaGroup(images, text) default: - // Long text — send text messages first, then images without caption + // Long text or no images — send text first, then all media if err := b.sendTextParts(splitText(text, maxMessageLen)); err != nil { return err } + // Send images without caption if len(images) == 1 { - return b.sendPhoto(images[0], "") + if err := b.sendPhoto(images[0], ""); err != nil { + return err + } + } else if len(images) > 1 { + if err := b.sendMediaGroup(images, ""); err != nil { + return err + } } - return b.sendMediaGroup(images, "") } + + // Send videos one by one + for _, v := range videos { + if err := b.sendVideo(v); err != nil { + return err + } + } + + // Send audios one by one + for _, a := range audios { + if err := b.sendAudio(a); err != nil { + return err + } + } + + return nil } func (b *Bot) sendTextParts(parts []string) error { @@ -272,7 +331,7 @@ func (b *Bot) sendText(text string) error { return err } -func (b *Bot) sendPhoto(img imageFile, caption string) error { +func (b *Bot) sendPhoto(img mediaFile, caption string) error { photo := tgbotapi.NewPhoto(b.chatID, tgbotapi.FileBytes{ Name: img.filename, Bytes: img.data, @@ -285,7 +344,25 @@ func (b *Bot) sendPhoto(img imageFile, caption string) error { return err } -func (b *Bot) sendMediaGroup(images []imageFile, caption string) error { +func (b *Bot) sendVideo(v mediaFile) error { + video := tgbotapi.NewVideo(b.chatID, tgbotapi.FileBytes{ + Name: v.filename, + Bytes: v.data, + }) + _, err := b.api.Send(video) + return err +} + +func (b *Bot) sendAudio(a mediaFile) error { + audio := tgbotapi.NewAudio(b.chatID, tgbotapi.FileBytes{ + Name: a.filename, + Bytes: a.data, + }) + _, err := b.api.Send(audio) + return err +} + +func (b *Bot) sendMediaGroup(images []mediaFile, caption string) error { media := make([]interface{}, len(images)) for i, img := range images { photo := tgbotapi.NewInputMediaPhoto(tgbotapi.FileBytes{ diff --git a/internal/telegram/format.go b/internal/telegram/format.go index f1fda24..1c2d82b 100644 --- a/internal/telegram/format.go +++ b/internal/telegram/format.go @@ -46,15 +46,19 @@ func formatMemory(mem *search.Memory, publicURL string) string { return b.String() } -// imageAttachments returns image attachments from the memo. -func imageAttachments(memo *memos.Memo) []memos.Attachment { - var images []memos.Attachment +// mediaAttachments splits memo attachments into images, videos, and audios. +func mediaAttachments(memo *memos.Memo) (images, videos, audios []memos.Attachment) { for _, att := range memo.Attachments { - if att.IsImage() { + switch { + case att.IsImage(): images = append(images, att) + case att.IsVideo(): + videos = append(videos, att) + case att.IsAudio(): + audios = append(audios, att) } } - return images + return } // splitText splits text into chunks of at most maxLen bytes,