From f1979258f6f1557854d755a1e682f82e4e59f46f Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sat, 16 May 2026 04:20:04 +0000 Subject: [PATCH 1/8] feat: add GIF support --- pkg/connector/handle_message.go | 1 + pkg/connector/handlers/handler.go | 3 + pkg/connector/handlers/image.go | 99 +++++++++++++++++++---- pkg/connector/media.go | 40 +++++++++ pkg/connector/send_message.go | 129 +++++++++++++++++++++++++----- pkg/connector/userinfo.go | 8 ++ pkg/line/client.go | 24 +++++- 7 files changed, 266 insertions(+), 38 deletions(-) diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index f38a9b8..443be34 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -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, } } diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 12b1a66..95b276d 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -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. diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 2b292e0..9cf2a41 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -27,22 +27,61 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int return nil, nil } - var imgData []byte - var err error - if isPlainMedia { - imgData, err = client.DownloadOBSWithSID(ctx, oid, data.ID, "m") - } else { - imgData, err = client.DownloadOBS(ctx, oid, data.ID) + // MEDIA_CONTENT_INFO marks animated GIFs, which need the original OBS object. + metadataAnimated := false + if mediaInfo := data.ContentMetadata["MEDIA_CONTENT_INFO"]; mediaInfo != "" { + var info struct { + Animated bool `json:"animated"` + } + if json.Unmarshal([]byte(mediaInfo), &info) == nil && info.Animated { + metadataAnimated = true + } + } + + downloadImage := func(c *line.Client) ([]byte, error) { + sid := "emi" + if isPlainMedia { + sid = "m" + } + if metadataAnimated { + if isPlainMedia { + imgData, err := c.DownloadOBSOriginal(ctx, oid, data.ID, sid) + 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.DownloadOBSWithSID(ctx, oid, data.ID, sid) + } + + imgData, err := c.DownloadOBSWithSID(ctx, oid, data.ID, sid) + 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.DownloadOBSOriginal(ctx, oid, data.ID, sid) + } + if isPlainMedia { + return c.DownloadOBSWithSID(ctx, oid, data.ID, sid) + } + return c.DownloadOBS(ctx, oid, data.ID) } + 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.DownloadOBSWithSID(ctx, oid, data.ID, "m") - } else { - imgData, err = client.DownloadOBS(ctx, oid, data.ID) - } + imgData, err = downloadImage(client) } if err != nil { @@ -82,22 +121,54 @@ 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 - mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg") + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, fileName, mimeType) if err != nil { h.Log.Error().Err(err).Int("size_bytes", len(imgData)).Msg("Failed to upload image to Matrix") return nil, fmt.Errorf("failed to upload image to matrix: %w", err) } + msgType := event.MsgImage + var info *event.FileInfo + if isAnimated { + msgType = event.MsgVideo + info = &event.FileInfo{ + MimeType: mimeType, + Size: len(imgData), + MauGIF: true, + } + } + return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ { Type: event.EventMessage, Content: &event.MessageEventContent{ - MsgType: event.MsgImage, - Body: "image.jpg", + MsgType: msgType, + Body: fileName, URL: mxc, File: file, + Info: info, RelatesTo: relatesTo, }, }, diff --git a/pkg/connector/media.go b/pkg/connector/media.go index 5f2cecd..5684a1e 100644 --- a/pkg/connector/media.go +++ b/pkg/connector/media.go @@ -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") diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index 5f956b4..f2a3f77 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -102,11 +102,9 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat // For plain media: we send the message first, then upload media to r/talk/m/{msgId}. var plainMediaData []byte // raw media data to upload after sending - var plainThumbData []byte // raw thumbnail data to upload after sending // For group chats, save original data in case E2EE encryption fails and we fall back to plain. var originalMediaData []byte - var originalThumbData []byte // For file uploads, save the raw data so we can retry with ZIP wrapping if LINE rejects // the raw file (some file types require ZIP, others like PDF work directly). @@ -131,7 +129,10 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, fmt.Errorf("failed to download media from matrix: %w", err) } - mimeType := msg.Content.Info.MimeType + mimeType := "" + if msg.Content.Info != nil { + mimeType = msg.Content.Info.MimeType + } isGif := mimeType == "image/gif" isAnimated := isGif && isAnimatedGif(data) @@ -148,11 +149,10 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat // Plain media: save data for post-send upload to r/talk/m/{msgId} plainMediaData = data - thumbnailData, thumbWidth, thumbHeight, err := generateThumbnail(data) + _, thumbWidth, thumbHeight, err := generateThumbnail(data) if err != nil { lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate thumbnail, continuing without it") } else { - plainThumbData = thumbnailData mediaThumbInfo := map[string]interface{}{ "width": thumbWidth, "height": thumbHeight, @@ -187,9 +187,6 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat // Save original data for potential group E2EE fallback if isGroup { originalMediaData = data - if thumbData, _, _, tErr := generateThumbnail(data); tErr == nil { - originalThumbData = thumbData - } } uploadData, keyMaterialB64, err := lc.encryptFileData(data) @@ -326,11 +323,110 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, fmt.Errorf("failed to download video from matrix: %w", err) } + // Beeper sends GIFs as MsgVideo with fi.mau.gif=true — treat as animated image + if msg.Content.Info != nil && msg.Content.Info.MauGIF { + // Convert video (mp4/webm) to actual GIF format for LINE + if !isAnimatedGif(data) { + gifData, convErr := convertVideoToGIF(data) + if convErr != nil { + return nil, fmt.Errorf("failed to convert video to GIF: %w", convErr) + } + data = gifData + lc.UserLogin.Bridge.Log.Info(). + Int("gif_size", len(data)). + Msg("Converted video to GIF for LINE") + } + + contentType = int(ContentImage) + + fileName := msg.Content.GetFileName() + if fileName == "" { + fileName = "image.gif" + } + contentMetadata["FILE_NAME"] = fileName + contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(data)) + contentMetadata["contentType"] = fmt.Sprintf("%d", ContentImage) + + setGifMediaInfo := func(fileSize int) { + mediaContentInfo := map[string]interface{}{ + "category": "original", + "fileSize": fileSize, + "extension": "gif", + "animated": true, + } + if mediaInfoJSON, err := json.Marshal(mediaContentInfo); err == nil { + contentMetadata["MEDIA_CONTENT_INFO"] = string(mediaInfoJSON) + } + } + setGifMediaInfo(len(data)) + + if plainText { + plainMediaData = data + + _, thumbWidth, thumbHeight, err := generateThumbnail(data) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate GIF thumbnail dimensions, continuing without it") + } else { + mediaThumbInfo := map[string]interface{}{ + "width": thumbWidth, + "height": thumbHeight, + } + if thumbInfoJSON, err := json.Marshal(mediaThumbInfo); err == nil { + contentMetadata["MEDIA_THUMB_INFO"] = string(thumbInfoJSON) + } + } + } else { + if isGroup { + originalMediaData = data + } + + uploadData, keyMaterialB64, err := lc.encryptFileData(data) + if err != nil { + return nil, fmt.Errorf("failed to encrypt GIF data: %w", err) + } + + oid, err := client.UploadOBS(uploadData) + if err != nil { + return nil, fmt.Errorf("failed to upload GIF to OBS: %w", err) + } + + thumbnailData, thumbWidth, thumbHeight, err := generateThumbnail(data) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate GIF thumbnail, continuing without it") + } else if thumbToUpload, err := encryptThumbnail(thumbnailData, keyMaterialB64); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to encrypt GIF thumbnail, continuing without it") + } else { + previewOID := fmt.Sprintf("%s__ud-preview", oid) + if err := client.UploadOBSWithOID(thumbToUpload, previewOID); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to upload GIF preview, continuing without it") + } else { + mediaThumbInfo := map[string]interface{}{ + "width": thumbWidth, + "height": thumbHeight, + } + if thumbInfoJSON, err := json.Marshal(mediaThumbInfo); err == nil { + contentMetadata["MEDIA_THUMB_INFO"] = string(thumbInfoJSON) + } + } + } + + contentMetadata["OID"] = oid + contentMetadata["SID"] = "emi" + contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(uploadData)) + contentMetadata["ENC_KM"] = keyMaterialB64 + setGifMediaInfo(len(uploadData)) + + imgPayload := map[string]string{"keyMaterial": keyMaterialB64} + payload, _ = json.Marshal(imgPayload) + } + break + } + contentType = int(ContentVideo) contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(data)) contentMetadata["contentType"] = fmt.Sprintf("%d", ContentVideo) - if msg.Content.Info.Duration > 0 { + if msg.Content.Info != nil && msg.Content.Info.Duration > 0 { contentMetadata["DURATION"] = fmt.Sprintf("%d", msg.Content.Info.Duration) } @@ -349,7 +445,6 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat thumbnailData = thumbBuf.Bytes() } if len(thumbnailData) > 0 { - plainThumbData = thumbnailData mediaThumbInfo := map[string]interface{}{ "width": thumbWidth, "height": thumbHeight, @@ -366,9 +461,6 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } else { if isGroup { originalMediaData = data - if thumbData, _, _, tErr := extractVideoThumbnail(data); tErr == nil { - originalThumbData = thumbData - } } uploadData, keyMaterialB64, err := lc.encryptVideoData(data) @@ -544,7 +636,6 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat delete(contentMetadata, "SID") delete(contentMetadata, "ENC_KM") plainMediaData = originalMediaData - plainThumbData = originalThumbData } } } @@ -699,7 +790,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat obsType = "file" } - if err := client.UploadOBSPlain(plainMediaData, sentMsg.ID, obsType); err != nil { + if err := client.UploadOBSPlain(plainMediaData, sentMsg.ID, obsType, contentMetadata["FILE_NAME"]); err != nil { return nil, fmt.Errorf("failed to upload plain media to OBS: %w", err) } lc.UserLogin.Bridge.Log.Info(). @@ -708,12 +799,8 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat Int("media_size", len(plainMediaData)). Msg("Uploaded plain media after sending") - if plainThumbData != nil { - previewID := fmt.Sprintf("%s__ud-preview", sentMsg.ID) - if err := client.UploadOBSPlain(plainThumbData, previewID, obsType); err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to upload plain media thumbnail, continuing without it") - } - } + // Skip plain thumbnail upload — LINE generates thumbnails server-side + // for media uploaded to the r/talk/m/ endpoint. } return &bridgev2.MatrixMessageResponse{ diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 8524e1a..70c4d86 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -122,6 +122,14 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port "audio/3gpp": event.CapLevelFullySupported, }, }, + event.CapMsgGIF: { + Caption: event.CapLevelRejected, + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/gif": event.CapLevelFullySupported, + "video/mp4": event.CapLevelFullySupported, + "video/webm": event.CapLevelFullySupported, + }, + }, }, } } diff --git a/pkg/line/client.go b/pkg/line/client.go index dcd45dd..cc854af 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -474,7 +474,8 @@ func obsTypeFromSID(sid string) string { // UploadOBSPlain uploads plain (non-E2EE) media to a specific OID via the "m" endpoint. // obsType should be "image", "video", "audio", or "file". -func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error { +// fileName is used in the OBS params name field (falls back to timestamp if empty). +func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string, fileName string) error { obsToken, err := c.AcquireEncryptedAccessToken() if err != nil { return fmt.Errorf("failed to acquire OBS token: %w", err) @@ -487,10 +488,15 @@ func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error { return fmt.Errorf("failed to create OBS request: %w", err) } + if fileName == "" { + fileName = fmt.Sprintf("%d", time.Now().UnixMilli()) + } + obsParams := map[string]string{ "ver": "2.0", - "name": fmt.Sprintf("%d", time.Now().UnixMilli()), + "name": fileName, "type": obsType, + "cat": "original", } obsParamsJSON, _ := json.Marshal(obsParams) obsParamsB64 := base64.StdEncoding.EncodeToString(obsParamsJSON) @@ -498,7 +504,7 @@ func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error { req.Header.Set("User-Agent", UserAgent) req.Header.Set("x-line-application", "CHROMEOS\t3.7.2\tChrome_OS") req.Header.Set("x-lal", "en_US") - req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("X-Obs-Params", obsParamsB64) req.Header.Set("x-line-access", obsToken) @@ -643,9 +649,21 @@ func (c *Client) DownloadOBS(ctx context.Context, oid string, messageID string) } func (c *Client) DownloadOBSWithSID(ctx context.Context, oid string, messageID string, sid string) ([]byte, error) { + return c.downloadOBSInternal(ctx, oid, messageID, sid, "") +} + +// DownloadOBSOriginal downloads the original quality media instead of LINE's preview. +func (c *Client) DownloadOBSOriginal(ctx context.Context, oid string, messageID string, sid string) ([]byte, error) { + return c.downloadOBSInternal(ctx, oid, messageID, sid, "original") +} + +func (c *Client) downloadOBSInternal(ctx context.Context, oid string, messageID string, sid string, suffix string) ([]byte, error) { // URL structure: https://obs.line-apps.com/r/talk/{SID}/{OID} // SID: emi (images), emv (videos), ema (audio), emf (files) url := fmt.Sprintf("%s/r/talk/%s/%s", OBSBaseURL, sid, oid) + if suffix != "" { + url += "/" + suffix + } obsToken, err := c.AcquireEncryptedAccessToken() if err != nil { From 6e0b6ad3305716c44555f3a13cf615606d8b5909 Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Wed, 20 May 2026 12:13:29 +0000 Subject: [PATCH 2/8] Fix bridged GIF rendering --- pkg/connector/handlers/image.go | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 9cf2a41..6ba4f19 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -1,9 +1,11 @@ package handlers import ( + "bytes" "context" "encoding/json" "fmt" + "image" "strings" "maunium.net/go/mautrix/bridgev2" @@ -37,6 +39,17 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int metadataAnimated = true } } + var metadataWidth, metadataHeight int + 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 + } + } downloadImage := func(c *line.Client) ([]byte, error) { sid := "emi" @@ -151,11 +164,20 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int msgType := event.MsgImage var info *event.FileInfo if isAnimated { - msgType = event.MsgVideo info = &event.FileInfo{ - MimeType: mimeType, - Size: len(imgData), - MauGIF: true, + MimeType: mimeType, + Size: len(imgData), + MauGIF: true, + IsAnimated: true, + } + 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).Msg("Failed to decode animated image dimensions") + } else { + info.Width = config.Width + info.Height = config.Height } } From 0294c7e73d3d72cbc14b5533fadf4ebc98370b0b Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Wed, 20 May 2026 12:22:08 +0000 Subject: [PATCH 3/8] Fix OBS token cache per account --- pkg/line/methods.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pkg/line/methods.go b/pkg/line/methods.go index b993240..3d77504 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -11,13 +11,17 @@ import ( ) var ( - obsTokenMu sync.Mutex - obsTokenCache string - obsTokenExpiry time.Time + obsTokenMu sync.Mutex + obsTokenCache = make(map[string]obsTokenCacheEntry) ) const obsTokenBuffer = 30 * time.Second +type obsTokenCacheEntry struct { + token string + expiry time.Time +} + // LoginV2 performs the loginV2 RPC call to authenticate a user func (c *Client) LoginV2(email, password, certificate, secret string) ([]byte, error) { return c.LoginV2WithType(2, email, password, certificate, secret) @@ -579,11 +583,11 @@ func (c *Client) GetLastOpRevision() (int64, error) { // this token is used to encrypt images, videos, and files uploaded to LINE's OBS storage func (c *Client) AcquireEncryptedAccessToken() (string, error) { + cacheKey := c.AccessToken obsTokenMu.Lock() - if obsTokenCache != "" && time.Now().Before(obsTokenExpiry) { - cached := obsTokenCache + if cached, ok := obsTokenCache[cacheKey]; ok && cached.token != "" && time.Now().Before(cached.expiry) { obsTokenMu.Unlock() - return cached, nil + return cached.token, nil } obsTokenMu.Unlock() @@ -615,8 +619,10 @@ func (c *Client) AcquireEncryptedAccessToken() (string, error) { token := parts[1] if expirySec, err := strconv.Atoi(parts[0]); err == nil && expirySec > 0 { obsTokenMu.Lock() - obsTokenCache = token - obsTokenExpiry = time.Now().Add(time.Duration(expirySec)*time.Second - obsTokenBuffer) + obsTokenCache[cacheKey] = obsTokenCacheEntry{ + token: token, + expiry: time.Now().Add(time.Duration(expirySec)*time.Second - obsTokenBuffer), + } obsTokenMu.Unlock() } From d757caa3abc500d5636f94096e02810ed508a822 Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Wed, 20 May 2026 12:47:42 +0000 Subject: [PATCH 4/8] Fix Matrix media dimensions --- pkg/connector/handlers/image.go | 41 ++++++++++++++++++--------------- pkg/connector/handlers/video.go | 10 ++++++++ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 6ba4f19..dc9c834 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -31,15 +31,19 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int // 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 && info.Animated { - metadataAnimated = true + if json.Unmarshal([]byte(mediaInfo), &info) == nil { + metadataAnimated = info.Animated + metadataWidth = info.Width + metadataHeight = info.Height } } - var metadataWidth, metadataHeight int if thumbInfo := data.ContentMetadata["MEDIA_THUMB_INFO"]; thumbInfo != "" { var info struct { Width int `json:"width"` @@ -162,23 +166,22 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int } msgType := event.MsgImage - var info *event.FileInfo + 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 = &event.FileInfo{ - MimeType: mimeType, - Size: len(imgData), - MauGIF: true, - IsAnimated: true, - } - 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).Msg("Failed to decode animated image dimensions") - } else { - info.Width = config.Width - info.Height = config.Height - } + info.MauGIF = true + info.IsAnimated = true } return &bridgev2.ConvertedMessage{ diff --git a/pkg/connector/handlers/video.go b/pkg/connector/handlers/video.go index 7e5d64c..e3f1827 100644 --- a/pkg/connector/handlers/video.go +++ b/pkg/connector/handlers/video.go @@ -165,6 +165,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{ From 0e4557500b08e4278e2682c530b1ae1ebc0c772c Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sat, 23 May 2026 06:59:00 +0000 Subject: [PATCH 5/8] Fix Go formatting --- pkg/line/methods.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 490b5af..1c25215 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -21,7 +21,7 @@ type obsTokenCacheEntry struct { token string expiry time.Time } - + // InvalidateOBSTokenCache clears the cached OBS access token. The OBS token is // derived from the main LINE access token; when the latter is rotated (refresh // or re-login) any previously-issued OBS token is invalidated server-side, but From 36deac1150b063effaf0b598942a634a3746ae0c Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sat, 23 May 2026 07:03:40 +0000 Subject: [PATCH 6/8] Fix OBS token cache invalidation --- pkg/line/methods.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 1c25215..6779c99 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -29,8 +29,7 @@ type obsTokenCacheEntry struct { // Callers must invoke this after any successful re-authentication. func InvalidateOBSTokenCache() { obsTokenMu.Lock() - obsTokenCache = "" - obsTokenExpiry = time.Time{} + obsTokenCache = make(map[string]obsTokenCacheEntry) obsTokenMu.Unlock() } From 76377821bf191f72bdcea278ec516fc20913b6b6 Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 7 Jun 2026 15:12:42 +0900 Subject: [PATCH 7/8] Skip Docker build on fork PRs --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c0a569c..16a5fde 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -71,6 +71,7 @@ jobs: build-docker: runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v4 From 51b896da17d179bb939523f27c99be48f8bacf46 Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 7 Jun 2026 16:02:47 +0900 Subject: [PATCH 8/8] Remove GIF PR workflow change --- .github/workflows/deploy.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 16a5fde..c0a569c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -71,7 +71,6 @@ jobs: build-docker: runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v4