add image conversion for telegram
This commit is contained in:
+5
-2
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user