From 51996b5ca2472687428a253d82d69c1723ff4285 Mon Sep 17 00:00:00 2001 From: highesttt Date: Fri, 26 Dec 2025 09:11:28 -0600 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Added=20photo=20and?= =?UTF-8?q?=20gif=20sending=20&=20receiving?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mini bug fixes regarding groups aswell --- go.mod | 3 +- go.sum | 2 + pkg/connector/client.go | 612 ++++++++++++++++++++++++++++++++++++---- pkg/e2ee/manager.go | 33 ++- pkg/line/client.go | 188 ++++++++++++ pkg/line/methods.go | 31 ++ pkg/line/structs.go | 53 +++- 7 files changed, 848 insertions(+), 74 deletions(-) diff --git a/go.mod b/go.mod index e8680cb..6eb0991 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.24.0 require ( github.com/rs/zerolog v1.34.0 go.mau.fi/util v0.9.3 + golang.org/x/crypto v0.44.0 + golang.org/x/image v0.23.0 maunium.net/go/mautrix v0.26.0 ) @@ -30,7 +32,6 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/yuin/goldmark v1.7.13 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/crypto v0.44.0 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect diff --git a/go.sum b/go.sum index 11e5a9f..720b756 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= diff --git a/pkg/connector/client.go b/pkg/connector/client.go index c024d5a..e7bc86a 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -1,9 +1,20 @@ package connector import ( + "bytes" "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" + "image" + _ "image/gif" + "image/jpeg" + _ "image/png" "io" "net/http" "strconv" @@ -12,6 +23,8 @@ import ( "time" "go.mau.fi/util/ptr" + "golang.org/x/crypto/hkdf" + "golang.org/x/image/draw" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" @@ -73,6 +86,166 @@ func (lc *LineClient) refreshAndSave(ctx context.Context) error { return nil } +// AES-256-CTR +// LINE's E2EE file format: [encrypted_data][32-byte HMAC] +// The keyMaterial is derived using HKDF to get encKey (32), macKey (32), and nonce (12 bytes) +func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 string) ([]byte, error) { + keyMaterial, err := base64.StdEncoding.DecodeString(keyMaterialB64) + if err != nil { + return nil, fmt.Errorf("failed to decode key material: %w", err) + } + + // Derive keys using HKDF (SHA-256, no salt, info="FileEncryption") + // Derives 76 bytes: encKey(32) + macKey(32) + nonce(12) + kdf := hkdf.New(sha256.New, keyMaterial, nil, []byte("FileEncryption")) + derived := make([]byte, 76) + if _, err := io.ReadFull(kdf, derived); err != nil { + return nil, fmt.Errorf("failed to derive keys: %w", err) + } + + encKey := derived[0:32] + // macKey := derived[32:64] // for HMAC verification + nonce := derived[64:76] + + // Create 16-byte counter: nonce(12 bytes) + zero counter(4 bytes) + counter := make([]byte, 16) + copy(counter, nonce) + // Last 4 bytes remain zero + + if len(encryptedData) < 32 { + return nil, fmt.Errorf("encrypted data too short (< 32 bytes for HMAC)") + } + encryptedData = encryptedData[:len(encryptedData)-32] + + block, err := aes.NewCipher(encKey) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + stream := cipher.NewCTR(block, counter) + + decrypted := make([]byte, len(encryptedData)) + stream.XORKeyStream(decrypted, encryptedData) + + return decrypted, nil +} + +// AES-256-CTR +func (lc *LineClient) encryptImageData(plainData []byte) ([]byte, string, error) { + keyMaterial := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, keyMaterial); err != nil { + return nil, "", fmt.Errorf("failed to generate key material: %w", err) + } + + // Derive keys using HKDF (SHA-256, no salt, info="FileEncryption") + // Derives 76 bytes: encKey(32) + macKey(32) + nonce(12) + kdf := hkdf.New(sha256.New, keyMaterial, nil, []byte("FileEncryption")) + derived := make([]byte, 76) + if _, err := io.ReadFull(kdf, derived); err != nil { + return nil, "", fmt.Errorf("failed to derive keys: %w", err) + } + + encKey := derived[0:32] + macKey := derived[32:64] + nonce := derived[64:76] + + // Create 16-byte counter: nonce(12 bytes) + zero counter(4 bytes) + counter := make([]byte, 16) + copy(counter, nonce) + + block, err := aes.NewCipher(encKey) + if err != nil { + return nil, "", fmt.Errorf("failed to create AES cipher: %w", err) + } + + stream := cipher.NewCTR(block, counter) + + encrypted := make([]byte, len(plainData)) + stream.XORKeyStream(encrypted, plainData) + + h := hmac.New(sha256.New, macKey) + h.Write(encrypted) + hmacSum := h.Sum(nil) + + // LINE E2EE file format: [encrypted_data][32-byte HMAC] + result := append(encrypted, hmacSum...) + + keyMaterialB64 := base64.StdEncoding.EncodeToString(keyMaterial) + + return result, keyMaterialB64, nil +} + +func generateThumbnail(imageData []byte) ([]byte, int, int, error) { + img, _, err := image.Decode(bytes.NewReader(imageData)) + if err != nil { + return nil, 0, 0, fmt.Errorf("failed to decode image: %w", err) + } + + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + maxDim := 1280 + newWidth := width + newHeight := height + + if width > maxDim || height > maxDim { + if width > height { + newWidth = maxDim + newHeight = (height * maxDim) / width + } else { + newHeight = maxDim + newWidth = (width * maxDim) / height + } + } + + var thumbnail image.Image + if newWidth != width || newHeight != height { + thumbnail = image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) + draw.CatmullRom.Scale(thumbnail.(draw.Image), thumbnail.Bounds(), img, bounds, draw.Over, nil) + } else { + thumbnail = img + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumbnail, &jpeg.Options{Quality: 60}); err != nil { + return nil, 0, 0, fmt.Errorf("failed to encode thumbnail: %w", err) + } + + return buf.Bytes(), newWidth, newHeight, nil +} + +func isAnimatedGif(data []byte) bool { + // GIF header: "GIF89a" or "GIF87a" + if len(data) < 6 { + return false + } + + if string(data[0:3]) != "GIF" { + return false + } + + // Count image descriptors (0x2C) which indicate frames + frameCount := 0 + for i := 0; i < len(data)-1; i++ { + if data[i] == 0x2C { // Image descriptor separator + frameCount++ + if frameCount > 1 { + return true + } + } + } + + return false +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + func (lc *LineClient) isRefreshRequired(err error) bool { return strings.Contains(err.Error(), "\"code\":119") || strings.Contains(err.Error(), "Access token refresh required") } @@ -525,7 +698,11 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { if bodyText == "" && len(msg.Chunks) > 0 { bodyText = "[Unable to decrypt message. Open an issue on GitHub.]" if lc.E2EE != nil { + // Ensure peer keys are available before attempting decryption + lc.ensurePeerKeyForMessage(context.Background(), msg) + if msg.ToType == 1 || msg.ToType == 2 { + // Group Decryption if len(msg.Chunks) >= 5 { if gkID, err := e2ee.DecodeKeyID(msg.Chunks[len(msg.Chunks)-1]); err == nil && gkID != 0 { if errFetch := lc.fetchAndUnwrapGroupKey(context.Background(), portalIDStr, gkID); errFetch != nil { @@ -533,31 +710,29 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } } } - pt, keyID, err := lc.E2EE.DecryptGroupMessage(msg, portalIDStr) - if err == nil && pt != "" { + if err == nil { bodyText = pt } else { lc.UserLogin.Bridge.Log.Debug().Err(err).Int("key_id", keyID).Str("chat_mid", portalIDStr).Msg("DecryptGroupMessage failed, trying to fetch key") if keyID != 0 { - lc.ensurePeerKeyForMessage(context.Background(), msg) if errFetch := lc.fetchAndUnwrapGroupKey(context.Background(), portalIDStr, keyID); errFetch != nil { lc.UserLogin.Bridge.Log.Warn().Err(errFetch).Int("key_id", keyID).Str("chat_mid", portalIDStr).Msg("Failed to fetch/unwrap group key") - } else if ptRetry, _, errRetry := lc.E2EE.DecryptGroupMessage(msg, portalIDStr); errRetry == nil && ptRetry != "" { + } else if ptRetry, _, errRetry := lc.E2EE.DecryptGroupMessage(msg, portalIDStr); errRetry == nil { bodyText = ptRetry - } else if errRetry != nil { - lc.UserLogin.Bridge.Log.Warn().Err(errRetry).Msg("DecryptGroupMessage retry failed") } } } } else { - // 1-1 Message - if pt, err := lc.E2EE.DecryptMessageV2(msg); err == nil && pt != "" { + // 1-1 Decryption + if pt, err := lc.E2EE.DecryptMessageV2(msg); err == nil { bodyText = pt } else { - lc.ensurePeerKeyForMessage(context.Background(), msg) - if ptRetry, errRetry := lc.E2EE.DecryptMessageV2(msg); errRetry == nil && ptRetry != "" { + lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("DecryptMessageV2 failed on first attempt") + if ptRetry, errRetry := lc.E2EE.DecryptMessageV2(msg); errRetry == nil { bodyText = ptRetry + } else { + lc.UserLogin.Bridge.Log.Warn().Err(errRetry).Msg("DecryptMessageV2 failed on retry") } } } @@ -565,14 +740,16 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } // unwrap JSON payloaad + unwrappedText := bodyText if strings.HasPrefix(bodyText, "{") { var wrapper map[string]any if err := json.Unmarshal([]byte(bodyText), &wrapper); err == nil { if t, ok := wrapper["text"].(string); ok { - bodyText = t + unwrappedText = t } } } + decryptedBody := bodyText lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Message[line.Message]{ EventMeta: simplevent.EventMeta{ @@ -586,13 +763,85 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { Data: *msg, ID: networkid.MessageID(msg.ID), ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message) (*bridgev2.ConvertedMessage, error) { + // Handle Images + if data.ContentType == 1 { // Image + oid := data.ContentMetadata["OID"] + + if oid != "" { + client := line.NewClient(lc.AccessToken) + imgData, err := client.DownloadOBS(oid, data.ID) + + // Refresh token if we get a 401 + if err != nil && (strings.Contains(err.Error(), "401") || lc.isRefreshRequired(err)) { + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + imgData, err = client.DownloadOBS(oid, data.ID) + } else { + lc.UserLogin.Bridge.Log.Warn().Err(errRefresh).Msg("Failed to refresh token for OBS download") + } + } + + if err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Str("oid", oid). + Str("msg_id", data.ID). + Msg("Failed to download image from OBS") + return nil, fmt.Errorf("failed to download image from OBS: %w", err) + } + + // Decrypt image if it has keyMaterial (E2EE) + if decryptedBody != "" && strings.Contains(decryptedBody, "keyMaterial") { + var decryptInfo struct { + KeyMaterial string `json:"keyMaterial"` + FileName string `json:"fileName"` + } + if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err == nil && decryptInfo.KeyMaterial != "" { + decryptedImg, err := lc.decryptImageData(imgData, decryptInfo.KeyMaterial) + if err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Msg("Failed to decrypt image data") + return nil, fmt.Errorf("failed to decrypt image data: %w", err) + } + imgData = decryptedImg + } + } + + // Upload to Matrix + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg") + if err != nil { + lc.UserLogin.Bridge.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) + } + + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{ + { + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: "image.jpg", + URL: mxc, + File: file, + }, + }, + }, + }, nil + } + } + + // Default to Text return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ { Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgText, - Body: bodyText, + Body: unwrappedText, }, }, }, @@ -629,7 +878,38 @@ func (lc *LineClient) IsLoggedIn() bool { return lc.AccessToken != "" } func (lc *LineClient) LogoutRemote(ctx context.Context) {} func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { - return &event.RoomFeatures{MaxTextLength: 5000} + return &event.RoomFeatures{ + MaxTextLength: 5000, + File: event.FileFeatureMap{ + event.MsgImage: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/jpeg": event.CapLevelFullySupported, + "image/png": event.CapLevelFullySupported, + "image/gif": event.CapLevelFullySupported, + "image/webp": event.CapLevelFullySupported, + }, + }, + event.MsgFile: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/gif": event.CapLevelFullySupported, + "*/*": event.CapLevelFullySupported, + }, + }, + event.MsgVideo: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "video/mp4": event.CapLevelFullySupported, + "video/webm": event.CapLevelFullySupported, + }, + }, + event.MsgAudio: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "audio/mpeg": event.CapLevelFullySupported, + "audio/ogg": event.CapLevelFullySupported, + "audio/mp4": event.CapLevelFullySupported, + }, + }, + }, + } } func makeUserID(userID string) networkid.UserID { return networkid.UserID(userID) } @@ -913,36 +1193,274 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, fmt.Errorf("E2EE not initialized; cannot send") } - if msg.Content.MsgType != event.MsgText { - return nil, fmt.Errorf("only text messages are implemented") - } - - portalMid := string(msg.Portal.ID) client := line.NewClient(lc.AccessToken) + portalMid := string(msg.Portal.ID) fromMid := lc.midOrFallback() var chunks []string var err error - lowerPortalID := strings.ToLower(portalMid) - // groups *should* start with 'c' and rooms with 'r' - if strings.HasPrefix(lowerPortalID, "c") || strings.HasPrefix(lowerPortalID, "r") { - if errFetch := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); errFetch != nil { - lc.UserLogin.Bridge.Log.Debug().Err(errFetch).Str("chat_mid", portalMid).Msg("fetchAndUnwrapGroupKey before encrypt failed") + contentType := 0 + contentMetadata := map[string]string{ + "e2eeVersion": "2", + } + + var payload []byte + + switch msg.Content.MsgType { + case event.MsgText: + contentType = 0 + // For groups, EncryptGroupMessage wraps in {"text": ...} + // For 1:1, we need JSON for EncryptMessageV2Raw + payload, err = json.Marshal(map[string]string{"text": msg.Content.Body}) + if err != nil { + return nil, fmt.Errorf("failed to marshal text payload: %w", err) } - chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) + + case event.MsgImage: + data, err := lc.UserLogin.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, msg.Content.File) if err != nil { - lc.UserLogin.Bridge.Log.Debug().Err(err).Str("chat_mid", portalMid).Msg("EncryptGroupMessage failed, fetching latest group key") - if errFetch := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); errFetch != nil { - err = fmt.Errorf("%w (failed to fetch latest group key: %v)", err, errFetch) + return nil, fmt.Errorf("failed to download media from matrix: %w", err) + } + + mimeType := msg.Content.Info.MimeType + isGif := mimeType == "image/gif" + isAnimated := isGif && isAnimatedGif(data) + + extension := "jpg" + if isGif { + extension = "gif" + } else if mimeType == "image/png" { + extension = "png" + } + + encryptedData, keyMaterialB64, err := lc.encryptImageData(data) + if err != nil { + return nil, fmt.Errorf("failed to encrypt image data: %w", err) + } + + oid, err := client.UploadOBS(encryptedData) + if err != nil { + return nil, fmt.Errorf("failed to upload encrypted image to OBS: %w", err) + } + + thumbnailData, thumbWidth, thumbHeight, err := generateThumbnail(data) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate thumbnail, continuing without it") + } else { + keyMaterial, _ := base64.StdEncoding.DecodeString(keyMaterialB64) + + // Derive keys using HKDF + kdf := hkdf.New(sha256.New, keyMaterial, nil, []byte("FileEncryption")) + derived := make([]byte, 76) + io.ReadFull(kdf, derived) + + encKey := derived[0:32] + macKey := derived[32:64] + nonce := derived[64:76] + + counter := make([]byte, 16) + copy(counter, nonce) + + block, _ := aes.NewCipher(encKey) + stream := cipher.NewCTR(block, counter) + + encryptedThumb := make([]byte, len(thumbnailData)) + stream.XORKeyStream(encryptedThumb, thumbnailData) + + h := hmac.New(sha256.New, macKey) + h.Write(encryptedThumb) + encryptedThumbWithHMAC := append(encryptedThumb, h.Sum(nil)...) + + // Upload preview + previewOID := fmt.Sprintf("%s__ud-preview", oid) + if err := client.UploadOBSWithOID(encryptedThumbWithHMAC, previewOID); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to upload 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) + } + + lc.UserLogin.Bridge.Log.Info(). + Str("preview_oid", previewOID). + Int("thumb_size", len(thumbnailData)). + Int("thumb_width", thumbWidth). + Int("thumb_height", thumbHeight). + Msg("Uploaded preview thumbnail") + } + } + + // metadata + contentType = 1 // Image + contentMetadata["OID"] = oid + contentMetadata["SID"] = "emi" // source ID for LINE images + contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(encryptedData)) + contentMetadata["contentType"] = "1" + + // Add encryption key material to metadata (ENC_KM) + contentMetadata["ENC_KM"] = keyMaterialB64 + + // Add file name + fileName := msg.Content.Body + if fileName == "" { + if isGif { + fileName = "animation.gif" + } else { + fileName = "image.jpg" + } + } + contentMetadata["FILE_NAME"] = fileName + + // Add MEDIA_CONTENT_INFO for proper display + mediaContentInfo := map[string]interface{}{ + "category": "original", + "fileSize": len(encryptedData), + "extension": extension, + } + + // Add animated flag for GIFs + if isAnimated { + mediaContentInfo["animated"] = true + } + + if mediaInfoJSON, err := json.Marshal(mediaContentInfo); err == nil { + contentMetadata["MEDIA_CONTENT_INFO"] = string(mediaInfoJSON) + } + + payload = []byte("{}") + + case event.MsgFile: + mimeType := msg.Content.Info.MimeType + if strings.HasPrefix(mimeType, "image/") { + data, err := lc.UserLogin.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, msg.Content.File) + if err != nil { + return nil, fmt.Errorf("failed to download media from matrix: %w", err) + } + + isGif := mimeType == "image/gif" + isAnimated := isGif && isAnimatedGif(data) + + extension := "jpg" + if isGif { + extension = "gif" + } else if mimeType == "image/png" { + extension = "png" + } + + encryptedData, keyMaterialB64, err := lc.encryptImageData(data) + if err != nil { + return nil, fmt.Errorf("failed to encrypt image data: %w", err) + } + + oid, err := client.UploadOBS(encryptedData) + if err != nil { + return nil, fmt.Errorf("failed to upload encrypted image to OBS: %w", err) + } + + thumbnailData, thumbWidth, thumbHeight, err := generateThumbnail(data) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate thumbnail, continuing without it") } else { - chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) - if err != nil { - lc.UserLogin.Bridge.Log.Debug().Err(err).Str("chat_mid", portalMid).Msg("EncryptGroupMessage still missing key after fetch") + keyMaterial, _ := base64.StdEncoding.DecodeString(keyMaterialB64) + + kdf := hkdf.New(sha256.New, keyMaterial, nil, []byte("FileEncryption")) + derived := make([]byte, 76) + io.ReadFull(kdf, derived) + + encKey := derived[0:32] + macKey := derived[32:64] + nonce := derived[64:76] + + counter := make([]byte, 16) + copy(counter, nonce) + + block, _ := aes.NewCipher(encKey) + stream := cipher.NewCTR(block, counter) + + encryptedThumb := make([]byte, len(thumbnailData)) + stream.XORKeyStream(encryptedThumb, thumbnailData) + + h := hmac.New(sha256.New, macKey) + h.Write(encryptedThumb) + encryptedThumbWithHMAC := append(encryptedThumb, h.Sum(nil)...) + + previewOID := fmt.Sprintf("%s__ud-preview", oid) + if err := client.UploadOBSWithOID(encryptedThumbWithHMAC, previewOID); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to upload 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) + } + } + } + + contentType = 1 + contentMetadata["OID"] = oid + contentMetadata["SID"] = "emi" + contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(encryptedData)) + contentMetadata["contentType"] = "1" + contentMetadata["ENC_KM"] = keyMaterialB64 + + fileName := msg.Content.Body + if fileName == "" { + if isGif { + fileName = "animation.gif" + } else { + fileName = "image.jpg" } } + contentMetadata["FILE_NAME"] = fileName + + mediaContentInfo := map[string]interface{}{ + "category": "original", + "fileSize": len(encryptedData), + "extension": extension, + } + + if isAnimated { + mediaContentInfo["animated"] = true + } + + if mediaInfoJSON, err := json.Marshal(mediaContentInfo); err == nil { + contentMetadata["MEDIA_CONTENT_INFO"] = string(mediaInfoJSON) + } + + payload = []byte("{}") + } else { + return nil, fmt.Errorf("file type %s not implemented yet", mimeType) + } + + default: + return nil, fmt.Errorf("message type %s not implemented", msg.Content.MsgType) + } + + lowerPortalID := strings.ToLower(portalMid) + isGroup := strings.HasPrefix(lowerPortalID, "c") || strings.HasPrefix(lowerPortalID, "r") + + if isGroup { + if errFetch := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); errFetch != nil { + lc.UserLogin.Bridge.Log.Debug().Err(errFetch).Str("chat_mid", portalMid).Msg("fetchAndUnwrapGroupKey before encrypt failed") + } + if contentType != 0 { + chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, payload) + } else { + chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) } if err != nil { - return nil, fmt.Errorf("group encrypt failed: %w", err) + if errFetch := lc.fetchAndUnwrapGroupKey(ctx, portalMid, 0); errFetch == nil { + if contentType != 0 { + chunks, err = lc.E2EE.EncryptGroupMessageRaw(portalMid, fromMid, contentType, payload) + } else { + chunks, err = lc.E2EE.EncryptGroupMessage(portalMid, fromMid, msg.Content.Body) + } + } } } else { // 1-1 Encryption @@ -950,32 +1468,30 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat if err != nil { return nil, fmt.Errorf("missing own E2EE key: %w", err) } - peerRaw, peerPub, err := lc.ensurePeerKey(ctx, portalMid) if err != nil { return nil, fmt.Errorf("failed to get peer key: %w", err) } - chunks, err = lc.E2EE.EncryptMessageV2(portalMid, fromMid, myKeyID, peerPub, myRaw, peerRaw, 0, msg.Content.Body) - if err != nil { - return nil, fmt.Errorf("encrypt failed: %w", err) - } + chunks, err = lc.E2EE.EncryptMessageV2Raw(portalMid, fromMid, myKeyID, peerPub, myRaw, peerRaw, contentType, payload) + } + + if err != nil { + return nil, fmt.Errorf("encrypt failed: %w", err) } now := time.Now().UnixMilli() lineMsg := &line.Message{ - ID: fmt.Sprintf("local-%d", now), - From: lc.midOrFallback(), - To: portalMid, - ToType: guessToType(portalMid), - SessionID: 0, - CreatedTime: json.Number(strconv.FormatInt(now, 10)), - ContentType: 0, - HasContent: false, - ContentMetadata: map[string]string{ - "e2eeVersion": "2", - }, - Chunks: chunks, + ID: fmt.Sprintf("local-%d", now), + From: lc.midOrFallback(), + To: portalMid, + ToType: guessToType(portalMid), + SessionID: 0, + CreatedTime: json.Number(strconv.FormatInt(now, 10)), + ContentType: contentType, + HasContent: contentType != 0, // True for images + ContentMetadata: contentMetadata, + Chunks: chunks, } reqSeq := int(now % 1_000_000_000) diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index 045044e..0d53191 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -198,18 +198,23 @@ func (m *Manager) InitFromLoginKeyChain(serverPubB64, encryptedKeyChainB64 strin } func (m *Manager) EncryptMessageV2(chatID, from string, myKeyID int, peerPubKeyB64 string, senderKeyID, receiverKeyID, contentType int, plaintext string) ([]string, error) { - chanID, seq, err := m.ensureChannelForEncrypt(chatID, myKeyID, senderKeyID, receiverKeyID, peerPubKeyB64) + // Standard text message payload + payload, err := json.Marshal(map[string]string{"text": plaintext}) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to marshal payload: %w", err) } + return m.EncryptMessageV2Raw(chatID, from, myKeyID, peerPubKeyB64, senderKeyID, receiverKeyID, contentType, payload) +} - //plaintext is a JSON object wrapping the text. - payload, err := json.Marshal(map[string]string{"text": plaintext}) +// EncryptMessageV2Raw encrypts a raw JSON payload (bytes) instead of forcing a text object. +// This is required for media messages +func (m *Manager) EncryptMessageV2Raw(chatID, from string, myKeyID int, peerPubKeyB64 string, senderKeyID, receiverKeyID, contentType int, payloadJSON []byte) ([]string, error) { + chanID, seq, err := m.ensureChannelForEncrypt(chatID, myKeyID, senderKeyID, receiverKeyID, peerPubKeyB64) if err != nil { - return nil, fmt.Errorf("failed to marshal payload: %w", err) + return nil, err } - ctB64, err := m.runner.ChannelEncryptV2(chanID, chatID, from, senderKeyID, receiverKeyID, contentType, seq, string(payload)) + ctB64, err := m.runner.ChannelEncryptV2(chanID, chatID, from, senderKeyID, receiverKeyID, contentType, seq, string(payloadJSON)) if err != nil { return nil, err } @@ -450,6 +455,15 @@ func (m *Manager) ensureGroupChannel(chatMid string, groupKeyID, unwrappedKeyID, } func (m *Manager) EncryptGroupMessage(chatMid, fromMid string, plaintext string) ([]string, error) { + payload, err := json.Marshal(map[string]string{"text": plaintext}) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + return m.EncryptGroupMessageRaw(chatMid, fromMid, 0, payload) +} + +// grpup messages are encrypted differently compared to 1:1 messages +func (m *Manager) EncryptGroupMessageRaw(chatMid, fromMid string, contentType int, payload []byte) ([]string, error) { m.mu.Lock() groupKeyID, ok := m.latestGroupKey[chatMid] var unwrappedKeyID int @@ -470,12 +484,7 @@ func (m *Manager) EncryptGroupMessage(chatMid, fromMid string, plaintext string) return nil, err } - payload, err := json.Marshal(map[string]string{"text": plaintext}) - if err != nil { - return nil, fmt.Errorf("failed to marshal payload: %w", err) - } - - ctB64, err := m.runner.ChannelEncryptV2(chanID, chatMid, fromMid, myKeyID, groupKeyID, 0, seq, string(payload)) + ctB64, err := m.runner.ChannelEncryptV2(chanID, chatMid, fromMid, myKeyID, groupKeyID, contentType, seq, string(payload)) if err != nil { return nil, err } diff --git a/pkg/line/client.go b/pkg/line/client.go index 8bac29f..5a3e0c2 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -2,6 +2,9 @@ package line import ( "bytes" + "crypto/rand" + "encoding/base64" + "encoding/binary" "encoding/json" "fmt" "io" @@ -335,3 +338,188 @@ func (c *Client) RefreshAccessToken(refreshToken string) (*TokenV3IssueResult, e return &res, nil } + +const OBSBaseURL = "https://obs.line-apps.com" + +// UploadOBS uploads media to LINE's Object Storage and returns the Object ID (OID). +// As per logs, we post to /r/talk/emi/reqid- +func (c *Client) UploadOBS(data []byte) (string, error) { + // First, acquire the OBS-specific encrypted access token + obsToken, err := c.AcquireEncryptedAccessToken() + if err != nil { + return "", fmt.Errorf("failed to acquire OBS token: %w", err) + } + + // Generate a random Request ID (UUID-like) + reqIDBytes := make([]byte, 16) + if _, err := rand.Read(reqIDBytes); err != nil { + return "", fmt.Errorf("failed to generate reqID: %w", err) + } + reqID := fmt.Sprintf("%x-%x-%x-%x-%x", reqIDBytes[0:4], reqIDBytes[4:6], reqIDBytes[6:8], reqIDBytes[8:10], reqIDBytes[10:]) + + url := fmt.Sprintf("%s/r/talk/emi/reqid-%s", OBSBaseURL, reqID) + + req, err := http.NewRequest("POST", url, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("failed to create OBS request: %w", err) + } + + // Construct X-Obs-Params header (base64 encoded JSON) + obsParams := map[string]string{ + "ver": "2.0", + "name": fmt.Sprintf("%d", time.Now().UnixMilli()), + "type": "file", + } + obsParamsJSON, _ := json.Marshal(obsParams) + obsParamsB64 := base64.StdEncoding.EncodeToString(obsParamsJSON) + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("x-line-application", "CHROMEOS\t3.7.1\tChrome_OS\t1") + req.Header.Set("x-lal", "en_US") + // OBS expects application/octet-stream for binary uploads + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("X-Obs-Params", obsParamsB64) + + // Use the OBS-specific access token + req.Header.Set("x-line-access", obsToken) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("OBS upload request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return "", fmt.Errorf("OBS upload failed (%d): %s", resp.StatusCode, string(body)) + } + + // The OID is typically returned in the x-obs-oid header + oid := resp.Header.Get("x-obs-oid") + if oid == "" { + // Fallback: checks if reqID is used as OID or if body contains it (rare for this endpoint) + return "", fmt.Errorf("OBS upload succeeded but no x-obs-oid header returned") + } + + return oid, nil +} + +// UploadOBSWithOID uploads data to a specific OID (used for preview/thumbnail uploads) +func (c *Client) UploadOBSWithOID(data []byte, oid string) error { + // First, acquire the OBS-specific encrypted access token + obsToken, err := c.AcquireEncryptedAccessToken() + if err != nil { + return fmt.Errorf("failed to acquire OBS token: %w", err) + } + + url := fmt.Sprintf("%s/r/talk/emi/%s", OBSBaseURL, oid) + + req, err := http.NewRequest("POST", url, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create OBS request: %w", err) + } + + // Construct X-Obs-Params header (base64 encoded JSON) + obsParams := map[string]string{ + "ver": "2.0", + "name": fmt.Sprintf("%d", time.Now().UnixMilli()), + "type": "file", + } + obsParamsJSON, _ := json.Marshal(obsParams) + obsParamsB64 := base64.StdEncoding.EncodeToString(obsParamsJSON) + + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("x-line-application", "CHROMEOS\t3.7.1\tChrome_OS\t1") + req.Header.Set("x-lal", "en_US") + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("X-Obs-Params", obsParamsB64) + req.Header.Set("x-line-access", obsToken) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("OBS upload request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return fmt.Errorf("OBS upload failed (%d): %s", resp.StatusCode, string(body)) + } + + return nil +} + +// DownloadOBS retrieves media from LINE's Object Storage using the OID. +func (c *Client) DownloadOBS(oid string, messageID string) ([]byte, error) { + // URL structure from logs: https://obs.line-apps.com/r/talk/emi/{OID} + url := fmt.Sprintf("%s/r/talk/emi/%s", OBSBaseURL, oid) + + obsToken, err := c.AcquireEncryptedAccessToken() + if err != nil { + return nil, fmt.Errorf("failed to acquire encrypted access token: %w", err) + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create OBS download request: %w", err) + } + + req.Header.Set("User-Agent", UserAgent) + if obsToken != "" { + req.Header.Set("x-line-access", obsToken) + } + + // Add x-talk-meta header with Thrift-encoded message + if messageID != "" { + talkMeta := c.constructTalkMeta(messageID) + req.Header.Set("x-talk-meta", talkMeta) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("OBS download request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("OBS download failed (%d): %s", resp.StatusCode, string(body)) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read OBS response body: %w", err) + } + + return data, nil +} + +// this builds x-talk-meta +// base64(json({"message": base64(thrift_message)})) +func (c *Client) constructTalkMeta(messageID string) string { + // [field_type][field_id_16bit][string_length_32bit][string_bytes]...[stop_byte] + var buf bytes.Buffer + + // Field 4 (id) - STRING type (0x0B) + buf.WriteByte(0x0B) // TType.STRING + binary.Write(&buf, binary.BigEndian, uint16(4)) // field ID = 4 + binary.Write(&buf, binary.BigEndian, uint32(len(messageID))) // string length + buf.WriteString(messageID) // message ID + + // required to send a valid struct + buf.WriteByte(0x0F) // TType.LIST + binary.Write(&buf, binary.BigEndian, uint16(27)) // field ID = 27 + buf.WriteByte(0x0C) // element type = STRUCT + binary.Write(&buf, binary.BigEndian, uint32(0)) // list size = 0 + + buf.WriteByte(0x00) + + thriftB64 := base64.StdEncoding.EncodeToString(buf.Bytes()) + + metaJSON := map[string]string{"message": thriftB64} + metaBytes, _ := json.Marshal(metaJSON) + + return base64.StdEncoding.EncodeToString(metaBytes) +} diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 0618f02..e59366b 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" ) // LoginV2 performs the loginV2 RPC call to authenticate a user @@ -398,3 +399,33 @@ func (c *Client) GetChats(mids []string, withMembers, withInvitees bool) (*GetCh } return &wrapper.Data, nil } + +// this token is used to encrypt images, videos, and files uploaded to LINE's OBS storage +func (c *Client) AcquireEncryptedAccessToken() (string, error) { + // 2 = FeatureType::OBS_Authorization. + resp, err := c.callRPC("TalkService", "acquireEncryptedAccessToken", 2) + if err != nil { + return "", err + } + + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data"` // Format: "expirySeconds\x1eToken" + } + + if err := json.Unmarshal(resp, &wrapper); err != nil { + return "", fmt.Errorf("failed to decode acquireEncryptedAccessToken response: %w", err) + } + + if wrapper.Code != 0 { + return "", fmt.Errorf("acquireEncryptedAccessToken API error: %s", wrapper.Message) + } + + parts := strings.Split(wrapper.Data, "\x1e") + if len(parts) < 2 { + return "", fmt.Errorf("invalid encrypted token format: missing separator") + } + + return parts[1], nil +} diff --git a/pkg/line/structs.go b/pkg/line/structs.go index 552f6fe..6d91423 100644 --- a/pkg/line/structs.go +++ b/pkg/line/structs.go @@ -2,6 +2,27 @@ package line import "encoding/json" +type FlexibleMidMap map[string]bool + +func (f *FlexibleMidMap) UnmarshalJSON(data []byte) error { + var m map[string]bool + if err := json.Unmarshal(data, &m); err == nil { + *f = m + return nil + } + + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *f = make(map[string]bool, len(arr)) + for _, mid := range arr { + (*f)[mid] = true + } + return nil + } + + return nil +} + type LoginRequest struct { Type int `json:"type"` // 0=Email/Password, 1=QRCode, 2=Secret IdentityProvider int `json:"identityProvider"` @@ -191,14 +212,14 @@ type GetChatsResponse struct { } type Chat struct { - ChatMid string `json:"chatMid"` - CreatedTime int64 `json:"createdTime"` - NotificationDisabled bool `json:"notificationDisabled"` - FavoriteTimestamp int64 `json:"favoriteTimestamp"` - ChatName string `json:"chatName"` - PicturePath string `json:"picturePath"` - Extra ChatExtra `json:"extra"` - Type int `json:"type"` // 0=GROUP, 1=ROOM + ChatMid string `json:"chatMid"` + CreatedTime json.Number `json:"createdTime"` + NotificationDisabled bool `json:"notificationDisabled"` + FavoriteTimestamp json.Number `json:"favoriteTimestamp"` + ChatName string `json:"chatName"` + PicturePath string `json:"picturePath"` + Extra ChatExtra `json:"extra"` + Type int `json:"type"` // 0=GROUP, 1=ROOM } type ChatExtra struct { @@ -207,12 +228,18 @@ type ChatExtra struct { } type GroupExtra struct { - CreatorMid string `json:"creatorMid"` - PreventedMids map[string]bool `json:"preventedMids"` - InvitationTicket string `json:"invitationTicket"` - MemberMids map[string]bool `json:"memberMids"` - InviteeMids map[string]bool `json:"inviteeMids"` + CreatorMid string `json:"creatorMid"` + PreventedMids FlexibleMidMap `json:"preventedMids"` + InvitationTicket string `json:"invitationTicket"` + MemberMids FlexibleMidMap `json:"memberMids"` + InviteeMids FlexibleMidMap `json:"inviteeMids"` } type PeerExtra struct { } + +type AcquireEncryptedAccessTokenResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data"` // Format: "expirySeconds\x1eToken" +} From 97c3dfb2b1b1bb4f43d55d8b5c69b22cb697a14f Mon Sep 17 00:00:00 2001 From: highesttt Date: Fri, 26 Dec 2025 11:58:32 -0600 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Added=20video=20comp?= =?UTF-8?q?atibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 4 + go.sum | 51 ++++++ pkg/connector/client.go | 364 ++++++++++++++++++++++++++++++++++++++++ pkg/line/client.go | 25 ++- 4 files changed, 439 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 6eb0991..81da286 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,10 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/aws/aws-sdk-go v1.38.20 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -30,6 +32,8 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/u2takey/ffmpeg-go v0.5.0 // indirect + github.com/u2takey/go-utils v0.3.1 // indirect github.com/yuin/goldmark v1.7.13 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect diff --git a/go.sum b/go.sum index 720b756..cdd8a00 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,29 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= +github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -23,10 +39,16 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 h1:QTvNkZ5ylY0PGgA+Lih+GdboMLY/G9SEGLMEGVjTVA4= github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -36,6 +58,11 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -48,37 +75,61 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= +github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= +github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= +github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU= go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= +gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mautrix v0.26.0 h1:valc2VmZF+oIY4bMq4Cd5H9cEKMRe8eP4FM7iiaYLxI= maunium.net/go/mautrix v0.26.0/go.mod h1:NWMv+243NX/gDrLofJ2nNXJPrG8vzoM+WUCWph85S6Q= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/pkg/connector/client.go b/pkg/connector/client.go index e7bc86a..d4dbb7d 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -17,11 +17,13 @@ import ( _ "image/png" "io" "net/http" + "os" "strconv" "strings" "sync" "time" + ffmpeg "github.com/u2takey/ffmpeg-go" "go.mau.fi/util/ptr" "golang.org/x/crypto/hkdf" "golang.org/x/image/draw" @@ -175,6 +177,48 @@ func (lc *LineClient) encryptImageData(plainData []byte) ([]byte, string, error) return result, keyMaterialB64, nil } +// encryptVideoData encrypts video data with HMAC computed on chunk hashes +func (lc *LineClient) encryptVideoData(plainData []byte) ([]byte, string, error) { + keyMaterial := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, keyMaterial); err != nil { + return nil, "", fmt.Errorf("failed to generate key material: %w", err) + } + + kdf := hkdf.New(sha256.New, keyMaterial, nil, []byte("FileEncryption")) + derived := make([]byte, 76) + if _, err := io.ReadFull(kdf, derived); err != nil { + return nil, "", fmt.Errorf("failed to derive keys: %w", err) + } + + encKey := derived[0:32] + macKey := derived[32:64] + nonce := derived[64:76] + + counter := make([]byte, 16) + copy(counter, nonce) + + block, err := aes.NewCipher(encKey) + if err != nil { + return nil, "", fmt.Errorf("failed to create AES cipher: %w", err) + } + + stream := cipher.NewCTR(block, counter) + + encrypted := make([]byte, len(plainData)) + stream.XORKeyStream(encrypted, plainData) + + // For videos: compute HMAC on chunk hashes + chunkHashes := generateChunkHashes(encrypted) + h := hmac.New(sha256.New, macKey) + h.Write(chunkHashes) + hmacSum := h.Sum(nil) + + result := append(encrypted, hmacSum...) + keyMaterialB64 := base64.StdEncoding.EncodeToString(keyMaterial) + + return result, keyMaterialB64, nil +} + func generateThumbnail(imageData []byte) ([]byte, int, int, error) { img, _, err := image.Decode(bytes.NewReader(imageData)) if err != nil { @@ -239,6 +283,75 @@ func isAnimatedGif(data []byte) bool { return false } +// 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") + if err != nil { + return nil, 0, 0, fmt.Errorf("failed to create temp video file: %w", err) + } + defer os.Remove(tmpVideoFile.Name()) + defer tmpVideoFile.Close() + + if _, err := tmpVideoFile.Write(videoData); err != nil { + return nil, 0, 0, fmt.Errorf("failed to write video data: %w", err) + } + tmpVideoFile.Close() + + tmpThumbFile, err := os.CreateTemp("", "thumb-*.jpg") + if err != nil { + return nil, 0, 0, fmt.Errorf("failed to create temp thumb file: %w", err) + } + defer os.Remove(tmpThumbFile.Name()) + tmpThumbFile.Close() + + err = ffmpeg.Input(tmpVideoFile.Name()). + Filter("scale", ffmpeg.Args{fmt.Sprintf("384:384:force_original_aspect_ratio=decrease")}). + Output(tmpThumbFile.Name(), ffmpeg.KwArgs{ + "vframes": 1, + "q:v": 5, + }). + OverWriteOutput(). + Silent(true). + Run() + + if err != nil { + return nil, 0, 0, fmt.Errorf("ffmpeg failed: %w", err) + } + + thumbData, err := os.ReadFile(tmpThumbFile.Name()) + if err != nil { + return nil, 0, 0, fmt.Errorf("failed to read thumbnail: %w", err) + } + + img, _, err := image.Decode(bytes.NewReader(thumbData)) + if err != nil { + return nil, 0, 0, fmt.Errorf("failed to decode thumbnail: %w", err) + } + + bounds := img.Bounds() + return thumbData, bounds.Dx(), bounds.Dy(), nil +} + +// generateChunkHashes generates SHA-256 hashes for 128KB chunks of encrypted video data +// This is required by LINE for video integrity verification +func generateChunkHashes(encryptedData []byte) []byte { + const chunkSize = 131072 // 128KB chunks + var allHashes []byte + + for i := 0; i < len(encryptedData); i += chunkSize { + end := i + chunkSize + if end > len(encryptedData) { + end = len(encryptedData) + } + + chunk := encryptedData[i:end] + hash := sha256.Sum256(chunk) + allHashes = append(allHashes, hash[:]...) + } + + return allHashes +} + func min(a, b int) int { if a < b { return a @@ -834,6 +947,152 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } } + if data.ContentType == 2 { // Video + oid := data.ContentMetadata["OID"] + + if oid == "" && decryptedBody != "" && strings.Contains(decryptedBody, "OID") { + var decryptInfo struct { + OID string `json:"OID"` + KeyMaterial string `json:"keyMaterial"` + FileName string `json:"fileName"` + } + if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err == nil && decryptInfo.OID != "" { + oid = decryptInfo.OID + } + } + + if oid != "" { + client := line.NewClient(lc.AccessToken) + videoData, err := client.DownloadOBSWithSID(oid, data.ID, "emv") + + if err != nil && (strings.Contains(err.Error(), "401") || lc.isRefreshRequired(err)) { + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + videoData, err = client.DownloadOBSWithSID(oid, data.ID, "emv") + } else { + lc.UserLogin.Bridge.Log.Warn().Err(errRefresh).Msg("Failed to refresh token for OBS download") + } + } + + if err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Str("oid", oid). + Str("msg_id", data.ID). + Msg("Failed to download video from OBS") + return nil, fmt.Errorf("failed to download video from OBS: %w", err) + } + + if decryptedBody != "" && strings.Contains(decryptedBody, "keyMaterial") { + var decryptInfo struct { + KeyMaterial string `json:"keyMaterial"` + FileName string `json:"fileName"` + } + if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err == nil && decryptInfo.KeyMaterial != "" { + lc.UserLogin.Bridge.Log.Debug(). + Str("key_material_len", fmt.Sprintf("%d", len(decryptInfo.KeyMaterial))). + Str("file_name", decryptInfo.FileName). + Msg("Decrypting E2EE video") + + decryptedVideo, err := lc.decryptImageData(videoData, decryptInfo.KeyMaterial) + if err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Msg("Failed to decrypt video data") + return nil, fmt.Errorf("failed to decrypt video data: %w", err) + } + videoData = decryptedVideo + lc.UserLogin.Bridge.Log.Info(). + Int("decrypted_size", len(videoData)). + Msg("Successfully decrypted video") + } + } + + if encKM := data.ContentMetadata["ENC_KM"]; encKM != "" && len(videoData) > 32 { + lc.UserLogin.Bridge.Log.Debug(). + Str("enc_km_preview", encKM[:min(20, len(encKM))]+"..."). + Msg("Decrypting video using ENC_KM from metadata") + + decryptedVideo, err := lc.decryptImageData(videoData, encKM) + if err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Msg("Failed to decrypt video data from ENC_KM") + return nil, fmt.Errorf("failed to decrypt video data: %w", err) + } + videoData = decryptedVideo + lc.UserLogin.Bridge.Log.Info(). + Int("decrypted_size", len(videoData)). + Msg("Successfully decrypted video from ENC_KM") + } + + fileName := data.ContentMetadata["FILE_NAME"] + + if fileName == "" && decryptedBody != "" && strings.Contains(decryptedBody, "fileName") { + var decryptInfo struct { + FileName string `json:"fileName"` + } + if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err == nil && decryptInfo.FileName != "" { + fileName = decryptInfo.FileName + } + } + + if fileName == "" { + fileName = "video.mp4" + } + + mimeType := "video/mp4" + if strings.HasSuffix(strings.ToLower(fileName), ".webm") { + mimeType = "video/webm" + } + + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, videoData, fileName, mimeType) + if err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Int("size_bytes", len(videoData)). + Msg("Failed to upload video to Matrix") + return nil, fmt.Errorf("failed to upload video to matrix: %w", err) + } + + lc.UserLogin.Bridge.Log.Info(). + Str("mxc", mxc.ParseOrIgnore().String()). + Str("file_name", fileName). + Int("size", len(videoData)). + Msg("Successfully uploaded video to Matrix") + + var duration int + if durationStr := data.ContentMetadata["DURATION"]; durationStr != "" { + if d, err := strconv.Atoi(durationStr); err == nil { + duration = d + } + } + + videoInfo := &event.FileInfo{ + MimeType: mimeType, + Size: len(videoData), + } + if duration > 0 { + videoInfo.Duration = duration + } + + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{ + { + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgVideo, + Body: fileName, + URL: mxc, + File: file, + Info: videoInfo, + }, + }, + }, + }, nil + } + } + // Default to Text return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ @@ -1437,6 +1696,111 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, fmt.Errorf("file type %s not implemented yet", mimeType) } + case event.MsgVideo: + data, err := lc.UserLogin.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, msg.Content.File) + if err != nil { + return nil, fmt.Errorf("failed to download video from matrix: %w", err) + } + + encryptedData, keyMaterialB64, err := lc.encryptVideoData(data) + if err != nil { + return nil, fmt.Errorf("failed to encrypt video data: %w", err) + } + + oid, err := client.UploadOBSWithSID(encryptedData, "emv") + if err != nil { + return nil, fmt.Errorf("failed to upload encrypted video to OBS: %w", err) + } + + chunkHashes := generateChunkHashes(encryptedData[:len(encryptedData)-32]) // Exclude HMAC + if len(chunkHashes) > 0 { + hashOID := fmt.Sprintf("%s__ud-hash", oid) + if err := client.UploadOBSWithOIDAndSID(chunkHashes, hashOID, "emv"); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to upload video hash, continuing without it") + } else { + lc.UserLogin.Bridge.Log.Info(). + Str("hash_oid", hashOID). + Int("hash_size", len(chunkHashes)). + Msg("Uploaded video chunk hashes") + } + } + + thumbnailData, thumbWidth, thumbHeight, err := extractVideoThumbnail(data) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to extract video thumbnail, using placeholder") + thumbWidth = 384 + thumbHeight = 384 + placeholderImg := image.NewRGBA(image.Rect(0, 0, thumbWidth, thumbHeight)) + var thumbBuf bytes.Buffer + jpeg.Encode(&thumbBuf, placeholderImg, &jpeg.Options{Quality: 30}) + thumbnailData = thumbBuf.Bytes() + } + + if len(thumbnailData) > 0 { + keyMaterial, _ := base64.StdEncoding.DecodeString(keyMaterialB64) + kdf := hkdf.New(sha256.New, keyMaterial, nil, []byte("FileEncryption")) + derived := make([]byte, 76) + io.ReadFull(kdf, derived) + + encKey := derived[0:32] + macKey := derived[32:64] + nonce := derived[64:76] + + counter := make([]byte, 16) + copy(counter, nonce) + + block, _ := aes.NewCipher(encKey) + stream := cipher.NewCTR(block, counter) + + encryptedThumb := make([]byte, len(thumbnailData)) + stream.XORKeyStream(encryptedThumb, thumbnailData) + + h := hmac.New(sha256.New, macKey) + h.Write(encryptedThumb) + encryptedThumbWithHMAC := append(encryptedThumb, h.Sum(nil)...) + + previewOID := fmt.Sprintf("%s__ud-preview", oid) + if err := client.UploadOBSWithOIDAndSID(encryptedThumbWithHMAC, previewOID, "emv"); err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to upload video 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) + } + + lc.UserLogin.Bridge.Log.Info(). + Str("preview_oid", previewOID). + Int("preview_size", len(encryptedThumbWithHMAC)). + Int("thumb_width", thumbWidth). + Int("thumb_height", thumbHeight). + Msg("Uploaded video preview placeholder") + } + } + + contentType = 2 // Video + contentMetadata["OID"] = oid + contentMetadata["SID"] = "emv" // Video SID + contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(data)) // Original unencrypted size + contentMetadata["contentType"] = "2" + contentMetadata["ENC_KM"] = keyMaterialB64 // Required for OBS access + + // Add duration if available (in milliseconds) + if msg.Content.Info.Duration > 0 { + contentMetadata["DURATION"] = fmt.Sprintf("%d", msg.Content.Info.Duration) + } + + //Empty payload like images + payload = []byte("{}") + + lc.UserLogin.Bridge.Log.Info(). + Str("oid", oid). + Str("enc_km", keyMaterialB64[:20]+"..."). + Int("encrypted_size", len(encryptedData)). + Msg("Prepared E2EE video message") + default: return nil, fmt.Errorf("message type %s not implemented", msg.Content.MsgType) } diff --git a/pkg/line/client.go b/pkg/line/client.go index 5a3e0c2..b0227cf 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -342,8 +342,13 @@ func (c *Client) RefreshAccessToken(refreshToken string) (*TokenV3IssueResult, e const OBSBaseURL = "https://obs.line-apps.com" // UploadOBS uploads media to LINE's Object Storage and returns the Object ID (OID). -// As per logs, we post to /r/talk/emi/reqid- +// Default uses "emi" SID for images func (c *Client) UploadOBS(data []byte) (string, error) { + return c.UploadOBSWithSID(data, "emi") +} + +// SID: emi (images), emv (videos), ema (audio), emf (files) +func (c *Client) UploadOBSWithSID(data []byte, sid string) (string, error) { // First, acquire the OBS-specific encrypted access token obsToken, err := c.AcquireEncryptedAccessToken() if err != nil { @@ -357,7 +362,7 @@ func (c *Client) UploadOBS(data []byte) (string, error) { } reqID := fmt.Sprintf("%x-%x-%x-%x-%x", reqIDBytes[0:4], reqIDBytes[4:6], reqIDBytes[6:8], reqIDBytes[8:10], reqIDBytes[10:]) - url := fmt.Sprintf("%s/r/talk/emi/reqid-%s", OBSBaseURL, reqID) + url := fmt.Sprintf("%s/r/talk/%s/reqid-%s", OBSBaseURL, sid, reqID) req, err := http.NewRequest("POST", url, bytes.NewReader(data)) if err != nil { @@ -407,13 +412,18 @@ func (c *Client) UploadOBS(data []byte) (string, error) { // UploadOBSWithOID uploads data to a specific OID (used for preview/thumbnail uploads) func (c *Client) UploadOBSWithOID(data []byte, oid string) error { + return c.UploadOBSWithOIDAndSID(data, oid, "emi") +} + +// UploadOBSWithOIDAndSID uploads data to a specific OID with a specific SID +func (c *Client) UploadOBSWithOIDAndSID(data []byte, oid string, sid string) error { // First, acquire the OBS-specific encrypted access token obsToken, err := c.AcquireEncryptedAccessToken() if err != nil { return fmt.Errorf("failed to acquire OBS token: %w", err) } - url := fmt.Sprintf("%s/r/talk/emi/%s", OBSBaseURL, oid) + url := fmt.Sprintf("%s/r/talk/%s/%s", OBSBaseURL, sid, oid) req, err := http.NewRequest("POST", url, bytes.NewReader(data)) if err != nil { @@ -453,8 +463,13 @@ func (c *Client) UploadOBSWithOID(data []byte, oid string) error { // DownloadOBS retrieves media from LINE's Object Storage using the OID. func (c *Client) DownloadOBS(oid string, messageID string) ([]byte, error) { - // URL structure from logs: https://obs.line-apps.com/r/talk/emi/{OID} - url := fmt.Sprintf("%s/r/talk/emi/%s", OBSBaseURL, oid) + return c.DownloadOBSWithSID(oid, messageID, "emi") +} + +func (c *Client) DownloadOBSWithSID(oid string, messageID string, sid 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) obsToken, err := c.AcquireEncryptedAccessToken() if err != nil { From 95720f73747785511158aa59a8181a88e0d14b55 Mon Sep 17 00:00:00 2001 From: highesttt Date: Fri, 26 Dec 2025 12:06:16 -0600 Subject: [PATCH 3/5] =?UTF-8?q?ci:=20=F0=9F=8E=A1=20removed=20unused=20err?= =?UTF-8?q?=20and=20useless=20Sprintf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/connector/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index d4dbb7d..6097c2b 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -305,7 +305,7 @@ func extractVideoThumbnail(videoData []byte) ([]byte, int, int, error) { tmpThumbFile.Close() err = ffmpeg.Input(tmpVideoFile.Name()). - Filter("scale", ffmpeg.Args{fmt.Sprintf("384:384:force_original_aspect_ratio=decrease")}). + Filter("scale", ffmpeg.Args{"384:384:force_original_aspect_ratio=decrease"}). Output(tmpThumbFile.Name(), ffmpeg.KwArgs{ "vframes": 1, "q:v": 5, @@ -1837,7 +1837,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, fmt.Errorf("failed to get peer key: %w", err) } - chunks, err = lc.E2EE.EncryptMessageV2Raw(portalMid, fromMid, myKeyID, peerPub, myRaw, peerRaw, contentType, payload) + chunks, _ = lc.E2EE.EncryptMessageV2Raw(portalMid, fromMid, myKeyID, peerPub, myRaw, peerRaw, contentType, payload) } if err != nil { From 0aa32a03a318daec413958ae0c02fe42166c8256 Mon Sep 17 00:00:00 2001 From: highesttt Date: Fri, 26 Dec 2025 12:18:42 -0600 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Added=20all=20other?= =?UTF-8?q?=20filetypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/connector/client.go | 248 ++++++++++++++++++++++++---------------- 1 file changed, 149 insertions(+), 99 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 6097c2b..ca74445 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -133,7 +133,7 @@ func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 stri } // AES-256-CTR -func (lc *LineClient) encryptImageData(plainData []byte) ([]byte, string, error) { +func (lc *LineClient) encryptFileData(plainData []byte) ([]byte, string, error) { keyMaterial := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, keyMaterial); err != nil { return nil, "", fmt.Errorf("failed to generate key material: %w", err) @@ -1093,6 +1093,114 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } } + // Handle File type (14) + if data.ContentType == 14 { + oid := data.ContentMetadata["OID"] + if oid == "" && decryptedBody != "" && strings.Contains(decryptedBody, "fileName") { + lc.UserLogin.Bridge.Log.Debug().Msg("File message with encrypted payload, OID in metadata") + } + + if oid != "" { + client := line.NewClient(lc.AccessToken) + fileData, err := client.DownloadOBSWithSID(oid, data.ID, "emf") + if err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Str("oid", oid). + Msg("Failed to download file from OBS") + return nil, fmt.Errorf("failed to download file from OBS: %w", err) + } + + // Try to decrypt using keyMaterial from encrypted payload + var fileName string + if decryptedBody != "" && strings.Contains(decryptedBody, "keyMaterial") { + var decryptInfo struct { + KeyMaterial string `json:"keyMaterial"` + FileName string `json:"fileName"` + } + if err := json.Unmarshal([]byte(decryptedBody), &decryptInfo); err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Msg("Failed to parse file payload JSON") + return nil, fmt.Errorf("failed to parse file payload: %w", err) + } + + if decryptInfo.KeyMaterial != "" { + keyPreview := decryptInfo.KeyMaterial + if len(keyPreview) > 20 { + keyPreview = keyPreview[:20] + "..." + } + lc.UserLogin.Bridge.Log.Debug(). + Str("key_material_preview", keyPreview). + Msg("Decrypting file using keyMaterial from payload") + + decryptedFile, err := lc.decryptImageData(fileData, decryptInfo.KeyMaterial) + if err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Msg("Failed to decrypt file data") + return nil, fmt.Errorf("failed to decrypt file data: %w", err) + } + fileData = decryptedFile + lc.UserLogin.Bridge.Log.Info(). + Int("decrypted_size", len(fileData)). + Msg("Successfully decrypted file") + } + + if decryptInfo.FileName != "" { + fileName = decryptInfo.FileName + } + } + + if fileName == "" { + fileName = data.ContentMetadata["FILE_NAME"] + } + + if fileName == "" { + fileName = "file.bin" + } + + // Detect MIME type from file extension + mimeType := "application/octet-stream" + if strings.HasSuffix(strings.ToLower(fileName), ".pdf") { + mimeType = "application/pdf" + } + + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, fileData, fileName, mimeType) + if err != nil { + lc.UserLogin.Bridge.Log.Error(). + Err(err). + Int("size_bytes", len(fileData)). + Msg("Failed to upload file to Matrix") + return nil, fmt.Errorf("failed to upload file to matrix: %w", err) + } + + lc.UserLogin.Bridge.Log.Info(). + Str("mxc", mxc.ParseOrIgnore().String()). + Str("file_name", fileName). + Int("size", len(fileData)). + Msg("Successfully uploaded file to Matrix") + + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{ + { + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgFile, + Body: fileName, + URL: mxc, + File: file, + Info: &event.FileInfo{ + MimeType: mimeType, + Size: len(fileData), + }, + }, + }, + }, + }, nil + } + } + // Default to Text return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ @@ -1492,7 +1600,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat extension = "png" } - encryptedData, keyMaterialB64, err := lc.encryptImageData(data) + encryptedData, keyMaterialB64, err := lc.encryptFileData(data) if err != nil { return nil, fmt.Errorf("failed to encrypt image data: %w", err) } @@ -1592,109 +1700,51 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat payload = []byte("{}") case event.MsgFile: - mimeType := msg.Content.Info.MimeType - if strings.HasPrefix(mimeType, "image/") { - data, err := lc.UserLogin.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, msg.Content.File) - if err != nil { - return nil, fmt.Errorf("failed to download media from matrix: %w", err) - } - - isGif := mimeType == "image/gif" - isAnimated := isGif && isAnimatedGif(data) - - extension := "jpg" - if isGif { - extension = "gif" - } else if mimeType == "image/png" { - extension = "png" - } - - encryptedData, keyMaterialB64, err := lc.encryptImageData(data) - if err != nil { - return nil, fmt.Errorf("failed to encrypt image data: %w", err) - } - - oid, err := client.UploadOBS(encryptedData) - if err != nil { - return nil, fmt.Errorf("failed to upload encrypted image to OBS: %w", err) - } - - thumbnailData, thumbWidth, thumbHeight, err := generateThumbnail(data) - if err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate thumbnail, continuing without it") - } else { - keyMaterial, _ := base64.StdEncoding.DecodeString(keyMaterialB64) - - kdf := hkdf.New(sha256.New, keyMaterial, nil, []byte("FileEncryption")) - derived := make([]byte, 76) - io.ReadFull(kdf, derived) - - encKey := derived[0:32] - macKey := derived[32:64] - nonce := derived[64:76] - - counter := make([]byte, 16) - copy(counter, nonce) - - block, _ := aes.NewCipher(encKey) - stream := cipher.NewCTR(block, counter) - - encryptedThumb := make([]byte, len(thumbnailData)) - stream.XORKeyStream(encryptedThumb, thumbnailData) - - h := hmac.New(sha256.New, macKey) - h.Write(encryptedThumb) - encryptedThumbWithHMAC := append(encryptedThumb, h.Sum(nil)...) - - previewOID := fmt.Sprintf("%s__ud-preview", oid) - if err := client.UploadOBSWithOID(encryptedThumbWithHMAC, previewOID); err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to upload 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) - } - } - } + // Generic files + data, err := lc.UserLogin.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, msg.Content.File) + if err != nil { + return nil, fmt.Errorf("failed to download file from matrix: %w", err) + } - contentType = 1 - contentMetadata["OID"] = oid - contentMetadata["SID"] = "emi" - contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(encryptedData)) - contentMetadata["contentType"] = "1" - contentMetadata["ENC_KM"] = keyMaterialB64 - - fileName := msg.Content.Body - if fileName == "" { - if isGif { - fileName = "animation.gif" - } else { - fileName = "image.jpg" - } - } - contentMetadata["FILE_NAME"] = fileName + // Encrypt the file data + encryptedData, keyMaterialB64, err := lc.encryptFileData(data) + if err != nil { + return nil, fmt.Errorf("failed to encrypt file data: %w", err) + } - mediaContentInfo := map[string]interface{}{ - "category": "original", - "fileSize": len(encryptedData), - "extension": extension, - } + // Upload encrypted file to LINE OBS with emf SID + oid, err := client.UploadOBSWithSID(encryptedData, "emf") + if err != nil { + return nil, fmt.Errorf("failed to upload encrypted file to OBS: %w", err) + } - if isAnimated { - mediaContentInfo["animated"] = true - } + // Prepare Metadata + contentType = 14 // File + contentMetadata["OID"] = oid + contentMetadata["SID"] = "emf" // File SID + contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(data)) // Original unencrypted size + contentMetadata["contentType"] = "14" - if mediaInfoJSON, err := json.Marshal(mediaContentInfo); err == nil { - contentMetadata["MEDIA_CONTENT_INFO"] = string(mediaInfoJSON) - } + fileName := msg.Content.Body + if fileName == "" { + fileName = "file.bin" + } + contentMetadata["FILE_NAME"] = fileName - payload = []byte("{}") - } else { - return nil, fmt.Errorf("file type %s not implemented yet", mimeType) + // For files, encryption key and filename go in the E2EE encrypted payload + filePayload := map[string]string{ + "keyMaterial": keyMaterialB64, + "fileName": fileName, } + payloadBytes, _ := json.Marshal(filePayload) + payload = payloadBytes + + lc.UserLogin.Bridge.Log.Info(). + Str("oid", oid). + Str("file_name", fileName). + Int("encrypted_size", len(encryptedData)). + Int("original_size", len(data)). + Msg("Prepared E2EE file message") case event.MsgVideo: data, err := lc.UserLogin.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, msg.Content.File) From 2de5de40c773616b72988fd9c701cebf44d10c33 Mon Sep 17 00:00:00 2001 From: highesttt Date: Fri, 26 Dec 2025 12:23:55 -0600 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20Checked=20off?= =?UTF-8?q?=20media=20messages=20in=20the=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ae93682..a30670f 100644 --- a/README.md +++ b/README.md @@ -21,5 +21,5 @@ Based on the [mautrix-twilio](https://github.com/mautrix/twilio) bridge - [ ] Reply support - [ ] Prefetch chats - [x] Group chats -- [ ] Media messages (images, videos, voice notes, files) +- [x] Media messages (images, videos, voice notes, files) - [ ] Sticker support