From f133c9a8109a7a5a63f401aa335e232791ff34a9 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 19:38:00 +0000 Subject: [PATCH 1/4] Bridge mentions in both directions --- ROADMAP.md | 1 + pkg/connector/capabilities.go | 5 +- pkg/connector/edits.go | 8 +- pkg/connector/handlegroupme.go | 14 +-- pkg/connector/handlematrix.go | 23 ++--- pkg/connector/mentions.go | 151 +++++++++++++++++++++++++++++++++ 6 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 pkg/connector/mentions.go diff --git a/ROADMAP.md b/ROADMAP.md index d114f68..f4566ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,6 +5,7 @@ | Group and DM text | ✅ | ✅ | | Formatting | Plain text | Flattened to plain text | | Replies | ✅ | ✅ | +| Mentions | ✅ | ✅ | | Images and GIFs | ✅ | ✅ | | Video | ✅ | ⚠️ Uploads, but GroupMe can't play it back | | Files | ✅ Groups only | ✅ Groups only | diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index bdaf0b6..07abe09 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -63,7 +63,10 @@ var fileMimes = map[string]event.CapabilitySupportLevel{ } var roomCaps = &event.RoomFeatures{ - ID: "fi.mau.groupme.capabilities.2026_10_14.4", + ID: "fi.mau.groupme.capabilities.2026_10_26.1", + Formatting: event.FormattingFeatureMap{ + event.FmtUserLink: event.CapLevelFullySupported, + }, File: event.FileFeatureMap{ event.MsgImage: { MimeTypes: imageMimes, diff --git a/pkg/connector/edits.go b/pkg/connector/edits.go index fc67280..7942472 100644 --- a/pkg/connector/edits.go +++ b/pkg/connector/edits.go @@ -96,13 +96,14 @@ func (gc *GMClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEd default: return bridgev2.ErrUnsupportedMessageType } + fmtCtx := format.NewContext(ctx) if msg.Content.Format == event.FormatHTML && msg.Content.FormattedBody != "" { - text = groupmeHTMLParser.Parse(msg.Content.FormattedBody, format.NewContext(ctx)) + text = gc.htmlParser().Parse(msg.Content.FormattedBody, fmtCtx) } if msg.Content.MsgType == event.MsgEmote { text = "/me " + text } - text = strings.TrimSpace(text) + text, mentions := takeMentions(strings.TrimSpace(text), fmtCtx) if utf8.RuneCountInString(text) > 1000 { return permanentFailure(event.MessageStatusUnsupported, "GroupMe messages are limited to 1000 characters") } @@ -117,6 +118,9 @@ func (gc *GMClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEd content.Attachments = append(content.Attachments, attachment) } } + if mentions != nil { + content.Attachments = append(content.Attachments, mentions) + } updated, err := groupmeext.EditMessage(ctx, gc.Meta.Token, kind == PortalTypeDM, chatID, ParseMessageID(target.ID), content) if err != nil { return wrapGroupMeError(err, "failed to edit message") diff --git a/pkg/connector/handlegroupme.go b/pkg/connector/handlegroupme.go index 8d7b0f0..9c4d514 100644 --- a/pkg/connector/handlegroupme.go +++ b/pkg/connector/handlegroupme.go @@ -228,13 +228,15 @@ func (gc *GMClient) convertGroupMeMessage(ctx context.Context, portal *bridgev2. } if len(msg.Text) > 0 || len(cm.Parts) == 0 { + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: msg.Text, + } + gc.applyMentions(content, msg.Attachments) cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{ - ID: "", - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: msg.Text, - }, + ID: "", + Type: event.EventMessage, + Content: content, }) } diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 60d96c9..93f4ec0 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -117,24 +117,22 @@ func (gc *GMClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Matri return nil, permanentFailure(event.MessageStatusUnsupported, "GroupMe replies cannot target poll votes").WithSendNotice(true) } content := msg.Content - text := content.Body - if content.Format == event.FormatHTML && content.FormattedBody != "" { - text = groupmeHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx)) - } - if content.MsgType == event.MsgEmote { - text = "/me " + text - } - portalType, gmid := ParsePortalID(msg.Portal.ID) if gmid == "" || (portalType != PortalTypeGroup && portalType != PortalTypeDM) { return nil, fmt.Errorf("invalid GroupMe portal") } + text := content.Body if content.MsgType == event.MsgImage || content.MsgType == event.MsgVideo || content.MsgType == event.MsgFile { text = content.GetCaption() - if text != "" && content.Format == event.FormatHTML && content.FormattedBody != "" { - text = groupmeHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx)) - } } + fmtCtx := format.NewContext(ctx) + if text != "" && content.Format == event.FormatHTML && content.FormattedBody != "" { + text = gc.htmlParser().Parse(content.FormattedBody, fmtCtx) + } + if content.MsgType == event.MsgEmote { + text = "/me " + text + } + text, mentions := takeMentions(text, fmtCtx) if utf8.RuneCountInString(text) > 1000 { return nil, permanentFailure(event.MessageStatusUnsupported, "GroupMe messages are limited to 1000 characters").WithSendNotice(true) } @@ -168,6 +166,9 @@ func (gc *GMClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Matri return nil, bridgev2.ErrUnsupportedMessageType } + if mentions != nil && out.Text != "" { + out.Attachments = append(out.Attachments, mentions) + } if msg.ReplyTo != nil { out.Attachments = append(out.Attachments, &groupme.Attachment{ Type: groupme.Reply, diff --git a/pkg/connector/mentions.go b/pkg/connector/mentions.go new file mode 100644 index 0000000..8968fd8 --- /dev/null +++ b/pkg/connector/mentions.go @@ -0,0 +1,151 @@ +// mautrix-groupme - A Matrix-GroupMe puppeting bridge. +// Copyright (C) 2026 The mautrix-groupme contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "html" + "slices" + "strings" + "unicode/utf16" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" + + "github.com/beeper/groupme-lib" +) + +// GroupMe mention loci are UTF-16 offsets into the plain text. + +const ( + mentionStart = '\uE000' + mentionEnd = '\uE001' + mentionsContextKey = "fi.mau.groupme.mentions" +) + +func (gc *GMClient) htmlParser() *format.HTMLParser { + parser := *groupmeHTMLParser + parser.PillConverter = gc.convertPill + return &parser +} + +func (gc *GMClient) convertPill(displayname, mxid, eventID string, ctx format.Context) string { + if eventID == "" && strings.HasPrefix(mxid, "@") { + if userID := gc.mentionedUser(id.UserID(mxid)); userID != "" { + ids, _ := ctx.ReturnData[mentionsContextKey].([]groupme.ID) + ctx.ReturnData[mentionsContextKey] = append(ids, userID) + return string(mentionStart) + "@" + strings.TrimPrefix(displayname, "@") + string(mentionEnd) + } + } + return format.DefaultPillConverter(displayname, mxid, eventID, ctx) +} + +func (gc *GMClient) mentionedUser(mxid id.UserID) groupme.ID { + if mxid == gc.UserLogin.UserMXID { + return groupme.ID(gc.Meta.GMID) + } + if userID, ok := gc.Main.br.Matrix.ParseGhostMXID(mxid); ok { + return ParseUserID(userID) + } + return "" +} + +func takeMentions(text string, ctx format.Context) (string, *groupme.Attachment) { + ids, _ := ctx.ReturnData[mentionsContextKey].([]groupme.ID) + if len(ids) == 0 { + return text, nil + } + var out strings.Builder + mentions := &groupme.Attachment{Type: groupme.Mentions} + offset, start := 0, -1 + for _, r := range text { + switch { + case r == mentionStart: + start = offset + case r == mentionEnd: + if start >= 0 && len(mentions.UserIDs) < len(ids) { + mentions.UserIDs = append(mentions.UserIDs, ids[len(mentions.UserIDs)]) + mentions.Loci = append(mentions.Loci, []int{start, offset - start}) + } + start = -1 + default: + out.WriteRune(r) + offset += utf16.RuneLen(r) + } + } + if len(mentions.UserIDs) == 0 { + return out.String(), nil + } + return out.String(), mentions +} + +func (gc *GMClient) applyMentions(content *event.MessageEventContent, attachments []*groupme.Attachment) { + var mentions *groupme.Attachment + for _, att := range attachments { + if att != nil && att.Type == groupme.Mentions { + mentions = att + break + } + } + if mentions == nil || len(mentions.UserIDs) == 0 { + return + } + type span struct { + start, end int + user groupme.ID + } + text := utf16.Encode([]rune(content.Body)) + spans := make([]span, 0, len(mentions.UserIDs)) + for i, user := range mentions.UserIDs { + if i >= len(mentions.Loci) || len(mentions.Loci[i]) != 2 || user == "" { + continue + } + start, length := mentions.Loci[i][0], mentions.Loci[i][1] + if start >= 0 && length > 0 && start+length <= len(text) { + spans = append(spans, span{start, start + length, user}) + } + } + slices.SortFunc(spans, func(a, b span) int { return a.start - b.start }) + escape := func(units []uint16) string { + return strings.ReplaceAll(html.EscapeString(string(utf16.Decode(units))), "\n", "
") + } + var out strings.Builder + content.Mentions = &event.Mentions{} + prev := 0 + for _, s := range spans { + if s.start < prev { + continue + } + mxid := gc.UserLogin.UserMXID + if string(s.user) != gc.Meta.GMID { + mxid = gc.Main.br.Matrix.FormatGhostMXID(MakeUserID(s.user)) + } + out.WriteString(escape(text[prev:s.start])) + out.WriteString(``) + out.WriteString(escape(text[s.start:s.end])) + out.WriteString("") + content.Mentions.Add(mxid) + prev = s.end + } + if prev == 0 { + content.Mentions = nil + return + } + out.WriteString(escape(text[prev:])) + content.Format = event.FormatHTML + content.FormattedBody = out.String() +} From 3c11795ce9fe2c7deca2c3e020699d59daaa0b9f Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 19:50:21 +0000 Subject: [PATCH 2/4] Use groupme-lib with numeric push IDs --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index a812fcf..58508e0 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,10 @@ go 1.26.0 toolchain go1.27.1 require ( - github.com/beeper/groupme-lib v0.2.1-0.20261013141939-3f2228f1769b + github.com/beeper/groupme-lib v0.2.1-0.20261026193346-17cb816de09e github.com/coder/websocket v1.8.15 github.com/google/uuid v1.2.0 + github.com/mattn/go-sqlite3 v1.14.52 github.com/rs/zerolog v1.35.1 go.mau.fi/util v0.10.1 maunium.net/go/mautrix v0.31.0 @@ -20,7 +21,6 @@ require ( github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.52 // indirect github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect github.com/rs/xid v1.6.0 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect diff --git a/go.sum b/go.sum index f8afa12..08d2765 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/beeper/groupme-lib v0.2.1-0.20261013141939-3f2228f1769b h1:l8UARv5mAmOg815nqmQ1tRy7E+qa1X01b6BS19ergxI= -github.com/beeper/groupme-lib v0.2.1-0.20261013141939-3f2228f1769b/go.mod h1:FYHpsnrrdRvw0kXIiflXpV2NAnlPHPHGgzIf/wSy/mM= +github.com/beeper/groupme-lib v0.2.1-0.20261026193346-17cb816de09e h1:/qA7XrLsKpTtX0nlMHsB+eL+YrXmG1cnCycjshxgDzE= +github.com/beeper/groupme-lib v0.2.1-0.20261026193346-17cb816de09e/go.mod h1:FYHpsnrrdRvw0kXIiflXpV2NAnlPHPHGgzIf/wSy/mM= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= From 1769183406a78a4a2907e2ef7a865161495fb91b Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 27 Oct 2026 16:36:19 +0000 Subject: [PATCH 3/4] Keep leading text intact so mention offsets still line up --- pkg/connector/mediainfo.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/connector/mediainfo.go b/pkg/connector/mediainfo.go index 1b456fe..1e3444c 100644 --- a/pkg/connector/mediainfo.go +++ b/pkg/connector/mediainfo.go @@ -24,6 +24,7 @@ import ( "slices" "strconv" "strings" + "unicode" "maunium.net/go/mautrix/event" @@ -65,14 +66,14 @@ func sameMediaURL(a, b string) bool { func visibleText(msg *groupme.Message) string { text := msg.Text for { - trimmed := strings.TrimSpace(text) + trimmed := strings.TrimRightFunc(text, unicode.IsSpace) cut := strings.LastIndexAny(trimmed, " \t\n") + 1 if !slices.ContainsFunc(msg.Attachments, func(att *groupme.Attachment) bool { return att != nil && (att.Type == groupme.Image || att.Type == groupme.Video) && sameMediaURL(trimmed[cut:], att.URL) }) { break } - text = strings.TrimSpace(trimmed[:cut]) + text = strings.TrimRightFunc(trimmed[:cut], unicode.IsSpace) } // File attachments are only bridged in groups. if msg.GroupID != "" && strings.HasPrefix(text, "Shared a document:") && slices.ContainsFunc(msg.Attachments, func(att *groupme.Attachment) bool { From 3144bc68a97dc0380a62adf3ce2a25f9f2fb1d7a Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 27 Oct 2026 16:48:51 +0000 Subject: [PATCH 4/4] Use merged groupme-lib --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 58508e0..74cc3eb 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 toolchain go1.27.1 require ( - github.com/beeper/groupme-lib v0.2.1-0.20261026193346-17cb816de09e + github.com/beeper/groupme-lib v0.2.1-0.20261027164818-a385d1c65167 github.com/coder/websocket v1.8.15 github.com/google/uuid v1.2.0 github.com/mattn/go-sqlite3 v1.14.52 diff --git a/go.sum b/go.sum index 08d2765..04007c4 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/beeper/groupme-lib v0.2.1-0.20261026193346-17cb816de09e h1:/qA7XrLsKpTtX0nlMHsB+eL+YrXmG1cnCycjshxgDzE= -github.com/beeper/groupme-lib v0.2.1-0.20261026193346-17cb816de09e/go.mod h1:FYHpsnrrdRvw0kXIiflXpV2NAnlPHPHGgzIf/wSy/mM= +github.com/beeper/groupme-lib v0.2.1-0.20261027164818-a385d1c65167 h1:OjaO3JFqQp2tuxDLvFzTk6MVNXu/7AaSgCaEMHaVfLU= +github.com/beeper/groupme-lib v0.2.1-0.20261027164818-a385d1c65167/go.mod h1:FYHpsnrrdRvw0kXIiflXpV2NAnlPHPHGgzIf/wSy/mM= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=