From d41a3104ab0359cac54279d2d7ef62ca5b962dd0 Mon Sep 17 00:00:00 2001 From: Indent Date: Wed, 4 Nov 2026 07:53:11 +0000 Subject: [PATCH 1/2] connector: cut off long messages so they fit in a Matrix event Inbound LINE messages whose body, formatted body and mentions add up to more than the homeserver's 64 KiB event limit once encrypted were rejected with M_BAD_JSON and never bridged. Apply the same cutoff as mautrix-whatsapp to every converted part: drop ghost mentions, shorten the plain-text fallback when a formatted body is present, and cut the remaining text at 40k bytes. --- pkg/connector/handle_message.go | 9 +++ pkg/connector/long_message.go | 97 +++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 pkg/connector/long_message.go diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index 812354b..052bd40 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -394,6 +394,15 @@ func isLineDecryptFallbackText(text string) bool { // 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, decryptionFailed bool) (*bridgev2.ConvertedMessage, error) { + converted, err := lc.convertLineMessageContent(ctx, portal, intent, data, bodyText, unwrappedText, decryptionFailed) + if err != nil { + return nil, err + } + lc.cutOffLongMessage(ctx, converted, ContentType(data.ContentType)) + return converted, nil +} + +func (lc *LineClient) convertLineMessageContent(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message, bodyText, unwrappedText string, decryptionFailed bool) (*bridgev2.ConvertedMessage, error) { decryptedBody := bodyText replyRelatesTo := lc.resolveReplyRelatesTo(ctx, &data) diff --git a/pkg/connector/long_message.go b/pkg/connector/long_message.go new file mode 100644 index 0000000..934ff30 --- /dev/null +++ b/pkg/connector/long_message.go @@ -0,0 +1,97 @@ +package connector + +import ( + "context" + "slices" + "strings" + "unicode/utf8" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +const ( + maxMatrixTextBytes = 40000 + maxFallbackBodyBytes = 500 + longFormattedBodyBytes = 20000 + longMessageCutOffText = "\n\n[long message cut off]" + longMessageCutOffHTML = "
[long message cut off]" +) + +func (lc *LineClient) cutOffLongMessage(ctx context.Context, converted *bridgev2.ConvertedMessage, contentType ContentType) { + if converted == nil { + return + } + var isGhost func(id.UserID) bool + if lc.UserLogin != nil && lc.UserLogin.Bridge != nil { + isGhost = lc.UserLogin.Bridge.IsGhostMXID + } + for i, part := range converted.Parts { + if part == nil || part.Content == nil { + continue + } + before := textContentSize(part.Content) + if !cutOffLongContent(part.Content, isGhost) { + continue + } + zerolog.Ctx(ctx).Warn(). + Int("content_type", int(contentType)). + Int("part_index", i). + Int("size_before", before). + Int("size_after", textContentSize(part.Content)). + Msg("Cut off long LINE message to fit in a Matrix event") + } +} + +func textContentSize(content *event.MessageEventContent) int { + size := len(content.Body) + len(content.FormattedBody) + if content.Mentions != nil { + for _, userID := range content.Mentions.UserIDs { + size += len(userID) + } + } + return size +} + +func cutOffLongContent(content *event.MessageEventContent, isGhost func(id.UserID) bool) bool { + if textContentSize(content) <= maxMatrixTextBytes { + return false + } + if content.Mentions != nil && isGhost != nil { + content.Mentions.UserIDs = slices.DeleteFunc(content.Mentions.UserIDs, isGhost) + } + if content.FormattedBody == "" { + if len(content.Body) > maxMatrixTextBytes { + content.Body = truncateUTF8(content.Body, maxMatrixTextBytes) + longMessageCutOffText + } + } else if len(content.FormattedBody) > longFormattedBodyBytes && len(content.Body) > maxFallbackBodyBytes { + content.Body = truncateUTF8(content.Body, maxFallbackBodyBytes) + "[...]" + } + if len(content.FormattedBody) > maxMatrixTextBytes { + content.FormattedBody = truncateHTML(content.FormattedBody, maxMatrixTextBytes) + longMessageCutOffHTML + } + return true +} + +func truncateUTF8(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + for maxBytes > 0 && !utf8.RuneStart(s[maxBytes]) { + maxBytes-- + } + return s[:maxBytes] +} + +func truncateHTML(s string, maxBytes int) string { + s = truncateUTF8(s, maxBytes) + if idx := strings.LastIndexByte(s, '<'); idx > strings.LastIndexByte(s, '>') { + s = s[:idx] + } + if idx := strings.LastIndexByte(s, '&'); idx > strings.LastIndexByte(s, ';') { + s = s[:idx] + } + return s +} From 4e3be785bcb82444a6fb90a4439574025c010d84 Mon Sep 17 00:00:00 2001 From: Indent Date: Wed, 4 Nov 2026 15:12:56 +0000 Subject: [PATCH 2/2] connector: measure the JSON-encoded size when cutting off long messages The homeserver limit applies to the encoded event, where quotes, angle brackets and ampersands expand to escape sequences, so raw string lengths could pass the check while the event stayed too large. Measure json.Marshal output, and binary-search the cut point so escape-heavy text isn't cut far shorter than needed. --- pkg/connector/long_message.go | 67 ++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/pkg/connector/long_message.go b/pkg/connector/long_message.go index 934ff30..bdd9436 100644 --- a/pkg/connector/long_message.go +++ b/pkg/connector/long_message.go @@ -2,6 +2,7 @@ package connector import ( "context" + "encoding/json" "slices" "strings" "unicode/utf8" @@ -13,9 +14,8 @@ import ( ) const ( - maxMatrixTextBytes = 40000 + maxEncodedContentBytes = 40000 maxFallbackBodyBytes = 500 - longFormattedBodyBytes = 20000 longMessageCutOffText = "\n\n[long message cut off]" longMessageCutOffHTML = "
[long message cut off]" ) @@ -32,47 +32,66 @@ func (lc *LineClient) cutOffLongMessage(ctx context.Context, converted *bridgev2 if part == nil || part.Content == nil { continue } - before := textContentSize(part.Content) - if !cutOffLongContent(part.Content, isGhost) { + before := encodedContentSize(part.Content) + if before <= maxEncodedContentBytes { continue } + cutOffLongContent(part.Content, isGhost) zerolog.Ctx(ctx).Warn(). Int("content_type", int(contentType)). Int("part_index", i). Int("size_before", before). - Int("size_after", textContentSize(part.Content)). + Int("size_after", encodedContentSize(part.Content)). Msg("Cut off long LINE message to fit in a Matrix event") } } -func textContentSize(content *event.MessageEventContent) int { - size := len(content.Body) + len(content.FormattedBody) - if content.Mentions != nil { - for _, userID := range content.Mentions.UserIDs { - size += len(userID) - } +func encodedContentSize(content *event.MessageEventContent) int { + data, err := json.Marshal(content) + if err != nil { + return len(content.Body) + len(content.FormattedBody) } - return size + return len(data) } -func cutOffLongContent(content *event.MessageEventContent, isGhost func(id.UserID) bool) bool { - if textContentSize(content) <= maxMatrixTextBytes { - return false - } +func cutOffLongContent(content *event.MessageEventContent, isGhost func(id.UserID) bool) { if content.Mentions != nil && isGhost != nil { content.Mentions.UserIDs = slices.DeleteFunc(content.Mentions.UserIDs, isGhost) - } - if content.FormattedBody == "" { - if len(content.Body) > maxMatrixTextBytes { - content.Body = truncateUTF8(content.Body, maxMatrixTextBytes) + longMessageCutOffText + if encodedContentSize(content) <= maxEncodedContentBytes { + return } - } else if len(content.FormattedBody) > longFormattedBodyBytes && len(content.Body) > maxFallbackBodyBytes { + } + if content.FormattedBody != "" && len(content.Body) > maxFallbackBodyBytes { content.Body = truncateUTF8(content.Body, maxFallbackBodyBytes) + "[...]" + if encodedContentSize(content) <= maxEncodedContentBytes { + return + } } - if len(content.FormattedBody) > maxMatrixTextBytes { - content.FormattedBody = truncateHTML(content.FormattedBody, maxMatrixTextBytes) + longMessageCutOffHTML + if content.FormattedBody != "" { + shrinkToFit(content, &content.FormattedBody, longMessageCutOffHTML, truncateHTML) + if encodedContentSize(content) <= maxEncodedContentBytes { + return + } + } + shrinkToFit(content, &content.Body, longMessageCutOffText, truncateUTF8) +} + +func shrinkToFit(content *event.MessageEventContent, field *string, marker string, truncate func(string, int) string) { + original := *field + fits := func(n int) bool { + *field = truncate(original, n) + marker + return encodedContentSize(content) <= maxEncodedContentBytes + } + lo, hi := 0, len(original) + for lo < hi { + mid := (lo + hi + 1) / 2 + if fits(mid) { + lo = mid + } else { + hi = mid - 1 + } } - return true + *field = truncate(original, lo) + marker } func truncateUTF8(s string, maxBytes int) string {