add image conversion for telegram

This commit is contained in:
2026-02-13 09:37:54 +03:00
parent 1905e7ab16
commit e1ebb83641
5 changed files with 203 additions and 35 deletions
+5 -2
View File
@@ -12,10 +12,13 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/remembos ./cmd/remembos 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 COPY --from=build /out/remembos /remembos
USER nonroot:nonroot USER appuser
ENTRYPOINT ["/remembos"] ENTRYPOINT ["/remembos"]
+66
View File
@@ -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
}
+18
View File
@@ -37,6 +37,24 @@ func (a Attachment) IsImage() bool {
return false 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}". // UID extracts the uid part from "attachments/{uid}".
func (a Attachment) UID() string { func (a Attachment) UID() string {
const prefix = "attachments/" const prefix = "attachments/"
+105 -28
View File
@@ -11,6 +11,7 @@ import (
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/remembos/internal/config" "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/memory"
"git.vakhrushev.me/av/remembos/internal/memos" "git.vakhrushev.me/av/remembos/internal/memos"
"git.vakhrushev.me/av/remembos/internal/search" "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) text := formatMemory(mem, b.publicURL)
images := imageAttachments(mem.Memo) imageAtts, videoAtts, audioAtts := mediaAttachments(mem.Memo)
var downloaded []imageFile var images []mediaFile
if len(images) > 0 { var skipped bool
downloaded = b.downloadImages(ctx, images) 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) 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) b.sendMemory(ctx, mem)
} }
type imageFile struct { type mediaFile struct {
filename string filename string
data []byte data []byte
} }
// downloadImages downloads image attachments, skipping failures. // downloadAndCompressImages downloads image attachments and compresses them if needed.
func (b *Bot) downloadImages(ctx context.Context, attachments []memos.Attachment) []imageFile { // Returns downloaded files and whether any were skipped due to errors.
files := make([]imageFile, 0, len(attachments)) 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 { for _, att := range attachments {
data, err := b.client.DownloadAttachment(ctx, att) data, err := b.client.DownloadAttachment(ctx, att)
if err != nil { if err != nil {
b.logger.Warn("failed to download attachment, skipping", "name", att.Name, "error", err) b.logger.Warn("failed to download attachment, skipping", "name", att.Name, "error", err)
continue continue
} }
files = append(files, imageFile{ files = append(files, mediaFile{filename: att.Filename, data: data})
filename: att.Filename,
data: data,
})
} }
return files return files
} }
// sendWithRetry attempts to send the message with up to 3 retries. // 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} backoffs := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second}
var lastErr error var lastErr error
for attempt := range 3 { for attempt := range 3 {
lastErr = b.send(text, images) lastErr = b.sendWithMedia(text, images, videos, audios)
if lastErr == nil { if lastErr == nil {
return nil return nil
} }
@@ -228,31 +259,59 @@ func (b *Bot) sendWithRetry(ctx context.Context, text string, images []imageFile
return lastErr return lastErr
} }
// send executes the actual Telegram API calls based on the sending strategy. // sendWithMedia executes the actual Telegram API calls for text, images, videos, and audios.
// Long text is split into multiple messages. Images are sent with a caption func (b *Bot) sendWithMedia(text string, images, videos, audios []mediaFile) error {
// only if the full text fits within the caption limit. hasMedia := len(images) > 0 || len(videos) > 0 || len(audios) > 0
func (b *Bot) send(text string, images []imageFile) error {
// Send text
switch { switch {
case len(images) == 0: case !hasMedia:
return b.sendTextParts(splitText(text, maxMessageLen)) 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) // Short text — use as caption on image(s)
if len(images) == 1 { 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: 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 { if err := b.sendTextParts(splitText(text, maxMessageLen)); err != nil {
return err return err
} }
// Send images without caption
if len(images) == 1 { 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 { func (b *Bot) sendTextParts(parts []string) error {
@@ -272,7 +331,7 @@ func (b *Bot) sendText(text string) error {
return err 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{ photo := tgbotapi.NewPhoto(b.chatID, tgbotapi.FileBytes{
Name: img.filename, Name: img.filename,
Bytes: img.data, Bytes: img.data,
@@ -285,7 +344,25 @@ func (b *Bot) sendPhoto(img imageFile, caption string) error {
return err 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)) media := make([]interface{}, len(images))
for i, img := range images { for i, img := range images {
photo := tgbotapi.NewInputMediaPhoto(tgbotapi.FileBytes{ photo := tgbotapi.NewInputMediaPhoto(tgbotapi.FileBytes{
+9 -5
View File
@@ -46,15 +46,19 @@ func formatMemory(mem *search.Memory, publicURL string) string {
return b.String() return b.String()
} }
// imageAttachments returns image attachments from the memo. // mediaAttachments splits memo attachments into images, videos, and audios.
func imageAttachments(memo *memos.Memo) []memos.Attachment { func mediaAttachments(memo *memos.Memo) (images, videos, audios []memos.Attachment) {
var images []memos.Attachment
for _, att := range memo.Attachments { for _, att := range memo.Attachments {
if att.IsImage() { switch {
case att.IsImage():
images = append(images, att) 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, // splitText splits text into chunks of at most maxLen bytes,