diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 4d6009fb..15070a5d 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -130,7 +130,11 @@ func (m *MetaConnector) getProxy(reason string) (string, error) { resp, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("failed to send request: %w", err) - } else if resp.StatusCode >= 300 || resp.StatusCode < 200 { + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode >= 300 || resp.StatusCode < 200 { return "", fmt.Errorf("unexpected status code %d", resp.StatusCode) } var respData respGetProxy @@ -507,7 +511,7 @@ func (m *MetaClient) saveConnectionState(ctx context.Context, state json.RawMess } m.lastStateSaveLock.Unlock() if state == nil { - if !ratelimited { + if ratelimited { return } state, _ = m.Client.DumpState() diff --git a/pkg/connector/handlemeta.go b/pkg/connector/handlemeta.go index 5f278c4d..634c157a 100644 --- a/pkg/connector/handlemeta.go +++ b/pkg/connector/handlemeta.go @@ -143,6 +143,7 @@ func (m *MetaClient) parseAndQueueTable(ctx context.Context, tbl *table.LSTable, } if ctx.Err() != nil { zerolog.Ctx(ctx).Warn().Err(ctx.Err()).Msg("Not dispatching parsed table, context is canceled") + return } select { case m.parsedTables <- wrapped: diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 55823bbb..35af7c92 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -46,8 +46,21 @@ const ( MetaAIMessengerID = 156025504001094 ) +// makeUserIdentifiers returns the identifier list for a Facebook user, in the same +// `network:handle` form the Instagram connector uses. Vanity usernames are optional +// on Facebook, so fall back to the numeric ID, which works in profile URLs too. +func makeUserIdentifiers(info types.UserInfo) []string { + if username := info.GetUsername(); username != "" { + return []string{fmt.Sprintf("facebook:%s", username)} + } else if fbid := info.GetFBID(); fbid != 0 { + return []string{fmt.Sprintf("facebook:%d", fbid)} + } + return nil +} + func (m *MetaClient) wrapUserInfo(info types.UserInfo) *bridgev2.UserInfo { return &bridgev2.UserInfo{ + Identifiers: makeUserIdentifiers(info), Name: ptr.Ptr(m.Main.Config.FormatDisplayname(DisplaynameParams{ DisplayName: info.GetName(), Username: info.GetUsername(), diff --git a/pkg/igconnector/chatsync.go b/pkg/igconnector/chatsync.go index 9796d0e7..fb189361 100644 --- a/pkg/igconnector/chatsync.go +++ b/pkg/igconnector/chatsync.go @@ -130,37 +130,46 @@ func (ic *IGClient) doChatBackfill(ctx context.Context, startCursor string) { } batchCount := 0 - if startCursor == "" { + cursor := startCursor + hasNextPage := true + if cursor == "" { log.Info().Msg("No start cursor, loading from scratch") resp, err := ic.Client.GetMailbox(ctx) if err != nil { log.Err(err).Msg("Failed to fetch initial inbox") return + } else if resp.Mailbox == nil { + log.Warn().Msg("Initial inbox response didn't contain a mailbox") + return } if !processThreads(resp.Mailbox.ThreadsByFolder) { return } + cursor = resp.Mailbox.ThreadsByFolder.PageInfo.EndCursor + hasNextPage = resp.Mailbox.ThreadsByFolder.PageInfo.HasNextPage } else { batchCount++ } - for batchCount < ic.Main.Config.ThreadBackfill.BatchCount { + for hasNextPage && cursor != "" && batchCount < ic.Main.Config.ThreadBackfill.BatchCount { select { case <-time.After(ic.Main.Config.ThreadBackfill.BatchDelay): case <-ctx.Done(): return } - resp, err := ic.Client.PaginateMailbox(ctx, slidetypes.MakePaginateMailboxRequest(viewerFBID, startCursor, "INBOX", nil)) + resp, err := ic.Client.PaginateMailbox(ctx, slidetypes.MakePaginateMailboxRequest(viewerFBID, cursor, "INBOX", nil)) if err != nil { log.Err(err).Msg("Failed to fetch more chats") return + } else if resp.Mailbox == nil { + log.Warn().Msg("Pagination response didn't contain a mailbox") + return } if !processThreads(resp.Mailbox.ThreadsByFolder) { return } batchCount++ - if !resp.Mailbox.ThreadsByFolder.PageInfo.HasNextPage { - break - } + cursor = resp.Mailbox.ThreadsByFolder.PageInfo.EndCursor + hasNextPage = resp.Mailbox.ThreadsByFolder.PageInfo.HasNextPage } log.Info().Int("total_batch_count", batchCount).Msg("Completed chat backfill successfully") ic.LoginMeta.BackfillCompleted = true diff --git a/pkg/igconnector/client.go b/pkg/igconnector/client.go index a8d85cb5..57aeae75 100644 --- a/pkg/igconnector/client.go +++ b/pkg/igconnector/client.go @@ -106,7 +106,11 @@ func (ic *IGConnector) getProxy(reason string) (string, error) { resp, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("failed to send request: %w", err) - } else if resp.StatusCode >= 300 || resp.StatusCode < 200 { + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode >= 300 || resp.StatusCode < 200 { return "", fmt.Errorf("unexpected status code %d", resp.StatusCode) } var respData respGetProxy diff --git a/pkg/igconnector/config.go b/pkg/igconnector/config.go index 75725092..121d1b6b 100644 --- a/pkg/igconnector/config.go +++ b/pkg/igconnector/config.go @@ -37,6 +37,7 @@ type Config struct { DisableXMABackfill bool `yaml:"disable_xma_backfill"` DisableXMAAlways bool `yaml:"disable_xma_always"` + SuppressXMA bool `yaml:"suppress_xma"` MinFullReconnectIntervalSeconds int `yaml:"min_full_reconnect_interval_seconds"` ForceRefreshIntervalSeconds int `yaml:"force_refresh_interval_seconds"` @@ -86,6 +87,7 @@ func upgradeConfig(helper up.Helper) { helper.Copy(up.Bool, "cache_connection_state") helper.Copy(up.Bool, "disable_xma_backfill") helper.Copy(up.Bool, "disable_xma_always") + helper.Copy(up.Bool, "suppress_xma") helper.Copy(up.Bool, "disable_typing") helper.Copy(up.Bool, "disable_view_once") helper.Copy(up.Int, "thread_backfill", "batch_count") diff --git a/pkg/igconnector/connector.go b/pkg/igconnector/connector.go index 37256c09..f3267070 100644 --- a/pkg/igconnector/connector.go +++ b/pkg/igconnector/connector.go @@ -44,6 +44,7 @@ func (ic *IGConnector) Init(bridge *bridgev2.Bridge) { ic.DB = metadb.New(bridge.ID, bridge.DB.Database, ic.Bridge.Log.With().Str("db_section", "meta").Logger()) ic.MsgConv = igconv.New(bridge, ic.DB) ic.MsgConv.DisableViewOnce = ic.Config.DisableViewOnce + ic.MsgConv.SuppressXMA = ic.Config.SuppressXMA } func (ic *IGConnector) Start(ctx context.Context) error { diff --git a/pkg/igconnector/example-config.yaml b/pkg/igconnector/example-config.yaml index 14cf835d..5acd45db 100644 --- a/pkg/igconnector/example-config.yaml +++ b/pkg/igconnector/example-config.yaml @@ -23,6 +23,10 @@ cache_connection_state: true disable_xma_backfill: true # Disable fetching XMA media entirely. disable_xma_always: false +# Don't bridge the preview image of XMA media at all, only the caption and a link to the original. +# The disable_xma_* options above only stop the extra requests for the full quality media, +# the preview is still reuploaded. This drops it too, so nothing is stored on the homeserver. +suppress_xma: false # Should typing notification bridging be disabled? # Bridging typing notifications requires 2 extra connections per user (one for receiving and one for sending), # because Meta only employs professional software engineers who make excellent architecture decisions. diff --git a/pkg/instameow/client.go b/pkg/instameow/client.go index c9dfdadd..57a33465 100644 --- a/pkg/instameow/client.go +++ b/pkg/instameow/client.go @@ -212,6 +212,8 @@ func (c *Client) LoadIndex(ctx context.Context) (*types.PolarisViewer, *slidetyp mailbox, err := c.GetMailbox(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get mailbox: %w", err) + } else if mailbox.GetMailbox() == nil { + return nil, nil, ErrMailboxMissing } c.seqID = mailbox.Mailbox.UQSeqID c.seqIDTS = time.Now() @@ -271,6 +273,7 @@ const MaxCachedStateAge = 24 * time.Hour var ErrCachedStateTooOld = errors.New("cached state is too old") var ErrClientIsNil = errors.New("client is nil") +var ErrMailboxMissing = errors.New("mailbox missing from response") func (c *Client) LoadState(state json.RawMessage) error { if c == nil { diff --git a/pkg/instameow/login_account_manager.go b/pkg/instameow/login_account_manager.go index be4e3a1e..73f1f445 100644 --- a/pkg/instameow/login_account_manager.go +++ b/pkg/instameow/login_account_manager.go @@ -266,7 +266,7 @@ func (c *Client) switchInstagramAccountManagerProfile( return fmt.Errorf("failed to prepare the primary Instagram web session: %w", err) } primaryCookies := c.cookies.GetAll() - primaryWWWClaim := c.cookies.IGWWWClaim + primaryWWWClaim := c.cookies.GetWWWClaim() if err := c.switchInstagramAccountManagerMobileAccount(ctx, state, account); err != nil { return err @@ -276,7 +276,7 @@ func (c *Client) switchInstagramAccountManagerProfile( // provisioned separately. Switch the already authenticated primary web session // through Instagram's matching FXCAL endpoint before persisting the selection. c.cookies.UpdateValues(primaryCookies) - c.cookies.IGWWWClaim = primaryWWWClaim + c.cookies.SetWWWClaim(primaryWWWClaim) if err := c.switchInstagramAccountManagerWebAccount(ctx, account.Username); err != nil { return err } diff --git a/pkg/instameow/login_web.go b/pkg/instameow/login_web.go index 336d4a77..9559c817 100644 --- a/pkg/instameow/login_web.go +++ b/pkg/instameow/login_web.go @@ -159,10 +159,10 @@ func (c *Client) addInstagramWebLoginHeaders(headers http.Header) error { } headers.Set("x-instagram-ajax", config.InstagramWebPushInfo.RolloutHash) headers.Set("x-web-session-id", c.configs.WebSessionID) - if c.cookies.IGWWWClaim == "" { + if wwwClaim := c.cookies.GetWWWClaim(); wwwClaim == "" { headers.Set("x-ig-www-claim", "0") } else { - headers.Set("x-ig-www-claim", c.cookies.IGWWWClaim) + headers.Set("x-ig-www-claim", wwwClaim) } if config.PolarisSiteData.SendDeviceIDHeader { if config.PolarisSiteData.DeviceID == "" { @@ -329,7 +329,7 @@ func (c *Client) CreateInstagramWebSession( cookies.IGCookieMachineID: c.cookies.Get(cookies.IGCookieMachineID), cookies.IGCookieDeviceID: c.cookies.Get(cookies.IGCookieDeviceID), }) - c.cookies.IGWWWClaim = "" + c.cookies.SetWWWClaim("") c.configs = httpclient.NewConfigs(c) c.http.SetConfigs(c.configs) diff --git a/pkg/messagix/cookies/cookies.go b/pkg/messagix/cookies/cookies.go index b8c515c9..7d6e6acb 100644 --- a/pkg/messagix/cookies/cookies.go +++ b/pkg/messagix/cookies/cookies.go @@ -54,7 +54,7 @@ type Cookies struct { values map[MetaCookieName]string lock sync.RWMutex - IGWWWClaim string + igWWWClaim string } func (c *Cookies) UpdateValues(newValues map[MetaCookieName]string) { @@ -67,10 +67,14 @@ func (c *Cookies) UpdateValues(newValues map[MetaCookieName]string) { } func (c *Cookies) MarshalJSON() ([]byte, error) { + c.lock.RLock() + defer c.lock.RUnlock() return json.Marshal(c.values) } func (c *Cookies) UnmarshalJSON(data []byte) error { + c.lock.Lock() + defer c.lock.Unlock() return json.Unmarshal(data, &c.values) } @@ -158,6 +162,21 @@ func (c *Cookies) Set(key MetaCookieName, value string) { c.values[key] = value } +func (c *Cookies) GetWWWClaim() string { + if c == nil { + return "" + } + c.lock.RLock() + defer c.lock.RUnlock() + return c.igWWWClaim +} + +func (c *Cookies) SetWWWClaim(claim string) { + c.lock.Lock() + defer c.lock.Unlock() + c.igWWWClaim = claim +} + func (c *Cookies) UpdateFromResponse(r *http.Response) { if c == nil || r == nil { return @@ -181,7 +200,7 @@ func (c *Cookies) UpdateFromResponse(r *http.Response) { } } if wwwClaim := r.Header.Get("x-ig-set-www-claim"); wwwClaim != "" { - c.IGWWWClaim = wwwClaim + c.igWWWClaim = wwwClaim } } diff --git a/pkg/messagix/cookies/cookies_test.go b/pkg/messagix/cookies/cookies_test.go new file mode 100644 index 00000000..a5408605 --- /dev/null +++ b/pkg/messagix/cookies/cookies_test.go @@ -0,0 +1,70 @@ +package cookies + +import ( + "encoding/json" + "strconv" + "sync" + "testing" +) + +const concurrencyIterations = 500 + +// Cookies is stored in the user login metadata, so it gets marshaled from +// whichever goroutine calls UserLogin.Save while HTTP responses keep updating +// it. Run both at once to make sure the map is never touched without the lock. +func TestCookiesConcurrentMarshalAndUpdate(t *testing.T) { + c := &Cookies{} + c.UpdateValues(map[MetaCookieName]string{IGCookieSessionID: "session"}) + + errs := make(chan error, concurrencyIterations) + var wg sync.WaitGroup + wg.Add(3) + go func() { + defer wg.Done() + for i := range concurrencyIterations { + c.Set(IGCookieRUR, strconv.Itoa(i)) + } + }() + go func() { + defer wg.Done() + for range concurrencyIterations { + if _, err := json.Marshal(c); err != nil { + errs <- err + } + } + }() + go func() { + defer wg.Done() + for range concurrencyIterations { + if err := json.Unmarshal([]byte(`{"sessionid":"other"}`), c); err != nil { + errs <- err + } + } + }() + wg.Wait() + close(errs) + for err := range errs { + t.Errorf("Unexpected error: %v", err) + } +} + +func TestCookiesConcurrentWWWClaim(t *testing.T) { + c := &Cookies{} + c.UpdateValues(nil) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := range concurrencyIterations { + c.SetWWWClaim(strconv.Itoa(i)) + } + }() + go func() { + defer wg.Done() + for range concurrencyIterations { + c.GetWWWClaim() + } + }() + wg.Wait() +} diff --git a/pkg/messagix/httpclient/graphql.go b/pkg/messagix/httpclient/graphql.go index dfdabf68..db9c2306 100644 --- a/pkg/messagix/httpclient/graphql.go +++ b/pkg/messagix/httpclient/graphql.go @@ -254,7 +254,7 @@ func (c *HTTPClient) MakeGraphQLRequest(ctx context.Context, name string, variab reqUrl := c.parent.GetEndpoint("graphql") //c.Logger.Info().Any("url", reqUrl).Any("payload", string(payloadBytes)).Any("headers", headers).Msg("Sending graphQL request.") - resp, respData, err := c.makeRequest(ctx, reqUrl, "POST", headers, payloadBytes, types.FORM, func(e *zerolog.Event) *zerolog.Event { + resp, respData, err := c.makeRequest(ctx, reqUrl, "POST", headers, payloadBytes, types.FORM, c.HTTP, func(e *zerolog.Event) *zerolog.Event { return e.Str("graphql_method", name) }) if err == nil && resp != nil { diff --git a/pkg/messagix/httpclient/http.go b/pkg/messagix/httpclient/http.go index bc0a3fff..cfc78e14 100644 --- a/pkg/messagix/httpclient/http.go +++ b/pkg/messagix/httpclient/http.go @@ -35,6 +35,7 @@ type HTTPClient struct { HTTP *http.Client HTTPSettings exhttp.ClientSettings websocketClient *http.Client + uploadClient *http.Client proxyAddr string GetNewProxy func(reason string) (string, error) @@ -80,6 +81,7 @@ func (c *HTTPClient) SetConfig(settings exhttp.ClientSettings) { } reqClient := req.C().ImpersonateChrome() wsClient := req.C().ImpersonateChrome() + uploadReqClient := req.C().ImpersonateChrome() forceHTTP1ChromeFingerprint(wsClient) if DisableTLSVerification { reqClient.SetTLSClientConfig(&tls.Config{ @@ -88,20 +90,35 @@ func (c *HTTPClient) SetConfig(settings exhttp.ClientSettings) { wsClient.SetTLSClientConfig(&tls.Config{ InsecureSkipVerify: true, }) + uploadReqClient.SetTLSClientConfig(&tls.Config{ + InsecureSkipVerify: true, + }) } oldHTTP := c.HTTP + oldUpload := c.uploadClient c.websocketClient = req.WithTransportOverride(c.HTTPSettings.WithGlobalTimeout(WebsocketHandshakeTimeout), wsClient).Compile() c.HTTP = req.WithTransportOverride(c.HTTPSettings, reqClient).Compile() c.HTTP.CheckRedirect = c.checkHTTPRedirect + uploadSettings := c.HTTPSettings. + WithGlobalTimeout(MediaUploadTimeout). + WithResponseHeaderTimeout(MediaUploadTimeout) + c.uploadClient = req.WithTransportOverride(uploadSettings, uploadReqClient).Compile() + c.uploadClient.CheckRedirect = c.checkHTTPRedirect if oldHTTP != nil { oldHTTP.CloseIdleConnections() } + if oldUpload != nil { + oldUpload.CloseIdleConnections() + } if DisableTLSVerification { c.HTTP.Transport.(*http.Transport).TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, } + c.uploadClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } } } @@ -196,6 +213,12 @@ func (c *HTTPClient) UpdateProxy(reason string) bool { var DisableTLSVerification = false var WebsocketHandshakeTimeout = 20 * time.Second +// MediaUploadTimeout is used instead of the default timeouts for media uploads. +// Uploading a large file takes much longer than the default global timeout, and the +// server doesn't send response headers until it has received the entire body, which +// means the default response header timeout applies to the upload itself. +var MediaUploadTimeout = 5 * time.Minute + func (c *HTTPClient) GetWebsocketDialer() *websocket.DialOptions { if c == nil { return nil @@ -392,7 +415,23 @@ func (c *HTTPClient) MakeRequest( payload []byte, contentType types.ContentType, ) (*http.Response, []byte, error) { - return c.makeRequest(ctx, url, method, headers, payload, contentType, func(e *zerolog.Event) *zerolog.Event { + return c.makeRequest(ctx, url, method, headers, payload, contentType, c.HTTP, func(e *zerolog.Event) *zerolog.Event { + return e + }) +} + +// MakeUploadRequest is like MakeRequest, but uses the client with MediaUploadTimeout +// instead of the default timeouts. It should be used for requests that send a whole +// media file as the body. +func (c *HTTPClient) MakeUploadRequest( + ctx context.Context, + url string, + method string, + headers http.Header, + payload []byte, + contentType types.ContentType, +) (*http.Response, []byte, error) { + return c.makeRequest(ctx, url, method, headers, payload, contentType, c.uploadClient, func(e *zerolog.Event) *zerolog.Event { return e }) } @@ -409,13 +448,14 @@ func (c *HTTPClient) makeRequest( headers http.Header, payload []byte, contentType types.ContentType, + httpClient *http.Client, logContext func(e *zerolog.Event) *zerolog.Event, ) (*http.Response, []byte, error) { var attempts int for { attempts++ start := time.Now() - resp, respDat, err := c.makeRequestDirect(ctx, url, method, headers, payload, contentType) + resp, respDat, err := c.makeRequestDirect(ctx, url, method, headers, payload, contentType, httpClient) dur := time.Since(start) if err == nil { logContext(c.log.Debug()). @@ -461,7 +501,7 @@ func (c *HTTPClient) makeRequest( } } -func (c *HTTPClient) makeRequestDirect(ctx context.Context, url string, method string, headers http.Header, payload []byte, contentType types.ContentType) (*http.Response, []byte, error) { +func (c *HTTPClient) makeRequestDirect(ctx context.Context, url string, method string, headers http.Header, payload []byte, contentType types.ContentType, httpClient *http.Client) (*http.Response, []byte, error) { newRequest, err := http.NewRequestWithContext(ctx, method, url, bytes.NewBuffer(payload)) if err != nil { return nil, nil, err @@ -473,7 +513,7 @@ func (c *HTTPClient) makeRequestDirect(ctx context.Context, url string, method s newRequest.Header = headers - response, err := c.HTTP.Do(newRequest) + response, err := httpClient.Do(newRequest) defer func() { if response != nil && response.Body != nil { _ = response.Body.Close() @@ -568,8 +608,8 @@ func (c *HTTPClient) addInstagramHeaders(h *http.Header) { } if c.configs.BrowserConfigTable != nil { - if c.parent.GetCookies().IGWWWClaim != "" { - h.Set("x-ig-www-claim", c.parent.GetCookies().IGWWWClaim) + if wwwClaim := c.parent.GetCookies().GetWWWClaim(); wwwClaim != "" { + h.Set("x-ig-www-claim", wwwClaim) } h.Set("x-ig-app-id", c.configs.BrowserConfigTable.CurrentUserInitialData.AppID) } diff --git a/pkg/messagix/httpclient/mercury.go b/pkg/messagix/httpclient/mercury.go index e08a4bf1..2b86c7da 100644 --- a/pkg/messagix/httpclient/mercury.go +++ b/pkg/messagix/httpclient/mercury.go @@ -91,7 +91,7 @@ func (c *HTTPClient) SendMercuryUploadRequest(ctx context.Context, threadID int6 h.Set("sec-fetch-mode", "cors") h.Set("sec-fetch-site", "same-origin") // header is required - _, respBody, err := c.MakeRequest(ctx, url, http.MethodPost, h, payload, types.NONE) + _, respBody, err := c.MakeUploadRequest(ctx, url, http.MethodPost, h, payload, types.NONE) if err != nil { // MakeRequest retries itself, so bail immediately if that fails return nil, fmt.Errorf("failed to send MercuryUploadRequest: %w", err) diff --git a/pkg/msgconv/from-meta.go b/pkg/msgconv/from-meta.go index 11e92468..bbf6c110 100644 --- a/pkg/msgconv/from-meta.go +++ b/pkg/msgconv/from-meta.go @@ -17,6 +17,7 @@ package msgconv import ( + "cmp" "context" "errors" "fmt" @@ -34,6 +35,7 @@ import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-meta/pkg/messagix" @@ -152,7 +154,9 @@ func (mc *MessageConverter) ToMatrix( urlPreviews = append(urlPreviews, xmaAtt) continue } else if xmaAtt.CTA != nil && strings.HasPrefix(xmaAtt.CTA.Type_, "xma_poll_") { - // Skip poll metadata entirely for now + if pollPart := mc.xmaPollToMatrix(ctx, xmaAtt); pollPart != nil { + cm.Parts = append(cm.Parts, pollPart) + } continue } cm.Parts = append(cm.Parts, mc.xmaAttachmentToMatrix(ctx, xmaAtt)...) @@ -339,6 +343,7 @@ func (mc *MessageConverter) blobAttachmentToMatrix(ctx context.Context, att *tab converted, err := mc.reuploadAttachment( ctx, att.AttachmentType, url, att.Filename, mime, int(att.Filesize), int(width), int(height), int(duration), + mediadl.ParseWaveformString(att.WaveformData), refreshMeta, ) if err != nil { @@ -401,6 +406,7 @@ func (mc *MessageConverter) legacyAttachmentToMatrix(ctx context.Context, att *t converted, err := mc.reuploadAttachment( ctx, att.AttachmentType, url, att.Filename, mime, int(att.Filesize), int(width), int(height), int(duration), + nil, refreshMeta, ) if err != nil { @@ -426,7 +432,7 @@ func (mc *MessageConverter) stickerToMatrix(ctx context.Context, att *table.LSIn // Stickers don't typically expire, so no refresh metadata needed converted, err := mc.reuploadAttachment( ctx, table.AttachmentTypeSticker, url, att.AccessibilitySummaryText, mime, 0, stickerSize, stickerSize, 0, - nil, + nil, nil, ) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer sticker media") @@ -490,7 +496,7 @@ func (mc *MessageConverter) urlPreviewToBeeper(ctx context.Context, att *table.W // URL previews don't typically need refresh metadata converted, err := mc.reuploadAttachment( ctx, att.AttachmentType, att.PreviewUrl, "preview", att.PreviewUrlMimeType, 0, int(att.PreviewWidth), int(att.PreviewHeight), 0, - nil, + nil, nil, ) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload URL preview image") @@ -505,6 +511,70 @@ func (mc *MessageConverter) urlPreviewToBeeper(ctx context.Context, att *table.W return preview } +type xmaPollOption struct { + Text string + VoteCount int64 + Percentage int64 +} + +func (opt xmaPollOption) FormatVotes() string { + switch { + case opt.VoteCount == 1: + return " (1 vote)" + case opt.VoteCount > 1: + return fmt.Sprintf(" (%d votes)", opt.VoteCount) + case opt.Percentage > 0: + return fmt.Sprintf(" (%d%%)", opt.Percentage) + default: + return "" + } +} + +// The XMA attachment of a poll only carries the first few options, the rest are behind +// a "see all" button in the Messenger client. +func xmaPollOptions(att *table.LSInsertXmaAttachment) []xmaPollOption { + all := []xmaPollOption{ + {att.ListItemTitleText1, att.ListItemTotalCount1, att.ListItemProgressBarFilledPercentage1}, + {att.ListItemTitleText2, att.ListItemTotalCount2, att.ListItemProgressBarFilledPercentage2}, + {att.ListItemTitleText3, att.ListItemTotalCount3, att.ListItemProgressBarFilledPercentage3}, + } + options := make([]xmaPollOption, 0, len(all)) + for _, opt := range all { + if opt.Text != "" { + options = append(options, opt) + } + } + return options +} + +// xmaPollToMatrix renders a poll as a text message. Polls can't be bridged as real +// Matrix polls, because bridgev2 has no way for a network connector to send poll +// events, but the attachment has the question and the options with their vote counts, +// so the message is at least readable instead of being dropped entirely. +func (mc *MessageConverter) xmaPollToMatrix(ctx context.Context, att *table.WrappedXMA) *bridgev2.ConvertedMessagePart { + question := cmp.Or(att.TitleText, att.ListItemsDescriptionText) + options := xmaPollOptions(att.LSInsertXmaAttachment) + if question == "" && len(options) == 0 { + zerolog.Ctx(ctx).Debug().Msg("Not bridging poll attachment with no question nor options") + return nil + } + var body strings.Builder + if question != "" { + fmt.Fprintf(&body, "%s", html.EscapeString(question)) + } + if len(options) > 0 { + body.WriteString("") + } + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: ptr.Ptr(format.HTMLToContent(body.String())), + } +} + func (mc *MessageConverter) xmaAttachmentToMatrix(ctx context.Context, att *table.WrappedXMA) []*bridgev2.ConvertedMessagePart { if att.CTA != nil && att.CTA.Type_ == "xma_live_location_sharing" { return []*bridgev2.ConvertedMessagePart{mc.xmaLocationToMatrix(ctx, att)} @@ -525,7 +595,7 @@ func (mc *MessageConverter) xmaAttachmentToMatrix(ctx context.Context, att *tabl // This is minimal conversion; fetchFullXMA will enhance with proper refresh metadata converted, err := mc.reuploadAttachment( ctx, att.AttachmentType, url, att.Filename, mime, int(att.Filesize), int(width), int(height), 0, - nil, + nil, nil, ) if errors.Is(err, mediadl.ErrURLNotFound) && att.TitleText != "" { return []*bridgev2.ConvertedMessagePart{{ @@ -557,6 +627,7 @@ func (mc *MessageConverter) reuploadAttachment( ctx context.Context, attachmentType table.AttachmentType, url, fileName, mimeType string, fileSize, width, height, duration int, + waveform []int, refreshMeta *mediadl.MediaRefreshMeta, ) (*bridgev2.ConvertedMessagePart, error) { return mediadl.ReuploadFileToMatrix(ctx, mediadl.ReuploadParams{ @@ -568,6 +639,7 @@ func (mc *MessageConverter) reuploadAttachment( Width: width, Height: height, Duration: duration, + Waveform: waveform, RefreshMeta: refreshMeta, DirectMedia: mc.DirectMedia, MaxFileSize: mc.MaxFileSize, diff --git a/pkg/msgconv/from-meta_test.go b/pkg/msgconv/from-meta_test.go new file mode 100644 index 00000000..ac21b6cf --- /dev/null +++ b/pkg/msgconv/from-meta_test.go @@ -0,0 +1,92 @@ +package msgconv + +import ( + "context" + "testing" + + "go.mau.fi/mautrix-meta/pkg/messagix/table" +) + +func TestXMAPollToMatrix(t *testing.T) { + tests := []struct { + name string + att *table.LSInsertXmaAttachment + wantNil bool + wantBody string + wantFormatted string + }{ + { + name: "question and options with votes", + att: &table.LSInsertXmaAttachment{ + TitleText: "Pizza or pasta?", + ListItemTitleText1: "Pizza", + ListItemTotalCount1: 3, + ListItemTitleText2: "Pasta", + ListItemTotalCount2: 1, + ListItemTitleText3: "Neither", + ListItemTotalCount3: 0, + }, + wantBody: "**Pizza or pasta?**\n* Pizza (3 votes)\n* Pasta (1 vote)\n* Neither", + wantFormatted: "Pizza or pasta?", + }, + { + name: "percentage is used when there's no count", + att: &table.LSInsertXmaAttachment{ + TitleText: "Best day?", + ListItemTitleText1: "Friday", + ListItemProgressBarFilledPercentage1: 75, + }, + wantBody: "**Best day?**\n* Friday (75%)", + wantFormatted: "Best day?", + }, + { + name: "question falls back to the list description", + att: &table.LSInsertXmaAttachment{ + ListItemsDescriptionText: "Untitled poll", + ListItemTitleText1: "Yes", + }, + wantBody: "**Untitled poll**\n* Yes", + wantFormatted: "Untitled poll", + }, + { + name: "question with no options", + att: &table.LSInsertXmaAttachment{TitleText: "Anyone?"}, + wantBody: "**Anyone?**", + wantFormatted: "Anyone?", + }, + { + name: "html in the question is escaped", + att: &table.LSInsertXmaAttachment{ + TitleText: "", + ListItemTitleText1: "a & b", + }, + wantFormatted: "<script>alert(1)</script>", + }, + { + name: "nothing to render", + att: &table.LSInsertXmaAttachment{}, + wantNil: true, + }, + } + mc := &MessageConverter{} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + part := mc.xmaPollToMatrix(context.Background(), &table.WrappedXMA{LSInsertXmaAttachment: test.att}) + if test.wantNil { + if part != nil { + t.Fatalf("Expected no part, got %+v", part.Content) + } + return + } + if part == nil { + t.Fatal("Expected a part, got nil") + } + if test.wantFormatted != "" && part.Content.FormattedBody != test.wantFormatted { + t.Errorf("FormattedBody =\n %q\nwant\n %q", part.Content.FormattedBody, test.wantFormatted) + } + if test.wantBody != "" && part.Content.Body != test.wantBody { + t.Errorf("Body =\n %q\nwant\n %q", part.Content.Body, test.wantBody) + } + }) + } +} diff --git a/pkg/msgconv/igconv/from-instagram.go b/pkg/msgconv/igconv/from-instagram.go index 917a1add..3debdff3 100644 --- a/pkg/msgconv/igconv/from-instagram.go +++ b/pkg/msgconv/igconv/from-instagram.go @@ -338,6 +338,7 @@ func (mc *MessageConverter) audioReuploadParams(att *slidetypes.AudioAttachment) AttachmentType: table.AttachmentTypeAudio, URL: att.AttachmentCDNURL, Duration: att.PlayableDurationMS, + Waveform: mediadl.ParseWaveformList(att.WaveformData), RefreshMeta: &mediadl.MediaRefreshMeta{AttachmentFBID: att.AttachmentFBID}, } } diff --git a/pkg/msgconv/igconv/msgconv.go b/pkg/msgconv/igconv/msgconv.go index 3caf9257..ff162bdc 100644 --- a/pkg/msgconv/igconv/msgconv.go +++ b/pkg/msgconv/igconv/msgconv.go @@ -29,6 +29,7 @@ type MessageConverter struct { MaxFileSize int64 AsyncFiles bool DisableViewOnce bool + SuppressXMA bool BridgeMode types.Platform HTMLParser *textfmt.MatrixHTMLParser DB *metadb.MetaDB diff --git a/pkg/msgconv/igconv/xma.go b/pkg/msgconv/igconv/xma.go index 32e50be4..049872c5 100644 --- a/pkg/msgconv/igconv/xma.go +++ b/pkg/msgconv/igconv/xma.go @@ -95,8 +95,19 @@ func xmaLooksLikeWhatsAppButton(xma *slidetypes.XMAContent) bool { } func (mc *MessageConverter) wrapXMA(ctx context.Context, xma *slidetypes.XMAContent) *bridgev2.ConvertedMessagePart { - previewPart := mc.wrapXMAPreviewImage(ctx, xma) captionPart := mc.wrapXMACaption(ctx, xma) + if mc.SuppressXMA { + // The caption already ends with a link to the original, so the message is still + // usable without reuploading any of the media. + if captionPart == nil { + return nil + } + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: captionPart, + } + } + previewPart := mc.wrapXMAPreviewImage(ctx, xma) if previewPart == nil { if captionPart == nil { return nil diff --git a/pkg/msgconv/mediadl/reupload.go b/pkg/msgconv/mediadl/reupload.go index a60aca8c..5b97ab88 100644 --- a/pkg/msgconv/mediadl/reupload.go +++ b/pkg/msgconv/mediadl/reupload.go @@ -86,6 +86,7 @@ type ReuploadParams struct { Width int Height int Duration int + Waveform []int PreviewWidth int PreviewHeight int @@ -156,7 +157,7 @@ func ReuploadFileToMatrix(ctx context.Context, params ReuploadParams) (*bridgev2 content.MSC3245Voice = &event.MSC3245Voice{} content.MSC1767Audio = &event.MSC1767Audio{ Duration: params.Duration, - Waveform: []int{}, + Waveform: params.Waveform, } default: switch strings.Split(params.MimeType, "/")[0] { diff --git a/pkg/msgconv/mediadl/upload.go b/pkg/msgconv/mediadl/upload.go index ac9585d3..53eee4ff 100644 --- a/pkg/msgconv/mediadl/upload.go +++ b/pkg/msgconv/mediadl/upload.go @@ -59,7 +59,7 @@ func ReuploadFileToMeta( SamplingFrequency: 9, } for i, amp := range content.MSC1767Audio.Waveform { - waveformData.Amplitudes[i] = max(min(float64(amp)/256.0, 1.0), 0.0) + waveformData.Amplitudes[i] = max(min(float64(amp)/WaveformScale, 1.0), 0.0) } } resp, err := client.SendMercuryUploadRequest(ctx, threadID, &httpclient.MercuryUploadMedia{ @@ -131,7 +131,7 @@ func reuploadVideoToMetaFallback(ctx context.Context, client *httpclient.HTTPCli h.Add("x-fb-server-cluster", "True") h.Add("x-zero-balance", "INIT") h.Add("x-zero-eh", "") - resp, body, err := client.MakeRequest( + resp, body, err := client.MakeUploadRequest( ctx, fmt.Sprintf("https://rupload.facebook.com/messenger_video/%s", uploadID), "POST", diff --git a/pkg/msgconv/mediadl/waveform.go b/pkg/msgconv/mediadl/waveform.go new file mode 100644 index 00000000..f122f5cc --- /dev/null +++ b/pkg/msgconv/mediadl/waveform.go @@ -0,0 +1,97 @@ +// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge. +// Copyright (C) 2026 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package mediadl + +import ( + "encoding/json" + "math" +) + +// WaveformScale is the value a fully saturated sample is scaled to when converting +// Meta's normalized amplitudes into the integers used in MSC1767 audio content. +// It's the inverse of the divisor used when sending waveforms in reuploadFileToMeta. +const WaveformScale = 256 + +type waveformObject struct { + Amplitudes []float64 `json:"amplitudes"` +} + +// ParseWaveformString converts the waveform of a Messenger voice message into the +// integer list used in MSC1767 audio content. The table field holds JSON, which has +// been observed as a bare list of amplitudes, and is accepted as an object with an +// amplitudes key too, since that's the shape used when uploading. +// +// It returns nil when there's no waveform or it can't be parsed, in which case the +// message is bridged without one rather than failing. +func ParseWaveformString(data string) []int { + if data == "" { + return nil + } + var amplitudes []float64 + if err := json.Unmarshal([]byte(data), &litudes); err == nil { + return scaleWaveform(amplitudes) + } + var obj waveformObject + if err := json.Unmarshal([]byte(data), &obj); err == nil { + return scaleWaveform(obj.Amplitudes) + } + return nil +} + +// ParseWaveformList converts the waveform of an Instagram voice message into the +// integer list used in MSC1767 audio content. The thread API returns the amplitudes +// already decoded, but without a concrete element type, so anything that isn't a +// number is skipped. +func ParseWaveformList(data []any) []int { + if len(data) == 0 { + return nil + } + amplitudes := make([]float64, 0, len(data)) + for _, item := range data { + switch val := item.(type) { + case float64: + amplitudes = append(amplitudes, val) + case int64: + amplitudes = append(amplitudes, float64(val)) + case json.Number: + parsed, err := val.Float64() + if err != nil { + return nil + } + amplitudes = append(amplitudes, parsed) + default: + return nil + } + } + return scaleWaveform(amplitudes) +} + +// scaleWaveform turns amplitudes normalized to 0-1 into integers scaled to +// WaveformScale, clamping anything outside the expected range. +func scaleWaveform(amplitudes []float64) []int { + if len(amplitudes) == 0 { + return nil + } + waveform := make([]int, len(amplitudes)) + for i, amplitude := range amplitudes { + if math.IsNaN(amplitude) { + amplitude = 0 + } + waveform[i] = int(math.Round(max(min(amplitude, 1), 0) * WaveformScale)) + } + return waveform +} diff --git a/pkg/msgconv/mediadl/waveform_test.go b/pkg/msgconv/mediadl/waveform_test.go new file mode 100644 index 00000000..f7b422dd --- /dev/null +++ b/pkg/msgconv/mediadl/waveform_test.go @@ -0,0 +1,77 @@ +package mediadl + +import ( + "encoding/json" + "slices" + "testing" +) + +func TestParseWaveformString(t *testing.T) { + tests := []struct { + name string + data string + want []int + }{ + {name: "empty", data: "", want: nil}, + {name: "empty list", data: "[]", want: nil}, + {name: "bare list", data: "[0, 0.25, 0.5, 1]", want: []int{0, 64, 128, 256}}, + { + name: "object with amplitudes", + data: `{"amplitudes":[0,0.5,1],"sampling_frequency":9}`, + want: []int{0, 128, 256}, + }, + {name: "out of range values are clamped", data: "[-1, 2]", want: []int{0, 256}}, + {name: "not json", data: "definitely not json", want: nil}, + {name: "wrong element type", data: `["a","b"]`, want: nil}, + {name: "object without amplitudes", data: `{"sampling_frequency":9}`, want: nil}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := ParseWaveformString(test.data) + if !slices.Equal(got, test.want) { + t.Errorf("ParseWaveformString(%q) = %v, want %v", test.data, got, test.want) + } + }) + } +} + +func TestParseWaveformList(t *testing.T) { + tests := []struct { + name string + data []any + want []int + }{ + {name: "nil", data: nil, want: nil}, + {name: "floats", data: []any{0.0, 0.5, 1.0}, want: []int{0, 128, 256}}, + {name: "json numbers", data: []any{json.Number("0.25"), json.Number("1")}, want: []int{64, 256}}, + {name: "unexpected type", data: []any{"loud"}, want: nil}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := ParseWaveformList(test.data) + if !slices.Equal(got, test.want) { + t.Errorf("ParseWaveformList(%v) = %v, want %v", test.data, got, test.want) + } + }) + } +} + +// The outgoing direction in reuploadFileToMeta divides by WaveformScale, so a waveform +// bridged from Meta and sent back should survive the round trip. +func TestWaveformRoundTrip(t *testing.T) { + original := []float64{0, 0.25, 0.5, 0.75, 1} + encoded, err := json.Marshal(original) + if err != nil { + t.Fatalf("Failed to marshal waveform: %v", err) + } + waveform := ParseWaveformString(string(encoded)) + if len(waveform) != len(original) { + t.Fatalf("Got %d samples, want %d", len(waveform), len(original)) + } + for i, amp := range waveform { + back := max(min(float64(amp)/WaveformScale, 1.0), 0.0) + if back != original[i] { + t.Errorf("Sample %d round tripped to %v, want %v", i, back, original[i]) + } + } +}