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
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main, feat/project-in-url]

permissions:
contents: read

jobs:
build:
name: Build & verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-go@v6
with:
go-version: "1.25"

# Drift check: regenerate the low-level layer from the vendored spec and
# fail if the committed output is stale. The generated code under gen/
# must always match spec/client.yaml. oapi-codegen is pinned as a tool
# dependency in go.mod, so `go generate` resolves it deterministically.
- name: Regenerate from spec
run: go generate ./...
- name: Verify gen/ is up to date
run: |
git diff --exit-code gen || {
echo "::error::Generated code in gen/ is out of date. Run 'make generate' and commit the result."
exit 1
}

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Check formatting
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "::error::These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi

- name: Test
run: go test -count=1 -race ./...
78 changes: 78 additions & 0 deletions .github/workflows/spec-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Spec Sync

# Bump bot for the vendored OpenAPI spec.
#
# Re-fetches spec/client.yaml from the pinned release recorded in spec/SOURCE.md,
# regenerates the low-level layer (gen/), and — if anything changed — opens a
# PR. This keeps the SDK in step with the platform spec without a manual fetch.
#
# Note: this syncs *at the current pin*. Bumping the pin itself (e.g. to a new
# release tag) is a manual edit to spec/SOURCE.md (its release asset URL is
# re-read from that file each run, so a bumped pin is picked up automatically).

on:
schedule:
# Weekly, Monday 06:00 UTC.
- cron: "0 6 * * 1"
workflow_dispatch: {}

permissions:
contents: write
pull-requests: write

jobs:
sync:
name: Re-fetch spec & regenerate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-go@v6
with:
go-version: "1.25"

# The release asset URL (repo + pinned tag + asset name) is single-sourced
# in spec/SOURCE.md; read it from the fenced ```...``` block there so the
# pin only ever lives in one place.
- name: Resolve spec URL from SOURCE.md
id: source
run: |
url="$(grep -Eo 'https://github\.com/lunogram/platform/releases/download/[^ )]+/client\.yaml' spec/SOURCE.md | head -n1)"
if [ -z "$url" ]; then
echo "::error::Could not find the release asset URL in spec/SOURCE.md"
exit 1
fi
echo "Spec URL: $url"
echo "url=$url" >> "$GITHUB_OUTPUT"

- name: Re-fetch spec
run: curl -fsSL "${{ steps.source.outputs.url }}" -o spec/client.yaml

- name: Regenerate low-level layer
run: go generate ./...

- name: Verify it still builds
run: go build ./...

# Open (or update) a PR only when the vendored spec or generated output
# actually changed.
- name: Open PR on drift
uses: peter-evans/create-pull-request@v7
with:
add-paths: |
spec/client.yaml
gen/**
branch: chore/spec-sync
commit-message: "chore: sync OpenAPI spec & regenerate client layer"
title: "chore: sync OpenAPI spec & regenerate client layer"
body: |
Automated spec sync.

Re-fetched `spec/client.yaml` from the pinned release in
`spec/SOURCE.md` and regenerated `gen/` via `go generate ./...`.

Review the diff and merge if the changes look correct. If the spec
should track a different release, update `spec/SOURCE.md` first.
labels: |
dependencies
automated
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ $(BIN)/%: | $(BIN) ; $(info $(M) building $(@F)…)
GOLANGCI_LINT = $(BIN)/golangci-lint

# Targets
.PHONY: generate
generate: | ; $(info $(M) generating spec-driven types…) @ ## Regenerate gen/ from spec/client.yaml
$Q $(GO) generate ./...

.PHONY: lint
lint: | $(GOLANGCI_LINT) ; $(info $(M) running linters…) @ ## Run the project linters
$Q $(GOLANGCI_LINT) run --max-issues-per-linter 10 --timeout 5m
Expand Down
175 changes: 170 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Go Reference](https://pkg.go.dev/badge/github.com/lunogram/go-sdk.svg)](https://pkg.go.dev/github.com/lunogram/go-sdk)

Go client library for the [Lunogram](https://lunogram.com) Client API. Manage user profiles, organizations, events, and scheduled resources from any Go application.
Go client library for the [Lunogram](https://lunogram.com) Client API. Manage user profiles, organizations, events, inbox messages, and scheduled resources from any Go application.

## Installation

Expand All @@ -12,6 +12,16 @@ go get github.com/lunogram/go-sdk

Requires **Go 1.22** or later.

## Project scoping

Every Client API request is scoped to a single Lunogram project. You supply the
project ID (a UUID) once, when constructing the client, and the SDK injects it
into every request path automatically — you never pass it per call. Requests are
issued under `/api/client/projects/{projectID}/...`.

`NewClient` validates the project ID and returns an error if it is empty or not
a valid UUID.

## Quick start

```go
Expand All @@ -26,7 +36,11 @@ import (
)

func main() {
client := lunogram.NewClient("your-api-key")
// The project ID (a UUID) scopes every request. It is required.
client, err := lunogram.NewClient("your-api-key", "your-project-id")
if err != nil {
log.Fatal(err)
}
ctx := context.Background()

user, err := client.UpsertUser(ctx, &lunogram.UpsertUserRequest{
Expand All @@ -48,18 +62,21 @@ func main() {

## Client options

All options are passed after the required `apiKey` and `projectID` arguments,
and `NewClient` returns `(*Client, error)`.

```go
// Custom base URL (e.g. for self-hosted or staging environments)
client := lunogram.NewClient(apiKey, lunogram.WithBaseURL("https://outreach.internal"))
client, err := lunogram.NewClient(apiKey, projectID, lunogram.WithBaseURL("https://outreach.internal"))

// Custom HTTP client
client := lunogram.NewClient(apiKey, lunogram.WithHTTPClient(&http.Client{
client, err := lunogram.NewClient(apiKey, projectID, lunogram.WithHTTPClient(&http.Client{
Timeout: 10 * time.Second,
Transport: myTransport,
}))

// Custom timeout (applies to the default HTTP client)
client := lunogram.NewClient(apiKey, lunogram.WithTimeout(10*time.Second))
client, err := lunogram.NewClient(apiKey, projectID, lunogram.WithTimeout(10*time.Second))
```

## Users
Expand Down Expand Up @@ -203,6 +220,154 @@ err := client.PostOrganizationEvents(ctx, []lunogram.OrganizationEvent{
})
```

## Inbox

Inbox messages are delivered to users or organizations on a specific channel
(`email`, `sms`, `push`, or `inbox`). Creating, reading, and archiving messages
are processed asynchronously; querying and counting return immediately.

### Create user inbox messages

`Identifier` is the message's own external identifier, used for idempotency and
traceability; `Target` identifies the recipient.

```go
err := client.PostUserInboxMessages(ctx, []lunogram.InboxMessageCreate{
{
Target: []lunogram.ExternalID{{ExternalID: "user_123"}},
Identifier: lunogram.ExternalID{ExternalID: "msg_welcome_1"},
Channel: lunogram.ChannelInbox,
Content: json.RawMessage(`{"title": "Welcome!", "body": "Thanks for joining."}`),
Tags: []string{"onboarding"},
},
})
```

### Query the user inbox

`Source`, `ExternalID`, and `Channel` are required; the remaining fields are
optional filters.

```go
list, err := client.GetUserInbox(ctx, &lunogram.InboxQuery{
Source: "default",
ExternalID: "user_123",
Channel: lunogram.ChannelInbox,
Status: &[]lunogram.InboxStatus{lunogram.InboxStatusUnread}[0],
})
for _, msg := range list.Results {
fmt.Println(msg.ID, msg.Priority)
}
```

### Count user inbox messages

```go
count, err := client.GetUserInboxCount(ctx, &lunogram.InboxCountQuery{
Source: "default",
ExternalID: "user_123",
Channel: lunogram.ChannelInbox,
})
fmt.Printf("%d unread of %d total\n", count.Unread, count.Total)
```

### Mark user messages read or archived

```go
err := client.MarkUserInboxRead(ctx, []lunogram.InboxMessageRef{
{
Target: []lunogram.ExternalID{{ExternalID: "user_123"}},
MessageID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
})

err = client.MarkUserInboxArchived(ctx, []lunogram.InboxMessageRef{
{
Target: []lunogram.ExternalID{{ExternalID: "user_123"}},
MessageID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
})
```

### Organization inbox

The organization inbox mirrors the user inbox. Create, query, count, and mark
messages with the organization-scoped methods:

```go
err := client.PostOrganizationInboxMessages(ctx, []lunogram.OrganizationInboxMessageCreate{
{
Target: []lunogram.ExternalID{{ExternalID: "org_456"}},
Identifier: lunogram.ExternalID{ExternalID: "msg_renewal_1"},
Channel: lunogram.ChannelInbox,
Content: json.RawMessage(`{"title": "Contract renewal"}`),
},
})

list, err := client.GetOrganizationInbox(ctx, &lunogram.InboxQuery{
Source: "default",
ExternalID: "org_456",
Channel: lunogram.ChannelInbox,
})

count, err := client.GetOrganizationInboxCount(ctx, &lunogram.InboxCountQuery{
Source: "default",
ExternalID: "org_456",
Channel: lunogram.ChannelInbox,
})

err = client.MarkOrganizationInboxRead(ctx, []lunogram.OrganizationInboxMessageRef{
{
Target: []lunogram.ExternalID{{ExternalID: "org_456"}},
MessageID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
})

err = client.MarkOrganizationInboxArchived(ctx, []lunogram.OrganizationInboxMessageRef{
{
Target: []lunogram.ExternalID{{ExternalID: "org_456"}},
MessageID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
})
```

## Devices and push

Register a device's push subscription for a user, and fetch the project's VAPID
public key for Web Push:

```go
os := lunogram.DeviceOSWeb
err := client.RegisterDevice(ctx, &lunogram.DeviceRegistration{
Identifier: []lunogram.ExternalID{{ExternalID: "user_123"}},
DeviceID: "user_123_web",
OS: &os,
Config: lunogram.DeviceConfig{
Endpoint: lunogram.String("https://push.example.com/subscription"),
Keys: &lunogram.DeviceKeys{
P256dh: "BKey-p256dh-value",
Auth: "auth-secret",
},
},
})

key, err := client.GetVapidPublicKey(ctx)
// key.PublicKey is the project's VAPID public key
```

## Sessions

Mint a short-lived session token for an end user, server-side, so the client can
call the Client API directly. The session's permissions are defined by the
session auth method (policy) identified by `authMethodID`:

```go
token, err := client.CreateSession(ctx, "auth-method-uuid", &lunogram.CreateSessionRequest{
UserID: "user_123",
})
// token.Token is a bearer token for the Client API; token.ExpiresAt is its expiry
```

## Scheduled resources

Scheduled resources trigger journey entrance recalculation at a specific time or on a recurring interval.
Expand Down
Loading