diff --git a/ROADMAP.md b/ROADMAP.md
index 43cc86e..112f656 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/go.mod b/go.mod
index a812fcf..74cc3eb 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.20261027164818-a385d1c65167
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..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.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.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=
diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go
index 5c9b9eb..0d77072 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_26.2",
+ ID: "fi.mau.groupme.capabilities.2026_10_26.4",
+ 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 d919801..35333d8 100644
--- a/pkg/connector/edits.go
+++ b/pkg/connector/edits.go
@@ -98,13 +98,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")
}
@@ -119,6 +120,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 b248dbd..b6aab63 100644
--- a/pkg/connector/handlegroupme.go
+++ b/pkg/connector/handlegroupme.go
@@ -241,13 +241,15 @@ func (gc *GMClient) convertGroupMeMessage(ctx context.Context, portal *bridgev2.
}
if text := visibleText(msg); len(text) > 0 || len(cm.Parts) == 0 {
+ content := &event.MessageEventContent{
+ MsgType: event.MsgText,
+ Body: text,
+ }
+ gc.applyMentions(content, msg.Attachments)
cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{
- ID: "",
- Type: event.EventMessage,
- Content: &event.MessageEventContent{
- MsgType: event.MsgText,
- Body: 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/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 {
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()
+}