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