From feb1fd2d980e50471db9179083047f1c632c1f1d Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 19:44:20 +0000 Subject: [PATCH 1/7] Describe GroupMe images and hide media URLs GroupMe adds to text --- pkg/connector/directmedia.go | 1 + pkg/connector/handlegroupme.go | 12 +++-- pkg/connector/mediainfo.go | 81 ++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 pkg/connector/mediainfo.go diff --git a/pkg/connector/directmedia.go b/pkg/connector/directmedia.go index ff18a3c..b9bed9a 100644 --- a/pkg/connector/directmedia.go +++ b/pkg/connector/directmedia.go @@ -107,6 +107,7 @@ func (gc *GMClient) directMediaContent(ctx context.Context, msg *groupme.Message case groupme.Image: fields = []string{"1i", att.URL} content.MsgType, content.Body = event.MsgImage, "image" + content.Info = imageInfoFromURL(att.URL) case groupme.Video: fields = []string{"1v", string(gc.UserLogin.ID), att.URL} content.MsgType, content.Body = event.MsgVideo, "video" diff --git a/pkg/connector/handlegroupme.go b/pkg/connector/handlegroupme.go index 8d7b0f0..af8042c 100644 --- a/pkg/connector/handlegroupme.go +++ b/pkg/connector/handlegroupme.go @@ -180,10 +180,16 @@ func (gc *GMClient) convertGroupMeMessage(ctx context.Context, portal *bridgev2. if err != nil { return nil, fmt.Errorf("upload GroupMe attachment: %w", err) } + info := &event.FileInfo{MimeType: mime, Size: len(data)} + if att.Type == groupme.Image { + if urlInfo := imageInfoFromURL(att.URL); urlInfo != nil { + info.Width, info.Height = urlInfo.Width, urlInfo.Height + } + } content = &event.MessageEventContent{ MsgType: event.MessageType("m." + att.Type), Body: filename, - Info: &event.FileInfo{MimeType: mime, Size: len(data)}, + Info: info, } if file != nil { content.File = file @@ -227,13 +233,13 @@ func (gc *GMClient) convertGroupMeMessage(ctx context.Context, portal *bridgev2. }) } - if len(msg.Text) > 0 || len(cm.Parts) == 0 { + if text := visibleText(msg.Text, msg.Attachments); len(text) > 0 || len(cm.Parts) == 0 { cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{ ID: "", Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgText, - Body: msg.Text, + Body: text, }, }) } diff --git a/pkg/connector/mediainfo.go b/pkg/connector/mediainfo.go new file mode 100644 index 0000000..0ba1169 --- /dev/null +++ b/pkg/connector/mediainfo.go @@ -0,0 +1,81 @@ +// 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 ( + "mime" + "net/url" + "path" + "regexp" + "strconv" + "strings" + + "maunium.net/go/mautrix/event" + + "github.com/beeper/groupme-lib" +) + +// GroupMe image URLs name the file after its dimensions and format, e.g. +// i.groupme.com/498x273.gif. or m.groupme.com/uploads//200x154.original.gif. +var imageNamePattern = regexp.MustCompile(`^(\d+)x(\d+)\.(?:original\.)?([A-Za-z0-9]+)`) + +func imageInfoFromURL(rawURL string) *event.FileInfo { + parsed, err := url.Parse(rawURL) + if err != nil { + return nil + } + match := imageNamePattern.FindStringSubmatch(path.Base(parsed.Path)) + if match == nil { + return nil + } + info := &event.FileInfo{MimeType: mime.TypeByExtension("." + strings.ToLower(match[3]))} + info.Width, _ = strconv.Atoi(match[1]) + info.Height, _ = strconv.Atoi(match[2]) + return info +} + +func sameMediaURL(a, b string) bool { + if a == b { + return true + } + pa, errA := url.Parse(a) + pb, errB := url.Parse(b) + return errA == nil && errB == nil && pa.Host != "" && strings.EqualFold(pa.Host, pb.Host) && pa.Path == pb.Path +} + +// GroupMe appends media URLs and document notices to the text for older +// clients; its own clients hide them when the attachment is shown. +func visibleText(text string, attachments []*groupme.Attachment) string { + for _, att := range attachments { + if att == nil { + continue + } + switch att.Type { + case groupme.Image, groupme.Video: + trimmed := strings.TrimSpace(text) + last := trimmed[strings.LastIndexAny(trimmed, " \t\n")+1:] + if att.URL != "" && last != "" && sameMediaURL(last, att.URL) { + text = strings.TrimSpace(strings.TrimSuffix(trimmed, last)) + } + case groupme.File: + if strings.HasPrefix(text, "Shared a document:") { + text = "" + } + } + } + return text +} From 8cad42bf47f2e166ae7533dc2f39b0e8dc149c49 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 19:49:18 +0000 Subject: [PATCH 2/7] Bridge GroupMe voice notes --- ROADMAP.md | 3 +- go.mod | 4 +-- go.sum | 4 +-- pkg/connector/capabilities.go | 12 +++++++- pkg/connector/directmedia.go | 5 +++- pkg/connector/handlegroupme.go | 7 +++-- pkg/connector/handlematrix.go | 43 ++++++++++++++++++--------- pkg/connector/mediainfo.go | 53 ++++++++++++++++++++++++++++++++++ pkg/groupmeext/message.go | 45 +++++++++++++++-------------- 9 files changed, 132 insertions(+), 44 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d114f68..2e7a56b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,10 +7,11 @@ | Replies | ✅ | ✅ | | Images and GIFs | ✅ | ✅ | | Video | ✅ | ⚠️ Uploads, but GroupMe can't play it back | +| Voice notes | ✅ | ✅ | | Files | ✅ Groups only | ✅ Groups only | | Media captions | ✅ | ✅ | | Locations | ✅ | ✅ | -| Direct media | ✅ Images, videos, group files and avatars | N/A | +| Direct media | ✅ Images, videos, voice notes, group files and avatars | N/A | | Emoji reactions | ⚠️ Push events, reconciled on reconnect | ✅ | | Custom reaction icons | ❌ | ❌ | | Polls | ✅ Active polls; reminders and results as text | ⚠️ Voting only; no creation or vote withdrawal | diff --git a/go.mod b/go.mod index a812fcf..938f634 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.20261026194537-3e4dbe84c8af 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..2424663 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.20261026194537-3e4dbe84c8af h1:M7yTBVqH85eqtM4yfM53njeN37K+PueN+SDEX4T5eY4= +github.com/beeper/groupme-lib v0.2.1-0.20261026194537-3e4dbe84c8af/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 bdaf0b6..3110839 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -57,13 +57,18 @@ var videoMimes = map[string]event.CapabilitySupportLevel{ "video/mp4": event.CapLevelFullySupported, } +// Beeper clients transcode voice recordings to a listed type before sending. +var voiceMimes = map[string]event.CapabilitySupportLevel{ + "audio/mp4": event.CapLevelFullySupported, +} + var fileMimes = map[string]event.CapabilitySupportLevel{ "application/*": event.CapLevelFullySupported, "text/*": event.CapLevelFullySupported, } var roomCaps = &event.RoomFeatures{ - ID: "fi.mau.groupme.capabilities.2026_10_14.4", + ID: "fi.mau.groupme.capabilities.2026_10_26.3", File: event.FileFeatureMap{ event.MsgImage: { MimeTypes: imageMimes, @@ -75,6 +80,11 @@ var roomCaps = &event.RoomFeatures{ Caption: event.CapLevelFullySupported, MaxSize: MaxFileSize, }, + event.CapMsgVoice: { + MimeTypes: voiceMimes, + Caption: event.CapLevelDropped, + MaxSize: MaxFileSize, + }, event.MsgFile: { MimeTypes: fileMimes, Caption: event.CapLevelFullySupported, diff --git a/pkg/connector/directmedia.go b/pkg/connector/directmedia.go index b9bed9a..06cf50d 100644 --- a/pkg/connector/directmedia.go +++ b/pkg/connector/directmedia.go @@ -27,7 +27,7 @@ func (gc *GMConnector) SetUseDirectMedia() { // Credentials stay in login metadata; the framework signs and serves media IDs. // // 1i: image URL -// 1v: login ID, video URL +// 1v: login ID, video or voice note URL // 1f: login ID, group ID, file ID func (gc *GMConnector) directMediaURI(ctx context.Context, fields ...string) (id.ContentURIString, error) { mediaID := networkid.MediaID(strings.Join(fields, "\x00")) @@ -111,6 +111,9 @@ func (gc *GMClient) directMediaContent(ctx context.Context, msg *groupme.Message case groupme.Video: fields = []string{"1v", string(gc.UserLogin.ID), att.URL} content.MsgType, content.Body = event.MsgVideo, "video" + case groupme.Audio: + fields = []string{"1v", string(gc.UserLogin.ID), att.URL} + content.MsgType, content.Body = event.MsgAudio, "voice note" case groupme.File: fields = []string{"1f", string(gc.UserLogin.ID), string(msg.GroupID), att.FileID} meta, err := groupmeext.GetFileMetadata(ctx, msg.GroupID, att.FileID, gc.Meta.Token) diff --git a/pkg/connector/handlegroupme.go b/pkg/connector/handlegroupme.go index af8042c..ed130cc 100644 --- a/pkg/connector/handlegroupme.go +++ b/pkg/connector/handlegroupme.go @@ -141,7 +141,7 @@ func (gc *GMClient) convertGroupMeMessage(ctx context.Context, portal *bridgev2. partID := networkid.PartID(fmt.Sprintf("attachment-%d", i)) var content *event.MessageEventContent switch att.Type { - case groupme.Image, groupme.Video, groupme.File: + case groupme.Image, groupme.Video, groupme.Audio, groupme.File: if att.Type == groupme.File && msg.GroupID == "" { log.Warn().Msg("Ignoring GroupMe file attachment outside a group") continue @@ -163,7 +163,7 @@ func (gc *GMClient) convertGroupMeMessage(ctx context.Context, portal *bridgev2. switch att.Type { case groupme.Image: data, mime, err = groupmeext.DownloadImage(ctx, att.URL) - case groupme.Video: + case groupme.Video, groupme.Audio: data, mime, err = groupmeext.DownloadVideo(ctx, att.URL, gc.Meta.Token) case groupme.File: data, filename, mime, err = groupmeext.DownloadFile(ctx, msg.GroupID, att.FileID, gc.Meta.Token) @@ -225,6 +225,9 @@ func (gc *GMClient) convertGroupMeMessage(ctx context.Context, portal *bridgev2. // Mentions and custom emoji retain their plaintext fallback. continue } + if att.Type == groupme.Audio { + addVoiceNoteInfo(content, att) + } cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{ ID: partID, diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 60d96c9..8231c81 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -17,6 +17,7 @@ package connector import ( + "cmp" "context" "errors" "fmt" @@ -129,11 +130,14 @@ func (gc *GMClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Matri if gmid == "" || (portalType != PortalTypeGroup && portalType != PortalTypeDM) { return nil, fmt.Errorf("invalid GroupMe portal") } - if content.MsgType == event.MsgImage || content.MsgType == event.MsgVideo || content.MsgType == event.MsgFile { + switch content.MsgType { + case event.MsgImage, event.MsgVideo, event.MsgFile: text = content.GetCaption() if text != "" && content.Format == event.FormatHTML && content.FormattedBody != "" { text = groupmeHTMLParser.Parse(content.FormattedBody, format.NewContext(ctx)) } + case event.MsgAudio: + text = "" } if utf8.RuneCountInString(text) > 1000 { return nil, permanentFailure(event.MessageStatusUnsupported, "GroupMe messages are limited to 1000 characters").WithSendNotice(true) @@ -150,7 +154,7 @@ func (gc *GMClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Matri switch content.MsgType { case event.MsgText, event.MsgNotice, event.MsgEmote: - case event.MsgImage, event.MsgVideo, event.MsgFile: + case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: attachment, err := gc.uploadMatrixMedia(ctx, content, portalType, gmid) if err != nil { return nil, err @@ -212,7 +216,7 @@ func (gc *GMClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Matri } // uploadMatrixMedia shares the Matrix download and byte limit checks. GroupMe -// has separate image, video-session, and group-file upload APIs. +// has separate image, media-session, and group-file upload APIs. func (gc *GMClient) uploadMatrixMedia(ctx context.Context, content *event.MessageEventContent, portalType PortalType, gmid groupme.ID) (*groupme.Attachment, error) { data, err := gc.Main.br.Bot.DownloadMedia(ctx, content.URL, content.File) if err != nil { @@ -236,21 +240,23 @@ func (gc *GMClient) uploadMatrixMedia(ctx context.Context, content *event.Messag return nil, wrapGroupMeError(err, "failed to upload media").WithIsCertain(true) } return &groupme.Attachment{Type: groupme.Image, URL: url}, nil - case event.MsgVideo: - if mimeType == "" { - mimeType = "video/mp4" - } + case event.MsgVideo, event.MsgAudio: + var extension string + mimeType, extension = mediaUploadType(content, mimeType) var groupID, recipientID string if portalType == PortalTypeDM { recipientID = string(gmid) } else { groupID = string(gmid) } - url, preview, err := groupmeext.UploadVideo(ctx, gc.Meta.Token, gc.Meta.GMID, groupID, recipientID, data, videoExtension(content, mimeType), mimeType) + uploaded, err := groupmeext.UploadMedia(ctx, gc.Meta.Token, gc.Meta.GMID, groupID, recipientID, data, extension, mimeType) if err != nil { return nil, wrapGroupMeError(err, "failed to upload media").WithIsCertain(true) } - return &groupme.Attachment{Type: groupme.Video, URL: url, VideoPreviewURL: preview}, nil + if content.MsgType == event.MsgAudio { + return voiceNoteAttachment(content, uploaded), nil + } + return &groupme.Attachment{Type: groupme.Video, URL: uploaded.RenderURL, VideoPreviewURL: uploaded.ThumbnailURL}, nil case event.MsgFile: fileID, err := groupmeext.UploadFile(ctx, gmid, gc.Meta.Token, content.GetFileName(), data, mimeType) if err != nil { @@ -262,15 +268,24 @@ func (gc *GMClient) uploadMatrixMedia(ctx context.Context, content *event.Messag } } -// The video upload session requires an extension before it receives any bytes. -func videoExtension(content *event.MessageEventContent, mimeType string) string { +// The media upload session requires an extension before it receives any bytes. +func mediaUploadType(content *event.MessageEventContent, mimeType string) (string, string) { + if content.MsgType == event.MsgAudio { + mimeType = cmp.Or(mimeType, "audio/mp4") + } else { + mimeType = cmp.Or(mimeType, "video/mp4") + } if ext := strings.TrimPrefix(filepath.Ext(content.GetFileName()), "."); ext != "" { - return strings.ToLower(ext) + return mimeType, strings.ToLower(ext) + } + if content.MsgType == event.MsgAudio { + // mime lists audio/mp4 extensions alphabetically, so .f4a would come first. + return mimeType, "m4a" } if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 { - return strings.ToLower(strings.TrimPrefix(exts[0], ".")) + return mimeType, strings.ToLower(strings.TrimPrefix(exts[0], ".")) } - return "mp4" + return mimeType, "mp4" } // GroupMe location attachments carry latitude, longitude, and a name. diff --git a/pkg/connector/mediainfo.go b/pkg/connector/mediainfo.go index 0ba1169..fa214ff 100644 --- a/pkg/connector/mediainfo.go +++ b/pkg/connector/mediainfo.go @@ -17,6 +17,8 @@ package connector import ( + "encoding/json" + "math" "mime" "net/url" "path" @@ -27,6 +29,7 @@ import ( "maunium.net/go/mautrix/event" "github.com/beeper/groupme-lib" + "github.com/beeper/groupme/pkg/groupmeext" ) // GroupMe image URLs name the file after its dimensions and format, e.g. @@ -71,6 +74,10 @@ func visibleText(text string, attachments []*groupme.Attachment) string { if att.URL != "" && last != "" && sameMediaURL(last, att.URL) { text = strings.TrimSpace(strings.TrimSuffix(trimmed, last)) } + case groupme.Audio: + if strings.HasPrefix(text, "⚠️You received a voice note") { + text = "" + } case groupme.File: if strings.HasPrefix(text, "Shared a document:") { text = "" @@ -79,3 +86,49 @@ func visibleText(text string, attachments []*groupme.Attachment) string { } return text } + +// Matrix voice waveforms range from 0 to 1024; GroupMe peaks from 0 to 1. +const waveformScale = 1024 + +func addVoiceNoteInfo(content *event.MessageEventContent, att *groupme.Attachment) { + if content.Info == nil { + content.Info = &event.FileInfo{} + } + content.Info.Duration = int(att.Duration * 1000) + content.MSC3245Voice = &event.MSC3245Voice{} + content.MSC1767Audio = &event.MSC1767Audio{Duration: content.Info.Duration} + for _, peak := range parsePeaks(att.Peaks) { + content.MSC1767Audio.Waveform = append(content.MSC1767Audio.Waveform, int(math.Round(min(max(peak, 0), 1)*waveformScale))) + } +} + +func parsePeaks(raw json.RawMessage) []float64 { + var encoded string + if json.Unmarshal(raw, &encoded) == nil { + raw = json.RawMessage(encoded) + } + var peaks []float64 + _ = json.Unmarshal(raw, &peaks) + return peaks +} + +func voiceNoteAttachment(content *event.MessageEventContent, uploaded *groupmeext.UploadedMedia) *groupme.Attachment { + att := &groupme.Attachment{Type: groupme.Audio, URL: uploaded.RenderURL, TranscriptURL: uploaded.TranscriptURL} + durationMS := 0 + if content.MSC1767Audio != nil { + durationMS = content.MSC1767Audio.Duration + if len(content.MSC1767Audio.Waveform) > 0 { + peaks := make([]float64, len(content.MSC1767Audio.Waveform)) + for i, sample := range content.MSC1767Audio.Waveform { + peaks[i] = min(max(float64(sample)/waveformScale, 0), 1) + } + encoded, _ := json.Marshal(peaks) + att.Peaks, _ = json.Marshal(string(encoded)) + } + } + if durationMS == 0 && content.Info != nil { + durationMS = content.Info.Duration + } + att.Duration = math.Round(float64(durationMS) / 1000) + return att +} diff --git a/pkg/groupmeext/message.go b/pkg/groupmeext/message.go index 40967ed..8c0cf0a 100644 --- a/pkg/groupmeext/message.go +++ b/pkg/groupmeext/message.go @@ -18,7 +18,8 @@ func DownloadImage(ctx context.Context, imageURL string) ([]byte, string, error) return downloadMediaURL(ctx, imageURL, "") } -// DownloadVideo uses the native token-cookie authentication contract. +// DownloadVideo uses the native token-cookie authentication contract, which +// voice notes share. func DownloadVideo(ctx context.Context, videoURL, token string) ([]byte, string, error) { return downloadMediaURL(ctx, videoURL, token) } @@ -113,7 +114,7 @@ func GetFileMetadata(ctx context.Context, groupID groupme.ID, fileID, token stri return &meta[0].FileData, nil } -// Video sessions require a group ID or recipient ID, plus size and extension. +// Upload sessions require a group ID or recipient ID, plus size and extension. type createUploadRequest struct { FileSize int64 `json:"FileSize"` SenderId string `json:"SenderId"` @@ -122,17 +123,19 @@ type createUploadRequest struct { RecipientId string `json:"recipientId,omitempty"` } -// UploadURL is a temporary signed upload destination. RenderURL and ThumbnailURL -// are attachment URLs; no expiry parameter was observed in those URLs. -type createUploadResponse struct { - UploadURL string `json:"uploadUrl"` - RenderURL string `json:"renderUrl"` - ThumbnailURL string `json:"thumbnailUrl"` +// UploadURL is a temporary signed upload destination. The other URLs are +// attachment URLs; no expiry parameter was observed in them. +type UploadedMedia struct { + UploadURL string `json:"uploadUrl"` + RenderURL string `json:"renderUrl"` + ThumbnailURL string `json:"thumbnailUrl"` + TranscriptURL string `json:"transcriptUrl"` } -// UploadVideo creates a session, then PUTs bytes to its signed URL without -// GroupMe credentials. Exactly one of groupID and recipientID must be set. -func UploadVideo(ctx context.Context, token, senderID, groupID, recipientID string, data []byte, extension, mimeType string) (renderURL, thumbnailURL string, err error) { +// UploadMedia uploads a video or voice note: it creates a session, then PUTs +// bytes to its signed URL without GroupMe credentials. Exactly one of groupID +// and recipientID must be set. +func UploadMedia(ctx context.Context, token, senderID, groupID, recipientID string, data []byte, extension, mimeType string) (*UploadedMedia, error) { reqBody, err := json.Marshal(createUploadRequest{ FileSize: int64(len(data)), SenderId: senderID, @@ -141,33 +144,33 @@ func UploadVideo(ctx context.Context, token, senderID, groupID, recipientID stri RecipientId: recipientID, }) if err != nil { - return "", "", fmt.Errorf("failed to build upload session request: %w", err) + return nil, fmt.Errorf("failed to build upload session request: %w", err) } sessionReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://m.groupme.com/uploads", bytes.NewReader(reqBody)) if err != nil { - return "", "", fmt.Errorf("failed to build upload session request: %w", err) + return nil, fmt.Errorf("failed to build upload session request: %w", err) } sessionReq.Header.Set("X-Access-Token", token) sessionReq.Header.Set("Content-Type", "application/json") sessionResp, err := mediaRequest(sessionReq) if err != nil { - return "", "", fmt.Errorf("failed to create upload session: %w", err) + return nil, fmt.Errorf("failed to create upload session: %w", err) } defer sessionResp.Body.Close() - var session createUploadResponse + var session UploadedMedia if err := json.NewDecoder(io.LimitReader(sessionResp.Body, 1024*1024)).Decode(&session); err != nil { - return "", "", fmt.Errorf("failed to decode upload session response: %w", err) + return nil, fmt.Errorf("failed to decode upload session response: %w", err) } if session.UploadURL == "" || session.RenderURL == "" { - return "", "", fmt.Errorf("GroupMe did not return an upload URL for the video session") + return nil, fmt.Errorf("GroupMe did not return an upload URL for the media session") } putReq, err := http.NewRequestWithContext(ctx, http.MethodPut, session.UploadURL, bytes.NewReader(data)) if err != nil { - return "", "", fmt.Errorf("invalid GroupMe video upload URL") + return nil, fmt.Errorf("invalid GroupMe media upload URL") } putReq.Header.Set("Content-Type", mimeType) // Required by Azure Blob Storage for a PUT that creates a new blob @@ -177,11 +180,11 @@ func UploadVideo(ctx context.Context, token, senderID, groupID, recipientID stri putResp, err := mediaRequest(putReq) if err != nil { - return "", "", fmt.Errorf("failed to upload video bytes: %w", err) + return nil, fmt.Errorf("failed to upload media bytes: %w", err) } defer putResp.Body.Close() - return session.RenderURL, session.ThumbnailURL, nil + return &session, nil } // createFileResponse is file.groupme.com's response to starting a file @@ -205,7 +208,7 @@ type fileUploadStatus struct { // UploadFile uploads an outgoing file (GroupMe's group file-sharing // feature -- see DownloadFile's doc comment) and returns its file_id, for // use as a "file" attachment's file_id. Reverse-engineered live the same -// way as UploadVideo. +// way as UploadMedia. // // Unlike video, this doesn't need a separate SAS-signed upload step -- // file.groupme.com is GroupMe's own file-service (confirmed via its From b8850d07102aabf085f1040bf236723892204c6c Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 19:56:16 +0000 Subject: [PATCH 3/7] Strip every trailing media URL and keep DM document notices --- pkg/connector/handlegroupme.go | 2 +- pkg/connector/mediainfo.go | 33 +++++++++++++++++---------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/pkg/connector/handlegroupme.go b/pkg/connector/handlegroupme.go index af8042c..b1e1050 100644 --- a/pkg/connector/handlegroupme.go +++ b/pkg/connector/handlegroupme.go @@ -233,7 +233,7 @@ func (gc *GMClient) convertGroupMeMessage(ctx context.Context, portal *bridgev2. }) } - if text := visibleText(msg.Text, msg.Attachments); len(text) > 0 || len(cm.Parts) == 0 { + if text := visibleText(msg); len(text) > 0 || len(cm.Parts) == 0 { cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{ ID: "", Type: event.EventMessage, diff --git a/pkg/connector/mediainfo.go b/pkg/connector/mediainfo.go index 0ba1169..1c54b4f 100644 --- a/pkg/connector/mediainfo.go +++ b/pkg/connector/mediainfo.go @@ -21,6 +21,7 @@ import ( "net/url" "path" "regexp" + "slices" "strconv" "strings" @@ -59,23 +60,23 @@ func sameMediaURL(a, b string) bool { // GroupMe appends media URLs and document notices to the text for older // clients; its own clients hide them when the attachment is shown. -func visibleText(text string, attachments []*groupme.Attachment) string { - for _, att := range attachments { - if att == nil { - continue - } - switch att.Type { - case groupme.Image, groupme.Video: - trimmed := strings.TrimSpace(text) - last := trimmed[strings.LastIndexAny(trimmed, " \t\n")+1:] - if att.URL != "" && last != "" && sameMediaURL(last, att.URL) { - text = strings.TrimSpace(strings.TrimSuffix(trimmed, last)) - } - case groupme.File: - if strings.HasPrefix(text, "Shared a document:") { - text = "" - } +func visibleText(msg *groupme.Message) string { + text := msg.Text + for { + trimmed := strings.TrimSpace(text) + 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]) + } + // 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 { + return att != nil && att.Type == groupme.File + }) { + return "" } return text } From a95aedc9b7b541c46770f915ee4e050567af1bde Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 19:56:59 +0000 Subject: [PATCH 4/7] Keep sub-second voice notes' duration --- pkg/connector/mediainfo.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/connector/mediainfo.go b/pkg/connector/mediainfo.go index ca8ddfc..0cdca93 100644 --- a/pkg/connector/mediainfo.go +++ b/pkg/connector/mediainfo.go @@ -131,6 +131,9 @@ func voiceNoteAttachment(content *event.MessageEventContent, uploaded *groupmeex if durationMS == 0 && content.Info != nil { durationMS = content.Info.Duration } - att.Duration = math.Round(float64(durationMS) / 1000) + // GroupMe's clients send whole seconds. + if durationMS > 0 { + att.Duration = max(1, math.Round(float64(durationMS)/1000)) + } return att } From 6f39803a0591b30c963545e09597f0a24b35bb69 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 19:57:24 +0000 Subject: [PATCH 5/7] Never match an empty media URL --- pkg/connector/mediainfo.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/connector/mediainfo.go b/pkg/connector/mediainfo.go index 1c54b4f..1b456fe 100644 --- a/pkg/connector/mediainfo.go +++ b/pkg/connector/mediainfo.go @@ -50,7 +50,9 @@ func imageInfoFromURL(rawURL string) *event.FileInfo { } func sameMediaURL(a, b string) bool { - if a == b { + if a == "" || b == "" { + return false + } else if a == b { return true } pa, errA := url.Parse(a) From 00d3ac6459a9717642360b456a5f8825f5177c64 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Tue, 27 Oct 2026 18:08:13 +0000 Subject: [PATCH 6/7] Decode comma-separated voice peaks from live DMs GroupMe's live DM events carry an audio attachment's peaks as a bare "0.0,0.125,..." string rather than the bracketed form groups use, so DM voice notes arrived without a waveform. Co-Authored-By: Claude Opus 5.5 --- pkg/connector/mediainfo.go | 5 +++++ pkg/connector/mediainfo_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 pkg/connector/mediainfo_test.go diff --git a/pkg/connector/mediainfo.go b/pkg/connector/mediainfo.go index 3638800..f53ddbe 100644 --- a/pkg/connector/mediainfo.go +++ b/pkg/connector/mediainfo.go @@ -110,6 +110,11 @@ func addVoiceNoteInfo(content *event.MessageEventContent, att *groupme.Attachmen func parsePeaks(raw json.RawMessage) []float64 { var encoded string if json.Unmarshal(raw, &encoded) == nil { + // Live DM events send comma-separated peaks without array brackets. + encoded = strings.TrimSpace(encoded) + if !strings.HasPrefix(encoded, "[") { + encoded = "[" + encoded + "]" + } raw = json.RawMessage(encoded) } var peaks []float64 diff --git a/pkg/connector/mediainfo_test.go b/pkg/connector/mediainfo_test.go new file mode 100644 index 0000000..d4e34c2 --- /dev/null +++ b/pkg/connector/mediainfo_test.go @@ -0,0 +1,31 @@ +package connector + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/beeper/groupme-lib" + "maunium.net/go/mautrix/event" +) + +func TestVoiceNoteNativePeakFormats(t *testing.T) { + for name, peaks := range map[string]string{ + "array": `[0,0.125,0.5,1,0]`, + "group string": `"[0,0.125,0.5,1,0]"`, + "live DM string": `"0.0,0.125,0.5,1.0,0.0"`, + } { + t.Run(name, func(t *testing.T) { + content := &event.MessageEventContent{} + addVoiceNoteInfo(content, &groupme.Attachment{ + Type: groupme.Audio, Duration: 3, Peaks: json.RawMessage(peaks), + }) + if content.MSC3245Voice == nil || content.Info.Duration != 3000 || content.MSC1767Audio.Duration != 3000 { + t.Fatal("voice flag or duration missing") + } + if !slices.Equal(content.MSC1767Audio.Waveform, []int{0, 128, 512, 1024, 0}) { + t.Fatalf("unexpected waveform: %v", content.MSC1767Audio.Waveform) + } + }) + } +} From 075d1820b3d24c27b5998536226e3ff3da3c781a Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 27 Oct 2026 18:11:24 +0000 Subject: [PATCH 7/7] Group test imports for goimports --- pkg/connector/mediainfo_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/connector/mediainfo_test.go b/pkg/connector/mediainfo_test.go index d4e34c2..a7e1573 100644 --- a/pkg/connector/mediainfo_test.go +++ b/pkg/connector/mediainfo_test.go @@ -5,8 +5,9 @@ import ( "slices" "testing" - "github.com/beeper/groupme-lib" "maunium.net/go/mautrix/event" + + "github.com/beeper/groupme-lib" ) func TestVoiceNoteNativePeakFormats(t *testing.T) {