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("