diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e893f2b..99f6e80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,10 @@ jobs: test: strategy: fail-fast: false - # connect v1.19.2 / golang.org/x/net v0.48 set the floor at Go 1.24. + # connect v1.20.0 sets the floor at Go 1.25. Support the current and + # previous Go minor releases per the library compatibility policy. matrix: - go: ["1.24", "1.25", "1.26"] + go: ["1.25", "1.26"] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 3a74983..cc3d5ce 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ messages, and polls. go get github.com/photon-hq/advanced-imessage-go ``` -Requires **Go 1.24+** (the `connectrpc.com/connect` runtime sets the floor). +Requires **Go 1.25+** (the `connectrpc.com/connect` runtime sets the floor). ## Quick start diff --git a/conv_common.go b/conv_common.go index 727b1c3..603a6e0 100644 --- a/conv_common.go +++ b/conv_common.go @@ -35,6 +35,10 @@ func ptrBool(v bool) *bool { return &v } func ptrFloat(v float64) *float64 { return &v } +func ptrString(v string) *string { return &v } + +func ptrInt64(v int64) *int64 { return &v } + func handleFromProto(pb *imessagev1.SingleServiceAddressInfo) Handle { if pb == nil { return Handle{} diff --git a/conv_message.go b/conv_message.go index 83ef191..979bf7c 100644 --- a/conv_message.go +++ b/conv_message.go @@ -106,9 +106,68 @@ func messageContentFromProto(pb *imessagev1.MessageContent) MessageContent { for _, mn := range pb.GetMentions() { c.Mentions = append(c.Mentions, mentionFromProto(mn)) } + if pb.HasMiniApp() { + c.MiniApp = miniAppContentFromProto(pb.GetMiniApp()) + } return c } +func miniAppContentFromProto(pb *imessagev1.MiniAppContent) *MiniAppContent { + if pb == nil { + return nil + } + content := &MiniAppContent{ + TeamID: pb.GetTeamId(), + ExtensionBundleID: pb.GetExtensionBundleId(), + Live: pb.GetLive(), + } + if pb.HasAppName() { + content.AppName = ptrString(pb.GetAppName()) + } + if pb.HasUrl() { + content.URL = ptrString(pb.GetUrl()) + } + if pb.HasSessionId() { + content.SessionID = ptrString(pb.GetSessionId()) + } + if pb.HasAppStoreId() { + content.AppStoreID = ptrInt64(pb.GetAppStoreId()) + } + if pb.HasLayout() { + content.Layout = miniAppLayoutInfoFromProto(pb.GetLayout()) + } + return content +} + +func miniAppLayoutInfoFromProto(pb *imessagev1.MiniAppLayoutInfo) *MiniAppLayoutInfo { + if pb == nil { + return nil + } + layout := &MiniAppLayoutInfo{} + if pb.HasCaption() { + layout.Caption = ptrString(pb.GetCaption()) + } + if pb.HasSubcaption() { + layout.Subcaption = ptrString(pb.GetSubcaption()) + } + if pb.HasTrailingCaption() { + layout.TrailingCaption = ptrString(pb.GetTrailingCaption()) + } + if pb.HasTrailingSubcaption() { + layout.TrailingSubcaption = ptrString(pb.GetTrailingSubcaption()) + } + if pb.HasImageTitle() { + layout.ImageTitle = ptrString(pb.GetImageTitle()) + } + if pb.HasImageSubtitle() { + layout.ImageSubtitle = ptrString(pb.GetImageSubtitle()) + } + if pb.HasSummary() { + layout.Summary = ptrString(pb.GetSummary()) + } + return layout +} + func textFormatFromProto(pb *imessagev1.TextFormat) TextFormat { return TextFormat{ Type: pb.GetType(), diff --git a/conv_test.go b/conv_test.go index 2732a9a..17be0a1 100644 --- a/conv_test.go +++ b/conv_test.go @@ -1,10 +1,12 @@ package imessage import ( + "encoding/base64" "testing" "time" imessagev1 "buf.build/gen/go/photon-hq/imessage/protocolbuffers/go/photon/imessage/v1" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -87,6 +89,88 @@ func TestMessageFromProtoMapsFields(t *testing.T) { if got.ChatActionType != nil { t.Errorf("ChatActionType = %v, want nil", got.ChatActionType) } + if got.Content.MiniApp != nil { + t.Errorf("Content.MiniApp = %+v, want nil", got.Content.MiniApp) + } +} + +func TestCatchUpEventFromProtoMapsMiniApp(t *testing.T) { + // Fixed v11.3.0 CatchUpEventsResponse frame containing + // Message.content.mini_app. This catches field-number drift between the + // server schema, generated client, and the public Go mapping. + wire, err := base64.StdEncoding.DecodeString("CMsRUt8CChdpTWVzc2FnZTstOysxNTU1MTIzNDU2NxIGCIfH8dMGUrsCCrgCCg1taW5pLWFwcC1ndWlkEvMBOvABCgpQOFhUNjIzMlNMEiZjb2Rlcy5waG90b24uRXhhbXBsZS5NZXNzYWdlc0V4dGVuc2lvbhoLRXhhbXBsZSBBcHAiHmh0dHBzOi8vZXhhbXBsZS5jb20vY2FyZD9pZD00MiokOEQ4OTgwMzQtNDA3Qi00RkY1LTkxRTgtOURDMTg5MTFEQ0E5MNKF2MwEOAFCXwoHQ2FwdGlvbhIKU3ViY2FwdGlvbhoIVHJhaWxpbmciD1RyYWlsaW5nIGRldGFpbCoLSW1hZ2UgdGl0bGUyDkltYWdlIHN1YnRpdGxlOhBGYWxsYmFjayBzdW1tYXJ5UgYIh8fx0waiAQ4KDCsxNTU1MTIzNDU2N+IFF2lNZXNzYWdlOy07KzE1NTUxMjM0NTY3") + if err != nil { + t.Fatalf("decode fixture: %v", err) + } + + var frame imessagev1.CatchUpEventsResponse + if err := proto.Unmarshal(wire, &frame); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + event, ok, err := catchUpEventFromProto(&frame) + if err != nil { + t.Fatalf("map catch-up event: %v", err) + } + if !ok { + t.Fatal("map catch-up event: ok = false") + } + received, ok := event.(MessageReceived) + if !ok { + t.Fatalf("event type = %T, want MessageReceived", event) + } + + miniApp := received.Message.Content.MiniApp + if miniApp == nil { + t.Fatal("Message.Content.MiniApp = nil") + } + if miniApp.TeamID != "P8XT6232SL" || miniApp.ExtensionBundleID != "codes.photon.Example.MessagesExtension" { + t.Fatalf("MiniApp identity = %+v", miniApp) + } + assertStringPtr(t, "MiniApp.AppName", miniApp.AppName, "Example App") + assertStringPtr(t, "MiniApp.URL", miniApp.URL, "https://example.com/card?id=42") + assertStringPtr(t, "MiniApp.SessionID", miniApp.SessionID, "8D898034-407B-4FF5-91E8-9DC18911DCA9") + if miniApp.AppStoreID == nil || *miniApp.AppStoreID != 1234567890 { + t.Fatalf("MiniApp.AppStoreID = %v", miniApp.AppStoreID) + } + if !miniApp.Live { + t.Error("MiniApp.Live = false, want true") + } + if miniApp.Layout == nil { + t.Fatal("MiniApp.Layout = nil") + } + assertStringPtr(t, "MiniApp.Layout.Caption", miniApp.Layout.Caption, "Caption") + assertStringPtr(t, "MiniApp.Layout.Subcaption", miniApp.Layout.Subcaption, "Subcaption") + assertStringPtr(t, "MiniApp.Layout.TrailingCaption", miniApp.Layout.TrailingCaption, "Trailing") + assertStringPtr(t, "MiniApp.Layout.TrailingSubcaption", miniApp.Layout.TrailingSubcaption, "Trailing detail") + assertStringPtr(t, "MiniApp.Layout.ImageTitle", miniApp.Layout.ImageTitle, "Image title") + assertStringPtr(t, "MiniApp.Layout.ImageSubtitle", miniApp.Layout.ImageSubtitle, "Image subtitle") + assertStringPtr(t, "MiniApp.Layout.Summary", miniApp.Layout.Summary, "Fallback summary") +} + +func TestMessageContentFromProtoPreservesIdentityOnlyMiniApp(t *testing.T) { + miniApp := &imessagev1.MiniAppContent{} + miniApp.SetTeamId("P8XT6232SL") + miniApp.SetExtensionBundleId("codes.photon.Example.MessagesExtension") + content := &imessagev1.MessageContent{} + content.SetMiniApp(miniApp) + + got := messageContentFromProto(content).MiniApp + if got == nil { + t.Fatal("MessageContent.MiniApp = nil") + } + if got.TeamID != "P8XT6232SL" || got.ExtensionBundleID != "codes.photon.Example.MessagesExtension" { + t.Fatalf("MiniApp identity = %+v", got) + } + if got.AppName != nil || got.URL != nil || got.SessionID != nil || got.AppStoreID != nil || got.Layout != nil { + t.Fatalf("MiniApp optional fields = %+v, want nil", got) + } +} + +func assertStringPtr(t *testing.T, name string, got *string, want string) { + t.Helper() + if got == nil || *got != want { + t.Fatalf("%s = %v, want %q", name, got, want) + } } func TestReactionKindRoundTrip(t *testing.T) { diff --git a/go.mod b/go.mod index bd920e7..cabbb88 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,18 @@ module github.com/photon-hq/advanced-imessage-go -go 1.24.0 +go 1.25.0 require ( - buf.build/gen/go/photon-hq/imessage/connectrpc/go v1.19.2-20260620033527-059c960d2c7a.1 - buf.build/gen/go/photon-hq/imessage/protocolbuffers/go v1.36.11-20260620033527-059c960d2c7a.1 - connectrpc.com/connect v1.19.2 + buf.build/gen/go/photon-hq/imessage/connectrpc/go v1.20.0-20260812120326-36bf2dfbbba4.1 + buf.build/gen/go/photon-hq/imessage/protocolbuffers/go v1.36.11-20260812120326-36bf2dfbbba4.1 + connectrpc.com/connect v1.20.0 github.com/google/uuid v1.6.0 go.uber.org/goleak v1.3.0 - golang.org/x/net v0.48.0 + golang.org/x/net v0.55.0 google.golang.org/protobuf v1.36.11 ) -require golang.org/x/text v0.32.0 // indirect +require ( + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect +) diff --git a/go.sum b/go.sum index 33d84f1..eb11f59 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -buf.build/gen/go/photon-hq/imessage/connectrpc/go v1.19.2-20260620033527-059c960d2c7a.1 h1:XCRilcKLD0qO2LbQ2NdRGZyjyDCu2miAUCMCtOJnT+8= -buf.build/gen/go/photon-hq/imessage/connectrpc/go v1.19.2-20260620033527-059c960d2c7a.1/go.mod h1:aSGFz8iai0movaCdC235Da3shJYsf64GS02RzbYNN30= -buf.build/gen/go/photon-hq/imessage/protocolbuffers/go v1.36.11-20260620033527-059c960d2c7a.1 h1:eP6qsqb5OtTrNy6mXokzBYLMbAks2u8jhjOxBAEHAnI= -buf.build/gen/go/photon-hq/imessage/protocolbuffers/go v1.36.11-20260620033527-059c960d2c7a.1/go.mod h1:VhtcnPoYjdvjVrXttOlKzQG8DXOby8GqH1N2BhYbi6c= -connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= -connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +buf.build/gen/go/photon-hq/imessage/connectrpc/go v1.20.0-20260812120326-36bf2dfbbba4.1 h1:TounNUmwpMOKx/4C43pp0A3HAq5e7TJdS8fIm+bznx0= +buf.build/gen/go/photon-hq/imessage/connectrpc/go v1.20.0-20260812120326-36bf2dfbbba4.1/go.mod h1:wWstLTsqQywjRFtlH/iefePDh8prYyb3dsYQpJZe/Gc= +buf.build/gen/go/photon-hq/imessage/protocolbuffers/go v1.36.11-20260812120326-36bf2dfbbba4.1 h1:i/9uzR/RtYx56IJj1Anvjj/HgPTTr/mlpqFsgMf5G0w= +buf.build/gen/go/photon-hq/imessage/protocolbuffers/go v1.36.11-20260812120326-36bf2dfbbba4.1/go.mod h1:VhtcnPoYjdvjVrXttOlKzQG8DXOby8GqH1N2BhYbi6c= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -16,10 +16,12 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 h1:dWeMvEJ3JhYgqSCAHUZZJgMUyfniiiCvDc72x5EqJP0= +google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/types_message.go b/types_message.go index 660a31d..46fd657 100644 --- a/types_message.go +++ b/types_message.go @@ -161,6 +161,35 @@ type MessageContent struct { Mentions []MessageMention BalloonBundleID string ExpressiveSendStyleID string + // MiniApp contains the public fields decoded from an iMessage app-extension + // balloon. It is nil for messages without mini-app content. + MiniApp *MiniAppContent +} + +// MiniAppContent is the public, decoded portion of an inbound iMessage +// app-extension balloon. Apple's opaque payload archive is intentionally not +// exposed by the API. +type MiniAppContent struct { + TeamID string + ExtensionBundleID string + AppName *string + URL *string + SessionID *string + AppStoreID *int64 + Live bool + Layout *MiniAppLayoutInfo +} + +// MiniAppLayoutInfo contains the visible fields decoded from an inbound +// mini-app card. Card media remains a separate message attachment. +type MiniAppLayoutInfo struct { + Caption *string + Subcaption *string + TrailingCaption *string + TrailingSubcaption *string + ImageTitle *string + ImageSubtitle *string + Summary *string } // MessageReaction is a tapback's kind and optional emoji.