Skip to content
Open
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/sagernet/sing v0.7.13
github.com/sagernet/sing-box v1.12.13
github.com/stretchr/testify v1.10.0
golang.org/x/text v0.28.0
google.golang.org/protobuf v1.36.6
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
Expand Down
19 changes: 19 additions & 0 deletions usermessage/testfixtures/fixtures.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Package testfixtures exposes versioned user-message JSON fixtures for
// compatibility tests in common, lantern-cloud, and Radiance.
package testfixtures

import "embed"

const (
V1RequestPath = "v1/request.json"
V1ResponseEmptyPath = "v1/response_empty.json"
V1ResponseMessagePath = "v1/response_message.json"
V1ResponseNoActionPath = "v1/response_no_action.json"
V1ResponseOpenPlansPath = "v1/response_open_plans.json"
)

// FS contains the versioned JSON fixtures named by the exported path
// constants above.
//
//go:embed v1/*.json
var FS embed.FS
14 changes: 14 additions & 0 deletions usermessage/testfixtures/v1/request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"locale": "fa-IR",
"platform": "android",
"app_version": "9.2.1",
"capabilities": {
"version": "user_messages_v1",
"surfaces": ["snackbar"],
"actions": ["open_https_url", "open_plans"]
},
"seen_display_ids": [
"campaign-018f:generation-1",
"campaign-0190:generation-3"
]
}
3 changes: 3 additions & 0 deletions usermessage/testfixtures/v1/response_empty.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"poll_interval_seconds": 300
}
18 changes: 18 additions & 0 deletions usermessage/testfixtures/v1/response_message.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"message": {
"display_id": "campaign-0191:generation-2",
"campaign_id": "0191d9ad-8b1c-7d5a-a70d-e4f2f744c51a",
"revision_id": "0191d9ae-05e1-70e2-924d-acde79344744",
"delivery_id": "0191d9ae-612c-7ddc-95ac-f1587b557e68",
"surface": "snackbar",
"locale": "fa-IR",
"body": "به ما بگویید تجربه شما از لنترن چگونه بوده است.",
"button_label": "شرکت در نظرسنجی",
"action": {
"type": "open_https_url",
"url": "https://example.com/survey?campaign=0191"
},
"expires_at": "2026-09-01T00:00:00Z"
},
"poll_interval_seconds": 300
}
13 changes: 13 additions & 0 deletions usermessage/testfixtures/v1/response_no_action.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"message": {
"display_id": "campaign-0192:generation-1",
"campaign_id": "0192d9ad-8b1c-7d5a-a70d-e4f2f744c51a",
"revision_id": "0192d9ae-05e1-70e2-924d-acde79344744",
"delivery_id": "0192d9ae-612c-7ddc-95ac-f1587b557e68",
"surface": "snackbar",
"locale": "en-US",
"body": "Lantern maintenance is complete.",
"expires_at": "2026-09-02T00:00:00Z"
},
"poll_interval_seconds": 300
}
17 changes: 17 additions & 0 deletions usermessage/testfixtures/v1/response_open_plans.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"message": {
"display_id": "campaign-0193:generation-1",
"campaign_id": "0193d9ad-8b1c-7d5a-a70d-e4f2f744c51a",
"revision_id": "0193d9ae-05e1-70e2-924d-acde79344744",
"delivery_id": "0193d9ae-612c-7ddc-95ac-f1587b557e68",
"surface": "snackbar",
"locale": "en-US",
"body": "Upgrade to Lantern Pro for unlimited data.",
"button_label": "View plans",
"action": {
"type": "open_plans"
},
"expires_at": "2026-09-03T00:00:00Z"
},
"poll_interval_seconds": 300
}
95 changes: 95 additions & 0 deletions usermessage/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Package usermessage defines the public JSON wire contract used to fetch
// resolved in-app messages. Campaign authoring, targeting, and publication
// types intentionally live in lantern-cloud rather than this package.
package usermessage

import "time"

const (
// CapabilityUserMessagesV1 identifies both support for user messages and
// version 1 of this wire contract.
CapabilityUserMessagesV1 = "user_messages_v1"

// MaxPollIntervalSeconds is the longest interval a server may recommend
// between successful user-message requests.
MaxPollIntervalSeconds = 5 * 60

// Wire-size limits are measured in UTF-8 bytes, except
// MaxSeenDisplayIDs, which limits the number of list entries.
MaxLocaleLength = 64
MaxPlatformLength = 32
MaxAppVersionLength = 64
MaxSeenDisplayIDs = 128
MaxDisplayIDLength = 128
MaxDiagnosticIDLength = 128
MaxMessageBodyLength = 2048
MaxButtonLabelLength = 128
MaxActionURLLength = 2048
MaxSupportedSurfaces = 16
MaxSupportedActions = 32
)

// Surface identifies how a resolved message should be presented. Consumers
// must ignore messages with surfaces they do not support.
type Surface string

const (
SurfaceSnackbar Surface = "snackbar"
)

// ActionType identifies an allowlisted operation initiated by a message
// button. The absence of an Action means the message has no button action.
type ActionType string

const (
ActionTypeOpenHTTPSURL ActionType = "open_https_url"
ActionTypeOpenPlans ActionType = "open_plans"
)

// ClientCapabilities identifies the wire version and presentation features
// that the requesting client can safely handle.
type ClientCapabilities struct {
Version string `json:"version"`
Surfaces []Surface `json:"surfaces"`
Actions []ActionType `json:"actions,omitempty"`
}

// UserMessageRequest contains only the client context needed to resolve a
// message. Authentication and canonical user identity are supplied by the
// transport/account layer, not asserted in this payload.
type UserMessageRequest struct {
Locale string `json:"locale"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Capabilities ClientCapabilities `json:"capabilities"`
SeenDisplayIDs []string `json:"seen_display_ids,omitempty"`
}

// UserMessageResponse contains at most one resolved message. A nil Message
// means no message is currently eligible for this client.
type UserMessageResponse struct {
Message *ResolvedUserMessage `json:"message,omitempty"`
PollIntervalSeconds int `json:"poll_interval_seconds"`
}

// ResolvedUserMessage is presentation-ready. It deliberately excludes
// campaign targeting and other backoffice-only state.
type ResolvedUserMessage struct {
DisplayID string `json:"display_id"`
CampaignID string `json:"campaign_id"`
RevisionID string `json:"revision_id"`
DeliveryID string `json:"delivery_id"`
Surface Surface `json:"surface"`
Locale string `json:"locale"`
Body string `json:"body"`
ButtonLabel string `json:"button_label,omitempty"`
Action *Action `json:"action,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}

// Action contains the data required for an allowlisted client operation.
// URL is required only for ActionTypeOpenHTTPSURL.
type Action struct {
Type ActionType `json:"type"`
URL string `json:"url,omitempty"`
}
Loading