feat_: Unfurl transaction deep link

This commit is contained in:
Emil Sawicki 2024-10-11 13:53:32 +02:00
parent 4c889399eb
commit 0c7f9f34a0
5 changed files with 205 additions and 14 deletions

View File

@ -54,6 +54,15 @@ type StatusCommunityLinkPreview struct {
Banner LinkPreviewThumbnail `json:"banner,omitempty"` Banner LinkPreviewThumbnail `json:"banner,omitempty"`
} }
type StatusTransactionLinkPreview struct {
TxType int `json:"txType"`
Amount string `json:"amount"`
Asset string `json:"asset"`
ToAsset string `json:"toAsset"`
Address string `json:"address"`
ChainID int `json:"chainId"`
}
type StatusCommunityChannelLinkPreview struct { type StatusCommunityChannelLinkPreview struct {
ChannelUUID string `json:"channelUuid"` ChannelUUID string `json:"channelUuid"`
Emoji string `json:"emoji"` Emoji string `json:"emoji"`
@ -64,10 +73,11 @@ type StatusCommunityChannelLinkPreview struct {
} }
type StatusLinkPreview struct { type StatusLinkPreview struct {
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Contact *StatusContactLinkPreview `json:"contact,omitempty"` Contact *StatusContactLinkPreview `json:"contact,omitempty"`
Community *StatusCommunityLinkPreview `json:"community,omitempty"` Community *StatusCommunityLinkPreview `json:"community,omitempty"`
Channel *StatusCommunityChannelLinkPreview `json:"channel,omitempty"` Channel *StatusCommunityChannelLinkPreview `json:"channel,omitempty"`
Transaction *StatusTransactionLinkPreview `json:"transaction,omitempty"`
} }
func (thumbnail *LinkPreviewThumbnail) IsEmpty() bool { func (thumbnail *LinkPreviewThumbnail) IsEmpty() bool {
@ -161,17 +171,23 @@ func (preview *StatusLinkPreview) validateForProto() error {
} }
// At least and only one of Contact/Community/Channel should be present in the preview // At least and only one of Contact/Community/Channel should be present in the preview
if preview.Contact != nil && preview.Community != nil { var linkTypes []string
return fmt.Errorf("both contact and community are set at the same time") if preview.Contact != nil {
linkTypes = append(linkTypes, "Contact")
} }
if preview.Community != nil && preview.Channel != nil { if preview.Community != nil {
return fmt.Errorf("both community and channel are set at the same time") linkTypes = append(linkTypes, "Community")
} }
if preview.Channel != nil && preview.Contact != nil { if preview.Channel != nil {
return fmt.Errorf("both contact and channel are set at the same time") linkTypes = append(linkTypes, "Channel")
} }
if preview.Contact == nil && preview.Community == nil && preview.Channel == nil { if preview.Transaction != nil {
return fmt.Errorf("none of contact/community/channel are set") linkTypes = append(linkTypes, "Transaction")
}
if len(linkTypes) > 1 {
return fmt.Errorf("multiple components set at the same time: %v", linkTypes)
} else if len(linkTypes) == 0 {
return fmt.Errorf("none of contact/community/channel/transaction are set")
} }
if preview.Contact != nil { if preview.Contact != nil {
@ -200,6 +216,14 @@ func (preview *StatusLinkPreview) validateForProto() error {
} }
return nil return nil
} }
if preview.Transaction != nil {
if preview.Transaction.Asset == "" && preview.Transaction.Amount == "" && preview.Transaction.Address == "" && preview.Transaction.ToAsset == "" {
return fmt.Errorf("transaction fields are empty")
}
return nil
}
return nil return nil
} }
@ -444,6 +468,19 @@ func (m *Message) ConvertStatusLinkPreviewsToProto() (*protobuf.UnfurledStatusLi
} }
if preview.Transaction != nil {
ul.Payload = &protobuf.UnfurledStatusLink_Transaction{
Transaction: &protobuf.UnfurledStatusTransactionLink{
TxType: uint32(preview.Transaction.TxType),
Amount: preview.Transaction.Amount,
Asset: preview.Transaction.Asset,
ToAsset: preview.Transaction.ToAsset,
Address: preview.Transaction.Address,
ChainId: uint32(preview.Transaction.ChainID),
},
}
}
unfurledLinks = append(unfurledLinks, ul) unfurledLinks = append(unfurledLinks, ul)
} }
@ -508,6 +545,17 @@ func (m *Message) ConvertFromProtoToStatusLinkPreviews(makeMediaServerURL func(m
} }
} }
if c := link.GetTransaction(); c != nil {
lp.Transaction = &StatusTransactionLinkPreview{
TxType: int(c.TxType),
Amount: c.Amount,
Asset: c.Asset,
ToAsset: c.ToAsset,
Address: c.Address,
ChainID: int(c.ChainId),
}
}
previews = append(previews, lp) previews = append(previews, lp)
} }

View File

@ -338,6 +338,15 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) {
}, },
} }
transaction := &StatusTransactionLinkPreview{
TxType: 2,
Amount: "Amount_22",
Asset: "Asset_23",
ToAsset: "ToAsset_24",
Address: "Address_25",
ChainID: 26,
}
message := Message{ message := Message{
StatusLinkPreviews: []StatusLinkPreview{ StatusLinkPreviews: []StatusLinkPreview{
{ {
@ -352,6 +361,10 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) {
URL: "https://status.app/cc/", URL: "https://status.app/cc/",
Channel: channel, Channel: channel,
}, },
{
URL: "https://status.app/tx/",
Transaction: transaction,
},
}, },
} }
@ -360,7 +373,7 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) {
unfurledLinks, err := message.ConvertStatusLinkPreviewsToProto() unfurledLinks, err := message.ConvertStatusLinkPreviewsToProto()
require.NoError(t, err) require.NoError(t, err)
require.Len(t, unfurledLinks.UnfurledStatusLinks, 3) require.Len(t, unfurledLinks.UnfurledStatusLinks, 4)
// Contact link // Contact link
@ -369,6 +382,7 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) {
require.NotNil(t, l1.GetContact()) require.NotNil(t, l1.GetContact())
require.Nil(t, l1.GetCommunity()) require.Nil(t, l1.GetCommunity())
require.Nil(t, l1.GetChannel()) require.Nil(t, l1.GetChannel())
require.Nil(t, l1.GetTransaction())
c1 := l1.GetContact() c1 := l1.GetContact()
require.Equal(t, compressedContactPublicKey, c1.PublicKey) require.Equal(t, compressedContactPublicKey, c1.PublicKey)
require.Equal(t, contact.DisplayName, c1.DisplayName) require.Equal(t, contact.DisplayName, c1.DisplayName)
@ -385,6 +399,7 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) {
require.NotNil(t, l2.GetCommunity()) require.NotNil(t, l2.GetCommunity())
require.Nil(t, l2.GetContact()) require.Nil(t, l2.GetContact())
require.Nil(t, l2.GetChannel()) require.Nil(t, l2.GetChannel())
require.Nil(t, l2.GetTransaction())
c2 := l2.GetCommunity() c2 := l2.GetCommunity()
require.Equal(t, compressedCommunityPublicKey, c2.CommunityId) require.Equal(t, compressedCommunityPublicKey, c2.CommunityId)
require.Equal(t, community.DisplayName, c2.DisplayName) require.Equal(t, community.DisplayName, c2.DisplayName)
@ -407,6 +422,7 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) {
require.NotNil(t, l3.GetChannel()) require.NotNil(t, l3.GetChannel())
require.Nil(t, l3.GetContact()) require.Nil(t, l3.GetContact())
require.Nil(t, l3.GetCommunity()) require.Nil(t, l3.GetCommunity())
require.Nil(t, l3.GetTransaction())
c3 := l3.GetChannel() c3 := l3.GetChannel()
require.Equal(t, channel.ChannelUUID, c3.ChannelUuid) require.Equal(t, channel.ChannelUUID, c3.ChannelUuid)
@ -430,6 +446,23 @@ func TestConvertStatusLinkPreviewsToProto(t *testing.T) {
require.Equal(t, uint32(channel.Community.Banner.Height), c3.Community.Banner.Height) require.Equal(t, uint32(channel.Community.Banner.Height), c3.Community.Banner.Height)
require.Equal(t, expectedThumbnailPayload, c3.Community.Banner.Payload) require.Equal(t, expectedThumbnailPayload, c3.Community.Banner.Payload)
// Transaction link
l4 := unfurledLinks.UnfurledStatusLinks[3]
require.Equal(t, "https://status.app/tx/", l4.Url)
require.NotNil(t, l4.GetTransaction())
require.Nil(t, l4.GetContact())
require.Nil(t, l4.GetCommunity())
require.Nil(t, l4.GetChannel())
t4 := l4.GetTransaction()
require.Equal(t, uint32(transaction.TxType), t4.TxType)
require.Equal(t, transaction.Amount, t4.Amount)
require.Equal(t, transaction.Asset, t4.Asset)
require.Equal(t, transaction.ToAsset, t4.ToAsset)
require.Equal(t, transaction.Address, t4.Address)
require.Equal(t, uint32(transaction.ChainID), t4.ChainId)
// Test any invalid link preview causes an early return. // Test any invalid link preview causes an early return.
invalidContactPreview := contact invalidContactPreview := contact
invalidContactPreview.PublicKey = "" invalidContactPreview.PublicKey = ""
@ -516,6 +549,15 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) {
}, },
} }
transaction := &protobuf.UnfurledStatusTransactionLink{
TxType: 1,
Amount: "100",
Asset: "ETH",
ToAsset: "DAI",
Address: "0x1234567890",
ChainId: 11,
}
msg := Message{ msg := Message{
ID: "42", ID: "42",
ChatMessage: &protobuf.ChatMessage{ ChatMessage: &protobuf.ChatMessage{
@ -539,6 +581,12 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) {
Channel: channel, Channel: channel,
}, },
}, },
{
Url: "https://status.app/tx/",
Payload: &protobuf.UnfurledStatusLink_Transaction{
Transaction: transaction,
},
},
}, },
}, },
}, },
@ -549,7 +597,7 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) {
} }
previews := msg.ConvertFromProtoToStatusLinkPreviews(urlMaker) previews := msg.ConvertFromProtoToStatusLinkPreviews(urlMaker)
require.Len(t, previews, 3) require.Len(t, previews, 4)
// Contact preview // Contact preview
@ -558,6 +606,7 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) {
require.NotNil(t, p1.Contact) require.NotNil(t, p1.Contact)
require.Nil(t, p1.Community) require.Nil(t, p1.Community)
require.Nil(t, p1.Channel) require.Nil(t, p1.Channel)
require.Nil(t, p1.Transaction)
c1 := p1.Contact c1 := p1.Contact
require.NotNil(t, c1) require.NotNil(t, c1)
@ -577,6 +626,7 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) {
require.NotNil(t, p2.Community) require.NotNil(t, p2.Community)
require.Nil(t, p2.Contact) require.Nil(t, p2.Contact)
require.Nil(t, p2.Channel) require.Nil(t, p2.Channel)
require.Nil(t, p2.Transaction)
c2 := p2.Community c2 := p2.Community
require.Equal(t, communityID, c2.CommunityID) require.Equal(t, communityID, c2.CommunityID)
@ -602,6 +652,7 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) {
require.NotNil(t, p3.Channel) require.NotNil(t, p3.Channel)
require.Nil(t, p3.Contact) require.Nil(t, p3.Contact)
require.Nil(t, p3.Community) require.Nil(t, p3.Community)
require.Nil(t, p3.Transaction)
c3 := previews[2].Channel c3 := previews[2].Channel
require.Equal(t, channel.ChannelUuid, c3.ChannelUUID) require.Equal(t, channel.ChannelUuid, c3.ChannelUUID)
@ -627,6 +678,20 @@ func TestConvertFromProtoToStatusLinkPreviews(t *testing.T) {
require.Equal(t, "", c3.Community.Banner.DataURI) require.Equal(t, "", c3.Community.Banner.DataURI)
require.Equal(t, "https://localhost:6666/42-https://status.app/cc/-community-channel-banner", c3.Community.Banner.URL) require.Equal(t, "https://localhost:6666/42-https://status.app/cc/-community-channel-banner", c3.Community.Banner.URL)
p4 := previews[3]
require.Equal(t, "https://status.app/tx/", p4.URL)
require.NotNil(t, p4.Transaction)
require.Nil(t, p4.Contact)
require.Nil(t, p4.Community)
require.Nil(t, p4.Channel)
t1 := p4.Transaction
require.Equal(t, transaction.TxType, uint32(t1.TxType))
require.Equal(t, transaction.Amount, t1.Amount)
require.Equal(t, transaction.Asset, t1.Asset)
require.Equal(t, transaction.ToAsset, t1.ToAsset)
require.Equal(t, transaction.Address, t1.Address)
require.Equal(t, transaction.ChainId, uint32(t1.ChainID))
} }
func assertMarshalAndUnmarshalJSON[T any](t *testing.T, obj *T, msgAndArgs ...any) { func assertMarshalAndUnmarshalJSON[T any](t *testing.T, obj *T, msgAndArgs ...any) {

View File

@ -83,6 +83,17 @@ func (u *StatusUnfurler) buildContactData(publicKey string) (*common.StatusConta
return c, nil return c, nil
} }
func (u *StatusUnfurler) buildTransactionData(urlData *TransactionURLData) (*common.StatusTransactionLinkPreview, error) {
return &common.StatusTransactionLinkPreview{
TxType: urlData.TxType,
Asset: urlData.Asset,
Amount: urlData.Amount,
Address: urlData.Address,
ChainID: urlData.ChainID,
ToAsset: urlData.ToAsset,
}, nil
}
func (u *StatusUnfurler) buildCommunityData(communityID string, shard *shard.Shard) (*communities.Community, *common.StatusCommunityLinkPreview, error) { func (u *StatusUnfurler) buildCommunityData(communityID string, shard *shard.Shard) (*communities.Community, *common.StatusCommunityLinkPreview, error) {
// This automatically checks the database // This automatically checks the database
community, err := u.m.FetchCommunity(&FetchCommunityRequest{ community, err := u.m.FetchCommunity(&FetchCommunityRequest{
@ -172,5 +183,13 @@ func (u *StatusUnfurler) Unfurl() (*common.StatusLinkPreview, error) {
return preview, nil return preview, nil
} }
if resp.Transaction != nil {
preview.Transaction, err = u.buildTransactionData(resp.Transaction)
if err != nil {
return nil, fmt.Errorf("error when building transaction data: %w", err)
}
return preview, nil
}
return nil, fmt.Errorf("shared url does not contain contact, community or channel data") return nil, fmt.Errorf("shared url does not contain contact, community or channel data")
} }

View File

@ -171,12 +171,22 @@ message UnfurledStatusChannelLink {
UnfurledStatusCommunityLink community = 6; UnfurledStatusCommunityLink community = 6;
} }
message UnfurledStatusTransactionLink {
uint32 txType = 1;
string address = 2;
string amount = 3;
string asset = 4;
uint32 chainId = 5;
string toAsset = 6;
}
message UnfurledStatusLink { message UnfurledStatusLink {
string url = 1; string url = 1;
oneof payload { oneof payload {
UnfurledStatusContactLink contact = 2; UnfurledStatusContactLink contact = 2;
UnfurledStatusCommunityLink community = 3; UnfurledStatusCommunityLink community = 3;
UnfurledStatusChannelLink channel = 4; UnfurledStatusChannelLink channel = 4;
UnfurledStatusTransactionLink transaction = 5;
} }
} }

View File

@ -265,6 +265,15 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() {
}, },
} }
transaction := &protobuf.UnfurledStatusTransactionLink{
TxType: 2,
Asset: "Asset_1",
Amount: "Amount_1",
Address: "Address_1",
ToAsset: "ToAsset_1",
ChainId: 11,
}
unfurledContact := &protobuf.UnfurledStatusLink{ unfurledContact := &protobuf.UnfurledStatusLink{
Url: "https://status.app/u/", Url: "https://status.app/u/",
Payload: &protobuf.UnfurledStatusLink_Contact{ Payload: &protobuf.UnfurledStatusLink_Contact{
@ -293,12 +302,20 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() {
}, },
} }
unfurledTransaction := &protobuf.UnfurledStatusLink{
Url: "https://status.app/tx/",
Payload: &protobuf.UnfurledStatusLink_Transaction{
Transaction: transaction,
},
}
const ( const (
messageIDContactOnly = "1" messageIDContactOnly = "1"
messageIDCommunityOnly = "2" messageIDCommunityOnly = "2"
messageIDChannelOnly = "3" messageIDChannelOnly = "3"
messageIDAllLinks = "4" messageIDAllLinks = "4"
messageIDUnsupportedImage = "5" messageIDUnsupportedImage = "5"
messageIDTransactionOnly = "6"
) )
s.saveUserMessage(&common.Message{ s.saveUserMessage(&common.Message{
@ -342,6 +359,7 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() {
unfurledContact, unfurledContact,
unfurledCommunity, unfurledCommunity,
unfurledChannel, unfurledChannel,
unfurledTransaction,
}, },
}, },
}, },
@ -358,6 +376,17 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() {
}, },
}) })
s.saveUserMessage(&common.Message{
ID: messageIDTransactionOnly,
ChatMessage: &protobuf.ChatMessage{
UnfurledStatusLinks: &protobuf.UnfurledStatusLinks{
UnfurledStatusLinks: []*protobuf.UnfurledStatusLink{
unfurledTransaction,
},
},
},
})
testCases := []struct { testCases := []struct {
Name string Name string
ExpectedHTTPStatusCode int ExpectedHTTPStatusCode int
@ -538,6 +567,26 @@ func (s *HandlersSuite) TestHandleStatusLinkPreviewThumbnail() {
s.Require().Equal("missing query parameter 'image-id'\n", rr.Body.String()) s.Require().Equal("missing query parameter 'image-id'\n", rr.Body.String())
}, },
}, },
{
Name: "Test request with missing 'message-id' parameter",
Parameters: url.Values{
"url": {unfurledTransaction.Url},
},
ExpectedHTTPStatusCode: http.StatusBadRequest,
CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) {
s.Require().Equal("missing query parameter 'message-id'\n", rr.Body.String())
},
},
{
Name: "Test request with missing 'url' parameter",
Parameters: url.Values{
"message-id": {messageIDTransactionOnly},
},
ExpectedHTTPStatusCode: http.StatusBadRequest,
CheckFunc: func(s *HandlersSuite, rr *httptest.ResponseRecorder) {
s.Require().Equal("missing query parameter 'url'\n", rr.Body.String())
},
},
} }
handler := handleStatusLinkPreviewThumbnail(s.db, s.logger) handler := handleStatusLinkPreviewThumbnail(s.db, s.logger)