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..bdd9436 --- /dev/null +++ b/pkg/connector/long_message.go @@ -0,0 +1,116 @@ +package connector + +import ( + "context" + "encoding/json" + "slices" + "strings" + "unicode/utf8" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +const ( + maxEncodedContentBytes = 40000 + maxFallbackBodyBytes = 500 + 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 := 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", encodedContentSize(part.Content)). + Msg("Cut off long LINE message to fit in a Matrix event") + } +} + +func encodedContentSize(content *event.MessageEventContent) int { + data, err := json.Marshal(content) + if err != nil { + return len(content.Body) + len(content.FormattedBody) + } + return len(data) +} + +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 encodedContentSize(content) <= maxEncodedContentBytes { + return + } + } + if content.FormattedBody != "" && len(content.Body) > maxFallbackBodyBytes { + content.Body = truncateUTF8(content.Body, maxFallbackBodyBytes) + "[...]" + if encodedContentSize(content) <= maxEncodedContentBytes { + return + } + } + 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 + } + } + *field = truncate(original, lo) + marker +} + +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 +}