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
+}