Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
5 changes: 4 additions & 1 deletion pkg/connector/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions pkg/connector/edits.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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")
Expand Down
14 changes: 8 additions & 6 deletions pkg/connector/handlegroupme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down
23 changes: 12 additions & 11 deletions pkg/connector/handlematrix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions pkg/connector/mediainfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"slices"
"strconv"
"strings"
"unicode"

"maunium.net/go/mautrix/event"

Expand Down Expand Up @@ -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 {
Expand Down
151 changes: 151 additions & 0 deletions pkg/connector/mentions.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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", "<br>")
}
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(`<a href="` + html.EscapeString(mxid.URI().MatrixToURL()) + `">`)
out.WriteString(escape(text[s.start:s.end]))
out.WriteString("</a>")
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()
}
Loading