Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions pkg/connector/handlers/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ func (h *Handler) ConvertAudio(ctx context.Context, portal *bridgev2.Portal, int
if isPlainMedia {
sid = "m"
}
audioData, err := client.DownloadOBSWithSID(ctx, oid, data.ID, sid)
downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia)
audioData, err := client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions)

if newClient, ok := h.tryRecoverClient(ctx, err); ok {
client = newClient
audioData, err = client.DownloadOBSWithSID(ctx, oid, data.ID, sid)
audioData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions)
}
_ = client

Expand Down
3 changes: 2 additions & 1 deletion pkg/connector/handlers/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ func (h *Handler) ConvertFile(ctx context.Context, portal *bridgev2.Portal, inte
if isPlainMedia {
sid = "m"
}
fileData, err := client.DownloadOBSWithSID(ctx, oid, data.ID, sid)
downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia)
fileData, err := client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions)
if err != nil {
h.Log.Warn().
Err(err).
Expand Down
80 changes: 74 additions & 6 deletions pkg/connector/handlers/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"

"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
Expand All @@ -27,30 +28,44 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
return nil, nil
}

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

var imgData []byte
var err error
dlStart := time.Now()
h.Log.Debug().
Str("oid", oid).
Str("msg_id", data.ID).
Str("tid", downloadOptions.TID).
Str("media_category", mediaCategory).
Bool("has_obs_pop", downloadOptions.OBSPop != "").
Bool("plain_media", isPlainMedia).
Msg("Downloading image from LINE OBS")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if isPlainMedia {
imgData, err = client.DownloadOBSWithSID(ctx, oid, data.ID, "m")
imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, "m", downloadOptions)
} else {
imgData, err = client.DownloadOBS(ctx, oid, data.ID)
imgData, err = client.DownloadOBSWithOptions(ctx, oid, data.ID, downloadOptions)
}

// 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")
imgData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, "m", downloadOptions)
} else {
imgData, err = client.DownloadOBS(ctx, oid, data.ID)
imgData, err = client.DownloadOBSWithOptions(ctx, oid, data.ID, downloadOptions)
}
}
downloadDuration := time.Since(dlStart)

if err != nil {
h.Log.Warn().
Err(err).
Str("oid", oid).
Str("msg_id", data.ID).
Bool("plain_media", isPlainMedia).
Dur("download_duration", downloadDuration).
Msg("Failed to download image from OBS, sending placeholder")
return &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{
Expand All @@ -67,28 +82,56 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
}

// Decrypt image if it has keyMaterial (E2EE)
var decryptDuration time.Duration
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 != "" {
decryptStart := time.Now()
decryptedImg, err := h.DecryptMedia(imgData, decryptInfo.KeyMaterial)
decryptDuration = time.Since(decryptStart)
if err != nil {
h.Log.Error().Err(err).Msg("Failed to decrypt image data")
h.Log.Error().
Err(err).
Dur("download_duration", downloadDuration).
Dur("decrypt_duration", decryptDuration).
Msg("Failed to decrypt image data")
return nil, fmt.Errorf("failed to decrypt image data: %w", err)
}
imgData = decryptedImg
}
}

// Upload to Matrix
uploadStart := time.Now()
mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg")
uploadDuration := time.Since(uploadStart)
if err != nil {
h.Log.Error().Err(err).Int("size_bytes", len(imgData)).Msg("Failed to upload image to Matrix")
h.Log.Error().
Err(err).
Int("size_bytes", len(imgData)).
Dur("download_duration", downloadDuration).
Dur("decrypt_duration", decryptDuration).
Dur("upload_duration", uploadDuration).
Msg("Failed to upload image to Matrix")
return nil, fmt.Errorf("failed to upload image to matrix: %w", err)
}

matrixMediaURL := string(mxc)
if file != nil && file.URL != "" {
matrixMediaURL = string(file.URL)
}

h.Log.Info().
Str("matrix_media_url", matrixMediaURL).
Int("size", len(imgData)).
Dur("download_duration", downloadDuration).
Dur("decrypt_duration", decryptDuration).
Dur("upload_duration", uploadDuration).
Msg("Successfully uploaded image to Matrix")

return &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{
{
Expand All @@ -104,3 +147,28 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int
},
}, nil
}

func lineMediaCategory(metadata map[string]string) string {
if metadata == nil || metadata["MEDIA_CONTENT_INFO"] == "" {
return ""
}

var info struct {
Category string `json:"category"`
}
if err := json.Unmarshal([]byte(metadata["MEDIA_CONTENT_INFO"]), &info); err != nil {
return ""
}

return info.Category
}

func lineOBSDownloadOptions(metadata map[string]string, isPlainMedia bool) line.OBSDownloadOptions {
opts := line.OBSDownloadOptions{
OBSPop: metadata["OBS_POP"],
}
if isPlainMedia && lineMediaCategory(metadata) == "original" {
opts.TID = "original"
}
return opts
}
5 changes: 3 additions & 2 deletions pkg/connector/handlers/video.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ func (h *Handler) ConvertVideo(ctx context.Context, portal *bridgev2.Portal, int
if isPlainMedia {
sid = "m"
}
downloadOptions := lineOBSDownloadOptions(data.ContentMetadata, isPlainMedia)
dlStart := time.Now()
videoData, err := client.DownloadOBSWithSID(ctx, oid, data.ID, sid)
videoData, err := client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions)

if newClient, ok := h.tryRecoverClient(ctx, err); ok {
client = newClient
videoData, err = client.DownloadOBSWithSID(ctx, oid, data.ID, sid)
videoData, err = client.DownloadOBSWithSIDOptions(ctx, oid, data.ID, sid, downloadOptions)
}
_ = client

Expand Down
65 changes: 52 additions & 13 deletions pkg/line/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"log"
"net/http"
"net/url"
"strings"
"time"

Expand All @@ -24,6 +25,9 @@ const (
ShopBaseURL = "https://line-chrome-gw.line-apps.com/api/shop/thrift/ShopService"
ExtensionVersion = "3.7.2"
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
rpcClientTimeout = 30 * time.Second
obsRetryDelay = 2 * time.Second
obsMaxRetries = 5
)

type Client struct {
Expand All @@ -32,14 +36,29 @@ type Client struct {
AccessToken string
}

type OBSDownloadOptions struct {
TID string
OBSPop string
}

func NewClient(token string) *Client {
return &Client{
HTTPClient: &http.Client{Timeout: 30 * time.Second},
HTTPClient: &http.Client{Timeout: rpcClientTimeout},
OBSClient: &http.Client{},
AccessToken: token,
}
}

func (c *Client) obsHTTPClient() *http.Client {
if c.OBSClient != nil {
return c.OBSClient
}
if c.HTTPClient != nil {
return c.HTTPClient
}
return &http.Client{}
}

func (c *Client) Login(email, pass, certificate string) (*LoginResult, error) {
// 1. Get RSA Key Info
rsaKey, err := c.GetRSAKeyInfo()
Expand Down Expand Up @@ -517,7 +536,7 @@ func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error {
req.Header.Set("X-Obs-Params", obsParamsB64)
req.Header.Set("x-line-access", obsToken)

resp, err := c.HTTPClient.Do(req)
resp, err := c.obsHTTPClient().Do(req)
if err != nil {
return fmt.Errorf("OBS upload request failed: %w", err)
}
Expand Down Expand Up @@ -579,7 +598,7 @@ func (c *Client) UploadOBSWithSID(data []byte, sid string) (string, error) {
// Use the OBS-specific access token
req.Header.Set("x-line-access", obsToken)

resp, err := c.HTTPClient.Do(req)
resp, err := c.obsHTTPClient().Do(req)
if err != nil {
return "", fmt.Errorf("OBS upload request failed: %w", err)
}
Expand Down Expand Up @@ -637,7 +656,7 @@ func (c *Client) UploadOBSWithOIDAndSID(data []byte, oid string, sid string) err
req.Header.Set("X-Obs-Params", obsParamsB64)
req.Header.Set("x-line-access", obsToken)

resp, err := c.HTTPClient.Do(req)
resp, err := c.obsHTTPClient().Do(req)
if err != nil {
return fmt.Errorf("OBS upload request failed: %w", err)
}
Expand All @@ -657,20 +676,33 @@ func (c *Client) DownloadOBS(ctx context.Context, oid string, messageID string)
return c.DownloadOBSWithSID(ctx, oid, messageID, "emi")
}

func (c *Client) DownloadOBSWithOptions(ctx context.Context, oid string, messageID string, opts OBSDownloadOptions) ([]byte, error) {
return c.DownloadOBSWithSIDOptions(ctx, oid, messageID, "emi", opts)
}

func (c *Client) DownloadOBSWithSID(ctx context.Context, oid string, messageID string, sid string) ([]byte, error) {
return c.DownloadOBSWithSIDOptions(ctx, oid, messageID, sid, OBSDownloadOptions{})
}

func (c *Client) DownloadOBSWithSIDOptions(ctx context.Context, oid string, messageID string, sid string, opts OBSDownloadOptions) ([]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)
obsURL := fmt.Sprintf("%s/r/talk/%s/%s", OBSBaseURL, sid, oid)
if opts.TID != "" {
obsURL += "/" + url.PathEscape(opts.TID)
}
if opts.OBSPop != "" {
obsURL += "?p=" + url.QueryEscape(opts.OBSPop)
}

obsToken, err := c.AcquireEncryptedAccessToken()
if err != nil {
return nil, fmt.Errorf("failed to acquire encrypted access token: %w", err)
}

// Retry loop for 202 (media still processing, e.g. video transcoding)
maxRetries := 5
for attempt := 0; attempt <= maxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
// Retry loop for 202/404 (media still processing, e.g. video transcoding).
for attempt := 0; attempt <= obsMaxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, "GET", obsURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create OBS download request: %w", err)
}
Expand All @@ -686,14 +718,21 @@ func (c *Client) DownloadOBSWithSID(ctx context.Context, oid string, messageID s
req.Header.Set("x-talk-meta", talkMeta)
}

resp, err := c.OBSClient.Do(req)
resp, err := c.obsHTTPClient().Do(req)
if err != nil {
return nil, fmt.Errorf("OBS download request failed: %w", err)
}

if (resp.StatusCode == 202 || resp.StatusCode == 404) && attempt < maxRetries {
if resp.StatusCode == 202 || resp.StatusCode == 404 {
resp.Body.Close()
time.Sleep(2 * time.Second)
if attempt >= obsMaxRetries {
return nil, fmt.Errorf("OBS download failed: media still processing after %d retries", obsMaxRetries)
}
select {
case <-time.After(obsRetryDelay):
case <-ctx.Done():
return nil, ctx.Err()
}
continue
}

Expand All @@ -712,7 +751,7 @@ func (c *Client) DownloadOBSWithSID(ctx context.Context, oid string, messageID s
return data, nil
}

return nil, fmt.Errorf("OBS download failed: media still processing after %d retries", maxRetries)
return nil, fmt.Errorf("OBS download failed: media still processing after %d retries", obsMaxRetries)
}

// this builds x-talk-meta
Expand Down
Loading