diff --git a/pkg/connector/client.go b/pkg/connector/client.go index c0e0b64..2b33308 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -137,6 +137,7 @@ func (lc *LineClient) isUserBlocked(mid string) bool { var _ bridgev2.NetworkAPI = (*LineClient)(nil) var _ bridgev2.NetworkAPIWithUserID = (*LineClient)(nil) var _ bridgev2.ReadReceiptHandlingNetworkAPI = (*LineClient)(nil) +var _ bridgev2.BackfillingNetworkAPI = (*LineClient)(nil) var _ bridgev2.ReactionHandlingNetworkAPI = (*LineClient)(nil) func (lc *LineClient) refreshAndSave(ctx context.Context) error { diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index 26654a7..b645dfd 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -37,29 +37,58 @@ func (lc *LineClient) newMessageHandler() *handlers.Handler { func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { // Only process known content types; skip system messages (group created, member invited, etc.) + if !isBridgeableContentType(msg) { + lc.UserLogin.Bridge.Log.Debug(). + Int("content_type", msg.ContentType). + Str("msg_id", msg.ID). + Interface("content_metadata", msg.ContentMetadata). + Str("text", msg.Text). + Int("chunk_count", len(msg.Chunks)). + Msg("Skipping unsupported content type") + return + } + + senderID := makeUserID(msg.From) + + portalIDStr := portalMIDForMessage(msg, opType) + portalKey := networkid.PortalKey{ID: makePortalID(portalIDStr), Receiver: lc.UserLogin.ID} + + bodyText, unwrappedText := lc.decryptMessageBody(msg, portalIDStr) + ts := lc.parseMessageTimestamp(msg) + + lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Message[line.Message]{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventMessage, + LogContext: func(c zerolog.Context) zerolog.Context { return c.Str("msg_id", msg.ID) }, + PortalKey: portalKey, + CreatePortal: true, + Sender: bridgev2.EventSender{Sender: senderID, IsFromMe: OperationType(opType) == OpSendMessage}, + Timestamp: ts, + }, + Data: *msg, + ID: networkid.MessageID(msg.ID), + ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message) (*bridgev2.ConvertedMessage, error) { + return lc.convertLineMessage(ctx, portal, intent, data, bodyText, unwrappedText) + }, + }) +} + +// isBridgeableContentType reports whether an inbound LINE message should be +// bridged to Matrix. System messages (group created, member invited, etc.) are +// skipped, but call and contact notifications are let through regardless of +// content type because LINE may wrap them in non-standard content type values. +func isBridgeableContentType(msg *line.Message) bool { switch ContentType(msg.ContentType) { case ContentText, ContentImage, ContentVideo, ContentAudio, ContentSticker, ContentContact, ContentFile, ContentLocation: - // supported — continue + return true default: - // Let call and contact notifications through regardless of content type, - // as LINE may wrap them in non-standard content type values. - if msg.ContentMetadata["ORGCONTP"] == "CALL" || msg.ContentMetadata["ORGCONTP"] == "CONTACT" { - // pass through — the ConvertMessageFunc will handle routing - } else { - lc.UserLogin.Bridge.Log.Debug(). - Int("content_type", msg.ContentType). - Str("msg_id", msg.ID). - Interface("content_metadata", msg.ContentMetadata). - Str("text", msg.Text). - Int("chunk_count", len(msg.Chunks)). - Msg("Skipping unsupported content type") - return - } + return msg.ContentMetadata["ORGCONTP"] == "CALL" || msg.ContentMetadata["ORGCONTP"] == "CONTACT" } +} - senderID := makeUserID(msg.From) - +// portalMIDForMessage returns the chat MID that owns a message (the portal key). +func portalMIDForMessage(msg *line.Message, opType int) string { portalIDStr := msg.From // If I sent it (Type 25), the portal is the recipient (msg.To) if OperationType(opType) == OpSendMessage { @@ -69,11 +98,34 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { if ToType(msg.ToType) == ToRoom || ToType(msg.ToType) == ToGroup { portalIDStr = msg.To } + return portalIDStr +} - portalKey := networkid.PortalKey{ID: makePortalID(portalIDStr), Receiver: lc.UserLogin.ID} +// parseMessageTimestamp converts a LINE message's CreatedTime to a time.Time, +// falling back to the current time if it can't be parsed or is zero. +func (lc *LineClient) parseMessageTimestamp(msg *line.Message) time.Time { + tsInt, err := msg.CreatedTime.Int64() + if err != nil { + lc.UserLogin.Bridge.Log.Warn(). + Err(err). + Str("msg_id", msg.ID). + Msg("Failed to convert message CreatedTime to int64, using current time") + return time.Now() + } + // time.UnixMilli(0) is the Unix epoch, not Go's zero time, so IsZero() never + // catches a missing timestamp — guard on the raw value instead. + if tsInt == 0 { + return time.Now() + } + return time.UnixMilli(tsInt) +} +// decryptMessageBody runs E2EE decryption (when needed) for an inbound message +// and returns the plaintext body plus the JSON-unwrapped text. Shared by the +// live message path (queueIncomingMessage) and the backfill path (FetchMessages). +func (lc *LineClient) decryptMessageBody(msg *line.Message, portalIDStr string) (bodyText, unwrappedText string) { // Handle Content - bodyText := msg.Text + bodyText = msg.Text if bodyText == "" && len(msg.Chunks) > 0 { bodyText = "[Unable to decrypt message. Open an issue on GitHub.]" if lc.E2EE != nil { @@ -152,7 +204,7 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } // unwrap JSON payload - unwrappedText := bodyText + unwrappedText = bodyText if strings.HasPrefix(bodyText, "{") { var wrapper map[string]any if err := json.Unmarshal([]byte(bodyText), &wrapper); err == nil { @@ -161,200 +213,178 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } } } + return bodyText, unwrappedText +} + +// convertLineMessage converts an inbound LINE message into a Matrix +// ConvertedMessage. bodyText/unwrappedText are the (decrypted) message text as +// returned by decryptMessageBody. Shared by the live message path and backfill. +func (lc *LineClient) convertLineMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message, bodyText, unwrappedText string) (*bridgev2.ConvertedMessage, error) { decryptedBody := bodyText + h := lc.newMessageHandler() + replyRelatesTo := lc.resolveReplyRelatesTo(ctx, &data) - var ts time.Time - if tsInt, err := msg.CreatedTime.Int64(); err != nil { - lc.UserLogin.Bridge.Log.Warn(). - Err(err). - Str("msg_id", msg.ID). - Msg("Failed to convert message CreatedTime to int64, using current time") - ts = time.Now() - } else { - ts = time.UnixMilli(tsInt) - if ts.IsZero() { - ts = time.Now() - } + // Handle call events (ORGCONTP == "CALL") + if data.ContentMetadata["ORGCONTP"] == "CALL" { + return h.ConvertCall(data, replyRelatesTo) } - lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Message[line.Message]{ - EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventMessage, - LogContext: func(c zerolog.Context) zerolog.Context { return c.Str("msg_id", msg.ID) }, - PortalKey: portalKey, - CreatePortal: true, - Sender: bridgev2.EventSender{Sender: senderID, IsFromMe: OperationType(opType) == OpSendMessage}, - Timestamp: ts, - }, - Data: *msg, - ID: networkid.MessageID(msg.ID), - ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message) (*bridgev2.ConvertedMessage, error) { - h := lc.newMessageHandler() - replyRelatesTo := lc.resolveReplyRelatesTo(ctx, &data) - - // Handle call events (ORGCONTP == "CALL") - if data.ContentMetadata["ORGCONTP"] == "CALL" { - return h.ConvertCall(data, replyRelatesTo) - } + // Dispatch to content-type-specific handlers + switch ContentType(data.ContentType) { + case ContentImage: + return h.ConvertImage(ctx, portal, intent, data, decryptedBody, replyRelatesTo) + case ContentVideo: + return h.ConvertVideo(ctx, portal, intent, data, decryptedBody, replyRelatesTo) + case ContentAudio: + return h.ConvertAudio(ctx, portal, intent, data, decryptedBody, replyRelatesTo) + case ContentFile: + return h.ConvertFile(ctx, portal, intent, data, decryptedBody, replyRelatesTo) + case ContentSticker: + return h.ConvertSticker(ctx, portal, intent, data, replyRelatesTo) + case ContentLocation: + return h.ConvertLocation(data, replyRelatesTo) + case ContentContact: + return h.ConvertContact(data, replyRelatesTo) + } - // Dispatch to content-type-specific handlers - switch ContentType(data.ContentType) { - case ContentImage: - return h.ConvertImage(ctx, portal, intent, data, decryptedBody, replyRelatesTo) - case ContentVideo: - return h.ConvertVideo(ctx, portal, intent, data, decryptedBody, replyRelatesTo) - case ContentAudio: - return h.ConvertAudio(ctx, portal, intent, data, decryptedBody, replyRelatesTo) - case ContentFile: - return h.ConvertFile(ctx, portal, intent, data, decryptedBody, replyRelatesTo) - case ContentSticker: - return h.ConvertSticker(ctx, portal, intent, data, replyRelatesTo) - case ContentLocation: - return h.ConvertLocation(data, replyRelatesTo) - case ContentContact: - return h.ConvertContact(data, replyRelatesTo) - } + // Handle device/phone contact shared via ORGCONTP (contentType 0 with vCard) + if data.ContentMetadata["ORGCONTP"] == "CONTACT" { + return h.ConvertDeviceContact(ctx, portal, intent, data, unwrappedText, replyRelatesTo) + } - // Handle device/phone contact shared via ORGCONTP (contentType 0 with vCard) - if data.ContentMetadata["ORGCONTP"] == "CONTACT" { - return h.ConvertDeviceContact(ctx, portal, intent, data, unwrappedText, replyRelatesTo) - } + // Handle inline emoji/stamp embedded in text messages + if data.ContentMetadata["STKID"] != "" || data.ContentMetadata["STKPKGID"] != "" || + data.ContentMetadata["STICON_OWNERSHIP"] != "" { + if data.ContentMetadata["STICON_OWNERSHIP"] != "" { + h.Log.Debug(). + Str("body_text", bodyText). + Str("unwrapped_text", unwrappedText). + Interface("content_metadata", data.ContentMetadata). + Msg("STICON_OWNERSHIP: full message body") + } + return h.ConvertInlineEmoji(ctx, portal, intent, data, unwrappedText, bodyText, replyRelatesTo) + } - // Handle inline emoji/stamp embedded in text messages - if data.ContentMetadata["STKID"] != "" || data.ContentMetadata["STKPKGID"] != "" || - data.ContentMetadata["STICON_OWNERSHIP"] != "" { - if data.ContentMetadata["STICON_OWNERSHIP"] != "" { - h.Log.Debug(). - Str("body_text", bodyText). - Str("unwrapped_text", unwrappedText). - Interface("content_metadata", data.ContentMetadata). - Msg("STICON_OWNERSHIP: full message body") - } - return h.ConvertInlineEmoji(ctx, portal, intent, data, unwrappedText, bodyText, replyRelatesTo) - } + // Skip empty/whitespace-only text messages (system messages that fell through) + if strings.TrimSpace(unwrappedText) == "" { + return nil, nil + } - // Skip empty/whitespace-only text messages (system messages that fell through) - if strings.TrimSpace(unwrappedText) == "" { - return nil, nil - } + // Default to text + converted, err := h.ConvertText(unwrappedText, replyRelatesTo) + if err != nil { + return nil, err + } - // Default to text - converted, err := h.ConvertText(unwrappedText, replyRelatesTo) - if err != nil { - return nil, err + if mentionStr := data.ContentMetadata["MENTION"]; mentionStr != "" && len(converted.Parts) > 0 { + lc.UserLogin.Bridge.Log.Debug().Str("raw_mention", mentionStr).Msg("Processing inbound LINE MENTION metadata") + var mentionData struct { + MENTIONEES []struct { + M string `json:"M,omitempty"` + A string `json:"A,omitempty"` + S string `json:"S,omitempty"` + E string `json:"E,omitempty"` + } `json:"MENTIONEES"` + } + if err := json.Unmarshal([]byte(mentionStr), &mentionData); err != nil { + lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("Failed to unmarshal MENTION metadata") + } else { + ghostFormatter, ok := lc.UserLogin.Bridge.Matrix.(interface { + FormatGhostMXID(networkid.UserID) id.UserID + }) + lc.UserLogin.Bridge.Log.Debug().Bool("formatter_ok", ok).Msg("Checking FormatGhostMXID availability") + mentions := &event.Mentions{} + type mentionEntry struct { + start int + end int + mxid string } - - if mentionStr := data.ContentMetadata["MENTION"]; mentionStr != "" && len(converted.Parts) > 0 { - lc.UserLogin.Bridge.Log.Debug().Str("raw_mention", mentionStr).Msg("Processing inbound LINE MENTION metadata") - var mentionData struct { - MENTIONEES []struct { - M string `json:"M,omitempty"` - A string `json:"A,omitempty"` - S string `json:"S,omitempty"` - E string `json:"E,omitempty"` - } `json:"MENTIONEES"` - } - if err := json.Unmarshal([]byte(mentionStr), &mentionData); err != nil { - lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("Failed to unmarshal MENTION metadata") - } else { - ghostFormatter, ok := lc.UserLogin.Bridge.Matrix.(interface { - FormatGhostMXID(networkid.UserID) id.UserID - }) - lc.UserLogin.Bridge.Log.Debug().Bool("formatter_ok", ok).Msg("Checking FormatGhostMXID availability") - mentions := &event.Mentions{} - type mentionEntry struct { - start int - end int - mxid string + var entries []mentionEntry + for _, ment := range mentionData.MENTIONEES { + lc.UserLogin.Bridge.Log.Debug(). + Str("ment_mid", ment.M). + Str("ment_a", ment.A). + Str("ment_s", ment.S). + Str("ment_e", ment.E). + Bool("has_formatter", ok). + Msg("Processing mention entry") + if ment.M != "" { + var mxid id.UserID + switch { + case ment.M == lc.Mid || ment.M == string(lc.UserLogin.ID): + mxid = lc.UserLogin.UserMXID + lc.UserLogin.Bridge.Log.Debug().Str("mxid", string(mxid)).Msg("Mention targets bridge user, using real MXID") + case ok: + mxid = ghostFormatter.FormatGhostMXID(networkid.UserID(ment.M)) + default: + lc.UserLogin.Bridge.Log.Debug().Msg("Skip mention: unknown MID and no formatter available") + continue } - var entries []mentionEntry - for _, ment := range mentionData.MENTIONEES { - lc.UserLogin.Bridge.Log.Debug(). - Str("mid", ment.M). - Str("a", ment.A). - Str("s", ment.S). - Str("e", ment.E). - Bool("has_formatter", ok). - Msg("Processing mention entry") - if ment.M != "" { - var mxid id.UserID - switch { - case ment.M == lc.Mid || ment.M == string(lc.UserLogin.ID): - mxid = lc.UserLogin.UserMXID - lc.UserLogin.Bridge.Log.Debug().Str("mxid", string(mxid)).Msg("Mention targets bridge user, using real MXID") - case ok: - mxid = ghostFormatter.FormatGhostMXID(networkid.UserID(ment.M)) - default: - lc.UserLogin.Bridge.Log.Debug().Msg("Skip mention: unknown MID and no formatter available") - continue - } - lc.UserLogin.Bridge.Log.Debug().Str("mxid", string(mxid)).Msg("Formatted MXID from LINE MID") - mentions.UserIDs = append(mentions.UserIDs, mxid) - if s, errS := strconv.Atoi(ment.S); errS == nil && s >= 0 { - if e, errE := strconv.Atoi(ment.E); errE == nil && e <= len(unwrappedText) && e > s { - entries = append(entries, mentionEntry{start: s, end: e, mxid: string(mxid)}) - } - } - } - if ment.A == "1" { - mentions.Room = true - if s, errS := strconv.Atoi(ment.S); errS == nil && s >= 0 { - if e, errE := strconv.Atoi(ment.E); errE == nil && e <= len(unwrappedText) && e > s { - entries = append(entries, mentionEntry{start: s, end: e, mxid: "@room"}) - } - } + lc.UserLogin.Bridge.Log.Debug().Str("mxid", string(mxid)).Msg("Formatted MXID from LINE MID") + mentions.UserIDs = append(mentions.UserIDs, mxid) + if s, errS := strconv.Atoi(ment.S); errS == nil && s >= 0 { + if e, errE := strconv.Atoi(ment.E); errE == nil && e <= len(unwrappedText) && e > s { + entries = append(entries, mentionEntry{start: s, end: e, mxid: string(mxid)}) } } - if len(mentions.UserIDs) > 0 || mentions.Room { - logEvt := lc.UserLogin.Bridge.Log.Debug(). - Int("user_count", len(mentions.UserIDs)). - Bool("is_room", mentions.Room) - if len(entries) > 0 { - logEvt = logEvt.Int("formatted_body_entries", len(entries)) - } - logEvt.Msg("Setting mentions on converted message") - var formattedBody string - if len(entries) > 0 { - sort.Slice(entries, func(i, j int) bool { return entries[i].start < entries[j].start }) - var fb strings.Builder - lastEnd := 0 - for _, entry := range entries { - if entry.start >= lastEnd && entry.start >= 0 && entry.end <= len(unwrappedText) && entry.start < entry.end { - fb.WriteString(html.EscapeString(unwrappedText[lastEnd:entry.start])) - fb.WriteString(fmt.Sprintf(`%s`, html.EscapeString(entry.mxid), html.EscapeString(unwrappedText[entry.start:entry.end]))) - lastEnd = entry.end - } - } - fb.WriteString(html.EscapeString(unwrappedText[lastEnd:])) - formattedBody = fb.String() + } + if ment.A == "1" { + mentions.Room = true + if s, errS := strconv.Atoi(ment.S); errS == nil && s >= 0 { + if e, errE := strconv.Atoi(ment.E); errE == nil && e <= len(unwrappedText) && e > s { + entries = append(entries, mentionEntry{start: s, end: e, mxid: "@room"}) } - // Replace room mention text in body with @room for client-side highlighting. - // Process end-to-start to preserve positions for earlier entries. - if mentions.Room && len(entries) > 0 { - body := converted.Parts[0].Content.Body - for i := len(entries) - 1; i >= 0; i-- { - if entries[i].mxid == "@room" && entries[i].start >= 0 && entries[i].end <= len(body) { - body = body[:entries[i].start] + "@room" + body[entries[i].end:] - } - } - for _, part := range converted.Parts { - part.Content.Body = body - } + } + } + } + if len(mentions.UserIDs) > 0 || mentions.Room { + logEvt := lc.UserLogin.Bridge.Log.Debug(). + Int("user_count", len(mentions.UserIDs)). + Bool("is_room", mentions.Room) + if len(entries) > 0 { + logEvt = logEvt.Int("formatted_body_entries", len(entries)) + } + logEvt.Msg("Setting mentions on converted message") + var formattedBody string + if len(entries) > 0 { + sort.Slice(entries, func(i, j int) bool { return entries[i].start < entries[j].start }) + var fb strings.Builder + lastEnd := 0 + for _, entry := range entries { + if entry.start >= lastEnd && entry.start >= 0 && entry.end <= len(unwrappedText) && entry.start < entry.end { + fb.WriteString(html.EscapeString(unwrappedText[lastEnd:entry.start])) + fb.WriteString(fmt.Sprintf(`%s`, html.EscapeString(entry.mxid), html.EscapeString(unwrappedText[entry.start:entry.end]))) + lastEnd = entry.end } - for _, part := range converted.Parts { - part.Content.Mentions = mentions - if formattedBody != "" { - part.Content.Format = event.FormatHTML - part.Content.FormattedBody = formattedBody - } + } + fb.WriteString(html.EscapeString(unwrappedText[lastEnd:])) + formattedBody = fb.String() + } + // Replace room mention text in body with @room for client-side highlighting. + // Process end-to-start to preserve positions for earlier entries. + if mentions.Room && len(entries) > 0 { + body := converted.Parts[0].Content.Body + for i := len(entries) - 1; i >= 0; i-- { + if entries[i].mxid == "@room" && entries[i].start >= 0 && entries[i].end <= len(body) { + body = body[:entries[i].start] + "@room" + body[entries[i].end:] } } + for _, part := range converted.Parts { + part.Content.Body = body + } + } + for _, part := range converted.Parts { + part.Content.Mentions = mentions + if formattedBody != "" { + part.Content.Format = event.FormatHTML + part.Content.FormattedBody = formattedBody + } } } + } + } - return converted, nil - }, - }) + return converted, nil } // resolveReplyRelatesTo looks up the Matrix event ID for a replied-to LINE message. diff --git a/pkg/connector/sync.go b/pkg/connector/sync.go index 719887b..333e7cb 100644 --- a/pkg/connector/sync.go +++ b/pkg/connector/sync.go @@ -108,6 +108,103 @@ func (lc *LineClient) queueDMChatResync(ctx context.Context, mid string, createP }) } +// queueDMBackfill asks the framework to backfill a DM portal's recent history. +// It must run after the portal already exists (e.g. right after queueDMChatResync +// recreated it on unblock), because the framework skips the backfill check on the +// resync that creates a portal. CheckNeedsBackfillFunc forces a forward backfill, +// which goes through FetchMessages and is batch-sent silently — no per-message +// notifications. +func (lc *LineClient) queueDMBackfill(mid string) { + portalKey := networkid.PortalKey{ID: makePortalID(mid), Receiver: lc.UserLogin.ID} + lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + PortalKey: portalKey, + Timestamp: time.Now(), + }, + CheckNeedsBackfillFunc: func(ctx context.Context, latestMessage *database.Message) (bool, error) { + return true, nil + }, + }) +} + +// FetchMessages implements bridgev2.BackfillingNetworkAPI. It powers silent, +// batch-sent history backfill. It is currently triggered when a DM portal is +// recreated after the contact is unblocked (see queueDMBackfill), repopulating +// the restored chat's recent history without notifying for every old message. +// Only the newest params.Count messages are returned; there is no older-history +// pagination, so backward fetches return an empty, final batch. +func (lc *LineClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { + // We only populate the most recent messages; we don't paginate further back. + if !params.Forward { + return &bridgev2.FetchMessagesResponse{HasMore: false}, nil + } + + chatMID := string(params.Portal.PortalKey.ID) + limit := params.Count + if limit <= 0 { + limit = 50 + } + + client := line.NewClient(lc.AccessToken) + msgs, err := client.GetRecentMessagesV2(chatMID, limit) + if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { + if errRecover := lc.recoverToken(ctx); errRecover == nil { + client = line.NewClient(lc.AccessToken) + msgs, err = client.GetRecentMessagesV2(chatMID, limit) + } + } + if err != nil { + return nil, fmt.Errorf("failed to fetch recent messages for backfill: %w", err) + } + + // GetRecentMessagesV2 returns newest-first; backfill wants oldest-first. + backfillMsgs := make([]*bridgev2.BackfillMessage, 0, len(msgs)) + for i := len(msgs) - 1; i >= 0; i-- { + msg := msgs[i] + if msg.ContentType == 18 { + lc.cacheGroupMembersFromSystemMessage(msg) + } + if !isBridgeableContentType(msg) { + continue + } + + sender := bridgev2.EventSender{ + Sender: makeUserID(msg.From), + IsFromMe: msg.From == lc.Mid, + } + intent, ok := params.Portal.GetIntentFor(ctx, sender, lc.UserLogin, bridgev2.RemoteEventMessage) + if !ok { + continue + } + + bodyText, unwrappedText := lc.decryptMessageBody(msg, chatMID) + converted, err := lc.convertLineMessage(ctx, params.Portal, intent, *msg, bodyText, unwrappedText) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Str("msg_id", msg.ID).Str("chat_mid", chatMID).Msg("Failed to convert message for backfill") + continue + } + if converted == nil { + continue + } + + backfillMsgs = append(backfillMsgs, &bridgev2.BackfillMessage{ + ConvertedMessage: converted, + Sender: sender, + ID: networkid.MessageID(msg.ID), + Timestamp: lc.parseMessageTimestamp(msg), + }) + } + + return &bridgev2.FetchMessagesResponse{ + Messages: backfillMsgs, + HasMore: false, + // Mark the restored chat as read so the silent backfill doesn't leave a + // stale unread badge — and so the forward batch send never notifies. + MarkRead: true, + }, nil +} + func (lc *LineClient) prefetchMessages(ctx context.Context) { defer lc.wg.Done() @@ -138,8 +235,9 @@ func (lc *LineClient) prefetchMessages(ctx context.Context) { // backfillRecentMessages fetches up to limit recent messages for a single // chat and queues any not already in the local DB through the normal inbound -// message path. Used by prefetchMessages on startup and by OpUnblockContact -// to repopulate a portal that was deleted on block. +// (live) message path. Used by prefetchMessages on startup. Note that this +// notifies for any not-yet-bridged messages; the silent backfill path used on +// unblock goes through FetchMessages instead. func (lc *LineClient) backfillRecentMessages(ctx context.Context, chatMID string, limit int) { client := line.NewClient(lc.AccessToken) msgs, err := client.GetRecentMessagesV2(chatMID, limit) @@ -686,18 +784,17 @@ func (lc *LineClient) handleOperation(ctx context.Context, op line.Operation) { lc.cacheMu.Unlock() lc.UserLogin.Bridge.Log.Info().Str("mid", mid).Msg("Contact unblocked") // Reattach the DM portal: emit a ChatResync with CreatePortal so the - // framework recreates the portal that was deleted on block, then - // backfill recent messages so the room isn't empty. + // framework recreates the portal that was deleted on block, then ask it + // to backfill recent history. The backfill is batch-sent silently (see + // FetchMessages), so the restored chat repopulates without firing a + // notification for every old message — a blocked contact can't have sent + // anything new, so notifying on unblock is never useful. lowerMid := strings.ToLower(mid) if strings.HasPrefix(lowerMid, "c") || strings.HasPrefix(lowerMid, "r") { return } lc.queueDMChatResync(ctx, mid, true) - lc.wg.Add(1) - go func() { - defer lc.wg.Done() - lc.backfillRecentMessages(context.Background(), mid, 50) - }() + lc.queueDMBackfill(mid) case OpContactUpdate: mid := op.Param1