diff --git a/go.mod b/go.mod index 7fed247..04de10b 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.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 diff --git a/go.sum b/go.sum index 2c0462d..4e57e71 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.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= diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index 2f46abf..f6bb838 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -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"` } diff --git a/pkg/connector/handlepoll.go b/pkg/connector/handlepoll.go index c542e3a..cbe0093 100644 --- a/pkg/connector/handlepoll.go +++ b/pkg/connector/handlepoll.go @@ -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 + } + 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) { @@ -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") } diff --git a/pkg/connector/handlepoll_test.go b/pkg/connector/handlepoll_test.go index bd299a4..fa3d23b 100644 --- a/pkg/connector/handlepoll_test.go +++ b/pkg/connector/handlepoll_test.go @@ -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" @@ -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) + } + }) + } +}