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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.26.0
toolchain go1.27.1

require (
github.com/beeper/groupme-lib v0.2.1-0.20261027180653-c4a4c74d6dd4
github.com/beeper/groupme-lib v0.2.1-0.20261028164212-4ccebf539ff9
github.com/coder/websocket v1.8.15
github.com/google/uuid v1.2.0
github.com/mattn/go-sqlite3 v1.14.52
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.20261027180653-c4a4c74d6dd4 h1:vIGN8y93kyKNOw5VF6B49EhQDSzKunHk71gn2Kf1lt0=
github.com/beeper/groupme-lib v0.2.1-0.20261027180653-c4a4c74d6dd4/go.mod h1:FYHpsnrrdRvw0kXIiflXpV2NAnlPHPHGgzIf/wSy/mM=
github.com/beeper/groupme-lib v0.2.1-0.20261028164212-4ccebf539ff9 h1:l3rFC0xBsPYhWlzMPrdZYJTzXURbEjtDsJWMd5t0Zhk=
github.com/beeper/groupme-lib v0.2.1-0.20261028164212-4ccebf539ff9/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
1 change: 1 addition & 0 deletions pkg/connector/dbmeta.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type PortalMetadata struct {
type MessageMetadata struct {
EditRevisionID groupme.ID `json:"edit_revision_id,omitempty"`
PollID string `json:"poll_id,omitempty"`
PollAnswerIDs map[string]string `json:"poll_answer_ids,omitempty"`
NativeContent *groupmeext.MessageContent `json:"native_content,omitempty"`
EditedAt groupme.Timestamp `json:"edited_at,omitempty"`
}
Expand Down
60 changes: 55 additions & 5 deletions pkg/connector/handlepoll.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,51 @@ import (

var _ bridgev2.PollHandlingNetworkAPI = (*GMClient)(nil)

// Only voting on received polls is implemented. The interface also requires a
// creation handler, and the shared poll capability therefore stays partial.
func (gc *GMClient) HandleMatrixPollStart(context.Context, *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) {
return nil, permanentFailure(event.MessageStatusUnsupported, "GroupMe polls can only be created in the GroupMe app")
// GroupMe polls require an expiration, even though Matrix polls stay open until ended.
func (gc *GMClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) {
portalType, groupID := ParsePortalID(msg.Portal.ID)
if portalType != PortalTypeGroup {
return nil, permanentFailure(event.MessageStatusUnsupported, "GroupMe polls can only be created in groups")
}
start := msg.Content.PollStart
request := &groupme.CreatePollRequest{
Subject: start.Question.GetText(),
Options: make([]groupme.PollOption, len(start.Answers)),
Expiration: groupme.Timestamp(time.Now().Add(14*24*time.Hour - time.Minute).Unix()),
Visibility: "public",
Type: "single",
}
if start.MaxSelections > 1 {
request.Type = "multi"
}
for i, answer := range start.Answers {
if answer.ID == "" || slices.ContainsFunc(start.Answers[:i], func(previous event.PollOption) bool { return previous.ID == answer.ID }) {
return nil, permanentFailure(event.MessageStatusUnsupported, "Poll answer IDs must be non-empty and unique")
}
request.Options[i].Title = answer.GetText()
}
poll, messageID, err := gc.Client.CreatePoll(ctx, string(groupID), request)
if err != nil {
status := wrapGroupMeError(err, "failed to create poll")
var meta *groupme.Meta
if status.Status == event.MessageStatusFail && errors.As(err, &meta) && len(meta.Errors) > 0 {
status = status.WithMessage(strings.Join(meta.Errors, "; "))
}
return nil, status
}
Comment thread
indent[bot] marked this conversation as resolved.
Resolved
answerIDs := make(map[string]string, len(start.Answers))
for i := range min(len(start.Answers), len(poll.Options)) {
answerIDs[start.Answers[i].ID] = poll.Options[i].ID
}
dbMessage := &database.Message{
ID: MakeMessageID(messageID),
SenderID: MakeUserID(groupme.ID(gc.Meta.GMID)),
Metadata: &MessageMetadata{PollID: poll.ID, PollAnswerIDs: answerIDs},
}
if poll.CreatedAt > 0 {
dbMessage.Timestamp = poll.CreatedAt.ToTime()
}
return &bridgev2.MatrixMessageResponse{DB: dbMessage}, nil
}

func (gc *GMClient) HandleMatrixPollVote(ctx context.Context, msg *bridgev2.MatrixPollVote) (*bridgev2.MatrixMessageResponse, error) {
Expand All @@ -39,7 +80,16 @@ func (gc *GMClient) HandleMatrixPollVote(ctx context.Context, msg *bridgev2.Matr
if poll.Status != "active" || (poll.Expiration > 0 && !poll.Expiration.ToTime().After(time.Now())) {
return nil, pollVoteError("This GroupMe poll has ended")
}
answers := msg.Content.Response.Answers
answers := slices.Clone(msg.Content.Response.Answers)
if meta.PollAnswerIDs != nil {
for i, answer := range answers {
optionID, ok := meta.PollAnswerIDs[answer]
if !ok {
return nil, pollVoteError("Unknown GroupMe poll answer")
}
answers[i] = optionID
}
}
if len(answers) == 0 {
return nil, pollVoteError("GroupMe vote withdrawal is not supported")
}
Expand Down
98 changes: 98 additions & 0 deletions pkg/connector/handlepoll_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"encoding/json"
"io"
"net/http"
"slices"
"strings"
"testing"
"time"

"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
Expand Down Expand Up @@ -106,3 +108,99 @@ func TestPollVoteRemoteResponses(t *testing.T) {
})
}
}

func TestCreatedPollAnswerIDs(t *testing.T) {
for _, tc := range []struct {
name, kind string
ids, votes, nativeVotes []string
}{
{"single", "single", []string{"1", "2"}, []string{"2"}, []string{"9"}},
{"multi", "multi", []string{"a7f", "q2x", "k9z"}, []string{"a7f", "k9z"}, []string{"7", "11"}},
} {
t.Run(tc.name, func(t *testing.T) {
start := event.PollStart{Question: event.MSC1767Message{Text: "Choose"}, MaxSelections: 1}
native := groupme.PollDetail{ID: "poll-1", ConversationID: "88", CreatedAt: 1793204604, Expiration: 4000000000, Status: "active", Type: tc.kind}
for i, id := range tc.ids {
start.Answers = append(start.Answers, event.PollOption{ID: id, MSC1767Message: event.MSC1767Message{Text: id}})
native.Options = append(native.Options, groupme.PollOption{ID: []string{"7", "9", "11"}[i], Title: id})
}
if tc.kind == "multi" {
start.MaxSelections = 3
}
calls := 0
mockGroupMe(t, func(r *http.Request) (int, string) {
calls++
switch calls {
case 1:
var request groupme.CreatePollRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
lifetime := time.Until(request.Expiration.ToTime())
if r.Method != "POST" || r.URL.Path != "/v3/poll/88" || request.Subject != "Choose" || request.Type != tc.kind || request.Visibility != "public" || lifetime > 14*24*time.Hour-time.Minute || lifetime < 14*24*time.Hour-2*time.Minute {
t.Fatalf("unexpected create request: %+v", request)
}
for i, option := range request.Options {
if option.ID != "" || option.Title != tc.ids[i] {
t.Fatalf("unexpected option: %+v", option)
}
}
case 2:
if r.Method != "GET" || r.URL.Path != "/v3/poll/88/poll-1" {
t.Fatalf("unexpected fetch: %s %s", r.Method, r.URL.Path)
}
case 3:
if r.Method != "POST" {
t.Fatal(r.Method)
}
if tc.kind == "single" {
if r.URL.Path != "/v3/poll/88/poll-1/9" {
t.Fatal(r.URL.Path)
}
} else {
var body struct {
Votes []string `json:"votes"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if r.URL.Path != "/v3/poll/88/poll-1" || !slices.Equal(body.Votes, tc.nativeVotes) {
t.Fatalf("wrong vote: %s %+v", r.URL.Path, body)
}
}
default:
t.Fatal("unexpected request")
}
response, _ := json.Marshal(map[string]any{"response": map[string]any{"poll": map[string]any{"data": native, "user_votes": tc.nativeVotes}, "message": map[string]any{"id": "announcement-1"}}})
return 201, string(response)
})
client := dmTestClient()
base := testMatrixText("group:88")
created, err := client.HandleMatrixPollStart(context.Background(), &bridgev2.MatrixPollStart{MatrixMessage: *base, Content: &event.PollStartEventContent{PollStart: start}})
if err != nil {
t.Fatal(err)
}
if created.DB.ID != "announcement-1" {
t.Fatalf("wrong echo identity: %s", created.DB.ID)
}
persisted, err := json.Marshal(created.DB.Metadata)
if err != nil {
t.Fatal(err)
}
var metadata MessageMetadata
if err = json.Unmarshal(persisted, &metadata); err != nil {
t.Fatal(err)
}
if metadata.PollID != "poll-1" || len(metadata.PollAnswerIDs) != len(tc.ids) {
t.Fatalf("incorrect persisted mapping: %s", persisted)
}
created.DB.Room = base.Portal.PortalKey
created.DB.Metadata = &metadata
vote := &bridgev2.MatrixPollVote{MatrixMessage: *base, VoteTo: created.DB, Content: &event.PollResponseEventContent{Response: event.PollResponse{Answers: tc.votes}}}
if _, err = client.HandleMatrixPollVote(context.Background(), vote); err != nil {
t.Fatal(err)
}
if calls != 3 || !slices.Equal(vote.Content.Response.Answers, tc.votes) {
t.Fatalf("calls=%d, mutated Matrix answers: %v", calls, vote.Content.Response.Answers)
}
})
}
}
Loading