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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions conv_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
59 changes: 59 additions & 0 deletions conv_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
84 changes: 84 additions & 0 deletions conv_test.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 9 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
22 changes: 12 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand Down
29 changes: 29 additions & 0 deletions types_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading