Skip to content
1 change: 1 addition & 0 deletions pkg/connector/handle_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func (lc *LineClient) newMessageHandler() *handlers.Handler {
IsLoggedOut: lc.isLoggedOut,
NewClient: func() *line.Client { return line.NewClient(lc.AccessToken) },
DecryptMedia: lc.decryptImageData,
IsAnimatedGif: isAnimatedGif,
}
}

Expand Down
3 changes: 3 additions & 0 deletions pkg/connector/handlers/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ type Handler struct {

// DecryptMedia decrypts E2EE encrypted media data using the given key material.
DecryptMedia func(data []byte, keyMaterial string) ([]byte, error)

// IsAnimatedGif checks if the given data is an animated GIF.
IsAnimatedGif func(data []byte) bool
}

// tryRecoverClient attempts token recovery on auth errors and returns a fresh client.
Expand Down
128 changes: 115 additions & 13 deletions pkg/connector/handlers/image.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package handlers

import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"strings"
"time"

Expand All @@ -28,9 +30,77 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
return nil, nil
}

// MEDIA_CONTENT_INFO marks animated GIFs, which need the original OBS object.
metadataAnimated := false
var metadataWidth, metadataHeight int
if mediaInfo := data.ContentMetadata["MEDIA_CONTENT_INFO"]; mediaInfo != "" {
var info struct {
Animated bool `json:"animated"`
Width int `json:"width"`
Height int `json:"height"`
}
if json.Unmarshal([]byte(mediaInfo), &info) == nil {
metadataAnimated = info.Animated
metadataWidth = info.Width
metadataHeight = info.Height
}
}
if thumbInfo := data.ContentMetadata["MEDIA_THUMB_INFO"]; thumbInfo != "" {
var info struct {
Width int `json:"width"`
Height int `json:"height"`
}
if json.Unmarshal([]byte(thumbInfo), &info) == nil {
metadataWidth = info.Width
metadataHeight = info.Height
}
}

mediaCategory := lineMediaCategory(data.ContentMetadata)
downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia)

downloadImage := func(c *line.Client) ([]byte, error) {
sid := "emi"
if isPlainMedia {
sid = "m"
}
if metadataAnimated {
originalOptions := downloadOptions
originalOptions.TID = "original"
standardOptions := downloadOptions
if isPlainMedia {
standardOptions.TID = ""
}

if isPlainMedia {
imgData, err := c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, originalOptions)
if err == nil {
return imgData, nil
}
h.Log.Debug().
Err(err).
Str("oid", oid).
Str("msg_id", data.ID).
Str("sid", sid).
Msg("Failed to download animated image original, falling back to standard OBS path")
return c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, standardOptions)
}

imgData, err := c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, standardOptions)
if err == nil {
return imgData, nil
}
h.Log.Debug().
Err(err).
Str("oid", oid).
Str("msg_id", data.ID).
Str("sid", sid).
Msg("Failed to download encrypted animated image, falling back to original OBS path")
return c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, originalOptions)
}
return c.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions)
}

var imgData []byte
var err error
dlStart := time.Now()
Expand All @@ -42,20 +112,12 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
Bool("has_obs_pop", downloadOptions.OBSPop != "").
Bool("plain_media", isPlainMedia).
Msg("Downloading image from LINE OBS")
if isPlainMedia {
imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, "m", downloadOptions)
} else {
imgData, err = client.DownloadOBSWithOptions(ctx, oid, data.ID, downloadOptions)
}
imgData, err = downloadImage(client)

// Refresh token if we get a 401
if newClient, ok := h.tryRecoverClient(ctx, err); ok {
client = newClient
if isPlainMedia {
imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, "m", downloadOptions)
} else {
imgData, err = client.DownloadOBSWithOptions(ctx, oid, data.ID, downloadOptions)
}
imgData, err = downloadImage(client)
}
downloadDuration := time.Since(dlStart)

Expand Down Expand Up @@ -104,9 +166,29 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
}
}

fileName := "image.jpg"
mimeType := "image/jpeg"
isAnimated := false

if h.IsAnimatedGif != nil && h.IsAnimatedGif(imgData) {
fileName = "image.gif"
mimeType = "image/gif"
isAnimated = true
} else if len(imgData) >= 3 && string(imgData[0:3]) == "GIF" {
fileName = "image.gif"
mimeType = "image/gif"
isAnimated = metadataAnimated
} else if len(imgData) >= 8 && string(imgData[:8]) == "\x89PNG\r\n\x1a\n" {
fileName = "image.png"
mimeType = "image/png"
} else if len(imgData) >= 12 && string(imgData[:4]) == "RIFF" && string(imgData[8:12]) == "WEBP" {
fileName = "image.webp"
mimeType = "image/webp"
}

// Upload to Matrix
uploadStart := time.Now()
mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg")
mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, fileName, mimeType)
uploadDuration := time.Since(uploadStart)
if err != nil {
h.Log.Error().
Expand All @@ -119,6 +201,25 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
return nil, fmt.Errorf("failed to upload image to matrix: %w", err)
}

msgType := event.MsgImage
info := &event.FileInfo{
MimeType: mimeType,
Size: len(imgData),
}
if metadataWidth > 0 && metadataHeight > 0 {
info.Width = metadataWidth
info.Height = metadataHeight
} else if config, _, err := image.DecodeConfig(bytes.NewReader(imgData)); err != nil {
h.Log.Warn().Err(err).Bool("animated", isAnimated).Msg("Failed to decode image dimensions")
} else {
info.Width = config.Width
info.Height = config.Height
}
if isAnimated {
info.MauGIF = true
info.IsAnimated = true
}

matrixMediaURL := string(mxc)
if file != nil && file.URL != "" {
matrixMediaURL = string(file.URL)
Expand All @@ -137,10 +238,11 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgImage,
Body: "image.jpg",
MsgType: msgType,
Body: fileName,
URL: mxc,
File: file,
Info: info,
RelatesTo: relatesTo,
},
},
Expand Down
10 changes: 10 additions & 0 deletions pkg/connector/handlers/video.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ func (h *Handler) ConvertVideo(ctx context.Context, portal *bridgev2.Portal, int
if duration > 0 {
videoInfo.Duration = duration
}
if thumbInfo := data.ContentMetadata["MEDIA_THUMB_INFO"]; thumbInfo != "" {
var info struct {
Width int `json:"width"`
Height int `json:"height"`
}
if json.Unmarshal([]byte(thumbInfo), &info) == nil && info.Width > 0 && info.Height > 0 {
videoInfo.Width = info.Width
videoInfo.Height = info.Height
}
}

return &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{
Expand Down
40 changes: 40 additions & 0 deletions pkg/connector/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,46 @@ func isAnimatedGif(data []byte) bool {
return false
}

// convertVideoToGIF converts video data (mp4/webm) to an animated GIF using ffmpeg.
func convertVideoToGIF(videoData []byte) ([]byte, error) {
tmpVideoFile, err := os.CreateTemp("", "video-*.mp4")
if err != nil {
return nil, fmt.Errorf("failed to create temp video file: %w", err)
}
defer os.Remove(tmpVideoFile.Name())

if _, err := tmpVideoFile.Write(videoData); err != nil {
tmpVideoFile.Close()
return nil, fmt.Errorf("failed to write video data: %w", err)
}
tmpVideoFile.Close()

tmpGIFFile, err := os.CreateTemp("", "gif-*.gif")
if err != nil {
return nil, fmt.Errorf("failed to create temp GIF file: %w", err)
}
defer os.Remove(tmpGIFFile.Name())
tmpGIFFile.Close()

err = ffmpeg.Input(tmpVideoFile.Name()).
Output(tmpGIFFile.Name(), ffmpeg.KwArgs{
"vf": "fps=15,scale=320:-1:flags=lanczos",
}).
OverWriteOutput().
Silent(true).
Run()
if err != nil {
return nil, fmt.Errorf("ffmpeg video-to-gif failed: %w", err)
}

gifData, err := os.ReadFile(tmpGIFFile.Name())
if err != nil {
return nil, fmt.Errorf("failed to read GIF output: %w", err)
}

return gifData, nil
}

// generates the first frame of a video and resizes it to fit within 384x384
func extractVideoThumbnail(videoData []byte) ([]byte, int, int, error) {
tmpVideoFile, err := os.CreateTemp("", "video-*.mp4")
Expand Down
Loading
Loading