diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfc4d92..e2a6180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: CI on: push: - branches-ignore: - - 'generated' - - 'codegen/**' - - 'integrated/**' - - 'stl-preview-head/**' - - 'stl-preview-base/**' + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' pull_request: branches-ignore: - 'stl-preview-head/**' @@ -22,7 +24,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: |- github.repository == 'stainless-sdks/beeper-desktop-api-go' && - (github.event_name == 'push' || github.event.pull_request.head.repo.fork) + (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index c6d0501..8554aff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .prism.log +.stdy.log codegen.log Brewfile.lock.json .idea/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2aca35a..8e76abb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.5.0" + ".": "5.0.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 004aab8..2dd3fee 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml -openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: aa49273410d42fb96c5515dbce1f182f +configured_endpoints: 30 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-c08c14bb754b4cb0e02b21fabb680469368286be339dec0aaa8c69d04a1f021a.yml +openapi_spec_hash: a10246aaf7cdc33b682fc245bd5f893b +config_hash: 72f9d43b9b51a5da912e9f3730e53ae2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e49922..f774108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## 5.0.0 (2026-05-06) + +Full Changelog: [v0.5.0...v5.0.0](https://github.com/beeper/desktop-api-go/compare/v0.5.0...v5.0.0) + +### Features + +* **api:** add network, bridge fields to accounts ([5042271](https://github.com/beeper/desktop-api-go/commit/50422719fe1149b4a0a33cac7a7605e499fac9e4)) +* **api:** api update ([4681bd6](https://github.com/beeper/desktop-api-go/commit/4681bd6077032db3dfe8d8e6d4b4edaddcb7292c)) +* **api:** api update ([85ced8c](https://github.com/beeper/desktop-api-go/commit/85ced8c13f1d373cd36f5fb2c74ae3aad2a99016)) +* **api:** api update ([d2d6223](https://github.com/beeper/desktop-api-go/commit/d2d6223284dade370d2671176ff36811203d9981)) +* **api:** manual updates ([9fd07e4](https://github.com/beeper/desktop-api-go/commit/9fd07e44e68014b1ba88891a8e89d2994540df19)) +* **go:** add default http client with timeout ([7c34729](https://github.com/beeper/desktop-api-go/commit/7c347297b7df763e97e3554777ffebdecd37009c)) +* **internal:** support comma format in multipart form encoding ([d4f7e8e](https://github.com/beeper/desktop-api-go/commit/d4f7e8ef134cb9a535fa05458ee7af93532c8c2b)) +* support setting headers via env ([d4fdbe1](https://github.com/beeper/desktop-api-go/commit/d4fdbe157b2634c7e0c16be61583f9ebb99a8ed4)) + + +### Bug Fixes + +* prevent duplicate ? in query params ([8293eb7](https://github.com/beeper/desktop-api-go/commit/8293eb76584617003ec104770810028b3e4076b0)) + + +### Chores + +* avoid embedding reflect.Type for dead code elimination ([6a8ff6a](https://github.com/beeper/desktop-api-go/commit/6a8ff6a1189beb742794e0f7b11ce2aa5651c63e)) +* **ci:** skip lint on metadata-only changes ([2a83146](https://github.com/beeper/desktop-api-go/commit/2a83146c4d9b2f29ddce45b6f8484f7533f98c58)) +* **ci:** support opting out of skipping builds on metadata-only commits ([b4dec8f](https://github.com/beeper/desktop-api-go/commit/b4dec8f324d1c18699f81d6a060b33c1b0d4772a)) +* **client:** fix multipart serialisation of Default() fields ([029f5d6](https://github.com/beeper/desktop-api-go/commit/029f5d67da0158cf1ed913fc3f6dfd213681d363)) +* **internal:** minor cleanup ([9504a61](https://github.com/beeper/desktop-api-go/commit/9504a6100b1c23909976ac1473285cab5397aece)) +* **internal:** more robust bootstrap script ([25b94e4](https://github.com/beeper/desktop-api-go/commit/25b94e4324b7c4b7f0c8decbaed4cd8ed979a3b4)) +* **internal:** support default value struct tag ([3e39971](https://github.com/beeper/desktop-api-go/commit/3e39971ec0ee20accae2c2eae5e64004f6cfe810)) +* **internal:** tweak CI branches ([b7458ce](https://github.com/beeper/desktop-api-go/commit/b7458ceb902a6e768e54adc05d5532f63147e3db)) +* **internal:** update gitignore ([229284f](https://github.com/beeper/desktop-api-go/commit/229284fcf7e552241afaebbe24df184a5db693af)) +* **internal:** use explicit returns ([ce4f065](https://github.com/beeper/desktop-api-go/commit/ce4f065cc711a045df715b8c0a7c4338f7d114db)) +* **internal:** use explicit returns in more places ([f102b8e](https://github.com/beeper/desktop-api-go/commit/f102b8ecdfa469185a1ed3700f73952f7c3bfec5)) +* remove unnecessary error check for url parsing ([76dc981](https://github.com/beeper/desktop-api-go/commit/76dc981c3f95e4b7935aaefd95f1de5e119d9298)) +* **tests:** bump steady to v0.19.4 ([0260191](https://github.com/beeper/desktop-api-go/commit/026019176b91a6cc8a06f3e34cf6df2365f14e50)) +* **tests:** bump steady to v0.19.5 ([bb2044f](https://github.com/beeper/desktop-api-go/commit/bb2044f9230e575f610398a5f28a43bd7b7e8cf2)) +* **tests:** bump steady to v0.19.6 ([30b897c](https://github.com/beeper/desktop-api-go/commit/30b897ce73a2216204876ab5fb3132cf549228ee)) +* **tests:** bump steady to v0.19.7 ([b64d423](https://github.com/beeper/desktop-api-go/commit/b64d42348319e02f4cad0107d9848f57743d04c7)) +* **tests:** bump steady to v0.20.1 ([8e7e3a6](https://github.com/beeper/desktop-api-go/commit/8e7e3a6fcedaf7b4eaeb0f7e880e5347f5c2ac93)) +* **tests:** bump steady to v0.20.2 ([a5fbcc7](https://github.com/beeper/desktop-api-go/commit/a5fbcc7e74ba585849c839bffb71ee1b42f6af2d)) +* **tests:** bump steady to v0.22.1 ([423bb69](https://github.com/beeper/desktop-api-go/commit/423bb69d2de050be324c3389a759747693eb55ed)) +* update docs for api:"required" ([350f111](https://github.com/beeper/desktop-api-go/commit/350f111de7c4045065804f2425729f72a65a1388)) + + +### Refactors + +* **tests:** switch from prism to steady ([bd09ac3](https://github.com/beeper/desktop-api-go/commit/bd09ac321d342e5f197f8ceac6abf4ee5e766822)) + ## 0.5.0 (2026-03-06) Full Changelog: [v0.4.0...v0.5.0](https://github.com/beeper/desktop-api-go/compare/v0.4.0...v0.5.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a63ac6b..9b64439 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,7 +46,7 @@ $ go mod edit -replace github.com/beeper/desktop-api-go=/path/to/desktop-api-go ## Running tests -Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. +Most tests require you to [set up a mock server](https://github.com/dgellow/steady) against the OpenAPI spec to run the tests. ```sh $ ./scripts/mock diff --git a/README.md b/README.md index f3f60c7..858aded 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ -Go Reference +Go Reference @@ -13,8 +13,8 @@ from applications written in Go. Use the Beeper Desktop MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. -[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-api-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1hcGktbWNwIl0sImVudiI6eyJCRUVQRVJfQUNDRVNTX1RPS0VOIjoiTXkgQWNjZXNzIFRva2VuIn19) -[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-api-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-api-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=%40beeper%2Fdesktop-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBiZWVwZXIvZGVza3RvcC1tY3AiXSwiZW52Ijp7IkJFRVBFUl9BQ0NFU1NfVE9LRU4iOiJNeSBBY2Nlc3MgVG9rZW4ifX0) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40beeper%2Fdesktop-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40beeper%2Fdesktop-mcp%22%5D%2C%22env%22%3A%7B%22BEEPER_ACCESS_TOKEN%22%3A%22My%20Access%20Token%22%7D%7D) > Note: You may need to set environment variables in your MCP client. @@ -24,7 +24,7 @@ Use the Beeper Desktop MCP Server to enable AI assistants to interact with this ```go import ( - "github.com/beeper/desktop-api-go" // imported as beeperdesktopapi + "github.com/beeper/desktop-api-go/v5" // imported as beeperdesktopapi ) ``` @@ -35,7 +35,7 @@ Or to pin the version: ```sh -go get -u 'github.com/beeper/desktop-api-go@v0.5.0' +go get -u 'github.com/beeper/desktop-api-go@v5.0.0' ``` @@ -55,12 +55,16 @@ import ( "context" "fmt" - "github.com/beeper/desktop-api-go" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" ) func main() { - client := beeperdesktopapi.NewClient() + client := beeperdesktopapi.NewClient( + option.WithAccessToken("My Access Token"), // defaults to os.LookupEnv("BEEPER_ACCESS_TOKEN") + ) page, err := client.Chats.Search(context.TODO(), beeperdesktopapi.ChatSearchParams{ + AccountIDs: []string{"matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"}, IncludeMuted: beeperdesktopapi.Bool(true), Limit: beeperdesktopapi.Int(3), Type: beeperdesktopapi.ChatSearchParamsTypeSingle, @@ -78,7 +82,7 @@ func main() { The beeperdesktopapi library uses the [`omitzero`](https://tip.golang.org/doc/go1.24#encodingjsonpkgencodingjson) semantics from the Go 1.24+ `encoding/json` release for request fields. -Required primitive fields (`int64`, `string`, etc.) feature the tag \`json:"...,required"\`. These +Required primitive fields (`int64`, `string`, etc.) feature the tag \`api:"required"\`. These fields are always serialized, even their zero values. Optional primitive types are wrapped in a `param.Opt[T]`. These fields can be set with the provided constructors, `beeperdesktopapi.String(string)`, `beeperdesktopapi.Int(int64)`, etc. @@ -294,9 +298,9 @@ You can use `.ListAutoPaging()` methods to iterate through items across all page ```go iter := client.Messages.SearchAutoPaging(context.TODO(), beeperdesktopapi.MessageSearchParams{ - AccountIDs: []string{"local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"}, + AccountIDs: []string{"discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"}, Limit: beeperdesktopapi.Int(10), - Query: beeperdesktopapi.String("deployment"), + Query: beeperdesktopapi.String("oauth"), }) // Automatically fetches more pages as needed. for iter.Next() { @@ -313,9 +317,9 @@ with additional helper methods like `.GetNextPage()`, e.g.: ```go page, err := client.Messages.Search(context.TODO(), beeperdesktopapi.MessageSearchParams{ - AccountIDs: []string{"local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"}, + AccountIDs: []string{"discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"}, Limit: beeperdesktopapi.Int(10), - Query: beeperdesktopapi.String("deployment"), + Query: beeperdesktopapi.String("oauth"), }) for page != nil { for _, message := range page.Items { diff --git a/account.go b/account.go index 22c0aef..2e8f335 100644 --- a/account.go +++ b/account.go @@ -7,11 +7,11 @@ import ( "net/http" "slices" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" - "github.com/beeper/desktop-api-go/packages/respjson" - "github.com/beeper/desktop-api-go/shared" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/beeper/desktop-api-go/v5/packages/respjson" + "github.com/beeper/desktop-api-go/v5/shared" ) // Manage connected chat accounts @@ -38,25 +38,35 @@ func NewAccountService(opts ...option.RequestOption) (r AccountService) { return } -// Lists chat accounts across networks (WhatsApp, Telegram, Twitter/X, etc.) -// actively connected to this Beeper Desktop instance +// List Chat Accounts connected to this Beeper Desktop instance, including bridge +// metadata and network identity. func (r *AccountService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Account, err error) { opts = slices.Concat(r.Options, opts) path := "v1/accounts" err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } -// A chat account added to Beeper +// A chat account added to Beeper. type Account struct { - // Chat account added to Beeper. Use this to route account-scoped actions. + // Chat account added to Beeper. Use this to route account-scoped actions. Examples + // include matrix for Beeper/Matrix, discordgo for a cloud bridge, + // slackgo.TEAM-USER for workspace-scoped cloud bridges, and local-whatsapp*ba*... + // for local bridges. AccountID string `json:"accountID" api:"required"` + // Bridge metadata for the account. Available in Beeper Desktop v4.2.785+. + Bridge AccountBridge `json:"bridge" api:"required"` // User the account belongs to. User shared.User `json:"user" api:"required"` + // Human-friendly network name for the account. Omitted when the network is + // unknown. + Network string `json:"network"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { AccountID respjson.Field + Bridge respjson.Field User respjson.Field + Network respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` @@ -67,3 +77,32 @@ func (r Account) RawJSON() string { return r.JSON.raw } func (r *Account) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } + +// Bridge metadata for the account. Available in Beeper Desktop v4.2.785+. +type AccountBridge struct { + // Bridge instance identifier. Matrix and cloud bridges often use the bridge type + // (for example matrix or discordgo); local bridges use a local bridge ID (for + // example local-whatsapp). Available in Beeper Desktop v4.2.785+. + ID string `json:"id" api:"required"` + // Bridge provider for the account. Available in Beeper Desktop v4.2.785+. + // + // Any of "cloud", "self-hosted", "local", "platform-sdk". + Provider string `json:"provider" api:"required"` + // Bridge type, such as matrix, discordgo, slackgo, whatsapp, telegram, or twitter. + // Available in Beeper Desktop v4.2.785+. + Type string `json:"type" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + ID respjson.Field + Provider respjson.Field + Type respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r AccountBridge) RawJSON() string { return r.JSON.raw } +func (r *AccountBridge) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} diff --git a/account_test.go b/account_test.go index 884aff1..b8875e9 100644 --- a/account_test.go +++ b/account_test.go @@ -8,9 +8,9 @@ import ( "os" "testing" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestAccountList(t *testing.T) { diff --git a/accountcontact.go b/accountcontact.go index e202b79..75a41a1 100644 --- a/accountcontact.go +++ b/accountcontact.go @@ -10,14 +10,14 @@ import ( "net/url" "slices" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/apiquery" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" - "github.com/beeper/desktop-api-go/packages/pagination" - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/packages/respjson" - "github.com/beeper/desktop-api-go/shared" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/apiquery" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/beeper/desktop-api-go/v5/packages/pagination" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/respjson" + "github.com/beeper/desktop-api-go/v5/shared" ) // Manage contacts on a specific account @@ -48,7 +48,7 @@ func (r *AccountContactService) List(ctx context.Context, accountID string, quer opts = append([]option.RequestOption{option.WithResponseInto(&raw)}, opts...) if accountID == "" { err = errors.New("missing required accountID parameter") - return + return nil, err } path := fmt.Sprintf("v1/accounts/%s/contacts/list", accountID) cfg, err := requestconfig.NewRequestConfig(ctx, http.MethodGet, path, query, &res, opts...) @@ -74,11 +74,11 @@ func (r *AccountContactService) Search(ctx context.Context, accountID string, qu opts = slices.Concat(r.Options, opts) if accountID == "" { err = errors.New("missing required accountID parameter") - return + return nil, err } path := fmt.Sprintf("v1/accounts/%s/contacts", accountID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) - return + return res, err } type AccountContactSearchResponse struct { diff --git a/accountcontact_test.go b/accountcontact_test.go index ec38de1..cd195b8 100644 --- a/accountcontact_test.go +++ b/accountcontact_test.go @@ -8,9 +8,9 @@ import ( "os" "testing" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestAccountContactListWithOptionalParams(t *testing.T) { diff --git a/aliases.go b/aliases.go index 958c462..e7c69e4 100644 --- a/aliases.go +++ b/aliases.go @@ -3,9 +3,9 @@ package beeperdesktopapi import ( - "github.com/beeper/desktop-api-go/internal/apierror" - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/shared" + "github.com/beeper/desktop-api-go/v5/internal/apierror" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/shared" ) // aliased to make [param.APIUnion] private when embedding @@ -41,9 +41,39 @@ const AttachmentTypeAudio = shared.AttachmentTypeAudio // This is an alias to an internal type. type AttachmentSize = shared.AttachmentSize +// Attachment transcription if available. +// +// This is an alias to an internal type. +type AttachmentTranscription = shared.AttachmentTranscription + // This is an alias to an internal type. type Message = shared.Message +// Link preview included with a message. +// +// This is an alias to an internal type. +type MessageLink = shared.MessageLink + +// Preview image dimensions. +// +// This is an alias to an internal type. +type MessageLinkImgSize = shared.MessageLinkImgSize + +// Read receipt state for this message, when available. +// +// This is an alias to an internal type. +type MessageSeenUnion = shared.MessageSeenUnion + +// ISO 8601 timestamp. +// +// This is an alias to an internal type. +type MessageSeenByParticipantItemUnion = shared.MessageSeenByParticipantItemUnion + +// Message send status for this message, when reported by the bridge. +// +// This is an alias to an internal type. +type MessageSendStatus = shared.MessageSendStatus + // Message content type. Useful for distinguishing reactions, media messages, and // state events from regular text messages. // diff --git a/api.md b/api.md index 209be51..d0d68af 100644 --- a/api.md +++ b/api.md @@ -1,65 +1,71 @@ # Shared Response Types -- shared.Attachment -- shared.Message -- shared.Reaction -- shared.User +- shared.Attachment +- shared.Message +- shared.Reaction +- shared.User # beeperdesktopapi Response Types: -- beeperdesktopapi.FocusResponse -- beeperdesktopapi.SearchResponse +- beeperdesktopapi.FocusResponse +- beeperdesktopapi.SearchResponse Methods: -- client.Focus(ctx context.Context, body beeperdesktopapi.FocusParams) (\*beeperdesktopapi.FocusResponse, error) -- client.Search(ctx context.Context, query beeperdesktopapi.SearchParams) (\*beeperdesktopapi.SearchResponse, error) +- client.Focus(ctx context.Context, body beeperdesktopapi.FocusParams) (\*beeperdesktopapi.FocusResponse, error) +- client.Search(ctx context.Context, query beeperdesktopapi.SearchParams) (\*beeperdesktopapi.SearchResponse, error) # Accounts Response Types: -- beeperdesktopapi.Account +- beeperdesktopapi.Account Methods: -- client.Accounts.List(ctx context.Context) (\*[]beeperdesktopapi.Account, error) +- client.Accounts.List(ctx context.Context) (\*[]beeperdesktopapi.Account, error) ## Contacts Response Types: -- beeperdesktopapi.AccountContactSearchResponse +- beeperdesktopapi.AccountContactSearchResponse Methods: -- client.Accounts.Contacts.List(ctx context.Context, accountID string, query beeperdesktopapi.AccountContactListParams) (\*pagination.CursorSearch[shared.User], error) -- client.Accounts.Contacts.Search(ctx context.Context, accountID string, query beeperdesktopapi.AccountContactSearchParams) (\*beeperdesktopapi.AccountContactSearchResponse, error) +- client.Accounts.Contacts.List(ctx context.Context, accountID string, query beeperdesktopapi.AccountContactListParams) (\*pagination.CursorSearch[shared.User], error) +- client.Accounts.Contacts.Search(ctx context.Context, accountID string, query beeperdesktopapi.AccountContactSearchParams) (\*beeperdesktopapi.AccountContactSearchResponse, error) # Chats Response Types: -- beeperdesktopapi.Chat -- beeperdesktopapi.ChatNewResponse -- beeperdesktopapi.ChatListResponse +- beeperdesktopapi.Chat +- beeperdesktopapi.ChatNewResponse +- beeperdesktopapi.ChatListResponse +- beeperdesktopapi.ChatStartResponse Methods: -- client.Chats.New(ctx context.Context, body beeperdesktopapi.ChatNewParams) (\*beeperdesktopapi.ChatNewResponse, error) -- client.Chats.Get(ctx context.Context, chatID string, query beeperdesktopapi.ChatGetParams) (\*beeperdesktopapi.Chat, error) -- client.Chats.List(ctx context.Context, query beeperdesktopapi.ChatListParams) (\*pagination.CursorNoLimit[beeperdesktopapi.ChatListResponse], error) -- client.Chats.Archive(ctx context.Context, chatID string, body beeperdesktopapi.ChatArchiveParams) error -- client.Chats.Search(ctx context.Context, query beeperdesktopapi.ChatSearchParams) (\*pagination.CursorSearch[beeperdesktopapi.Chat], error) +- client.Chats.New(ctx context.Context, body beeperdesktopapi.ChatNewParams) (\*beeperdesktopapi.ChatNewResponse, error) +- client.Chats.Get(ctx context.Context, chatID string, query beeperdesktopapi.ChatGetParams) (\*beeperdesktopapi.Chat, error) +- client.Chats.Update(ctx context.Context, chatID string, body beeperdesktopapi.ChatUpdateParams) (\*beeperdesktopapi.Chat, error) +- client.Chats.List(ctx context.Context, query beeperdesktopapi.ChatListParams) (\*pagination.CursorNoLimit[beeperdesktopapi.ChatListResponse], error) +- client.Chats.Archive(ctx context.Context, chatID string, body beeperdesktopapi.ChatArchiveParams) error +- client.Chats.MarkRead(ctx context.Context, chatID string, body beeperdesktopapi.ChatMarkReadParams) (\*beeperdesktopapi.Chat, error) +- client.Chats.MarkUnread(ctx context.Context, chatID string, body beeperdesktopapi.ChatMarkUnreadParams) (\*beeperdesktopapi.Chat, error) +- client.Chats.NotifyAnyway(ctx context.Context, chatID string, body beeperdesktopapi.ChatNotifyAnywayParams) (\*beeperdesktopapi.Chat, error) +- client.Chats.Search(ctx context.Context, query beeperdesktopapi.ChatSearchParams) (\*pagination.CursorSearch[beeperdesktopapi.Chat], error) +- client.Chats.Start(ctx context.Context, body beeperdesktopapi.ChatStartParams) (\*beeperdesktopapi.ChatStartResponse, error) ## Reminders Methods: -- client.Chats.Reminders.New(ctx context.Context, chatID string, body beeperdesktopapi.ChatReminderNewParams) error -- client.Chats.Reminders.Delete(ctx context.Context, chatID string) error +- client.Chats.Reminders.New(ctx context.Context, chatID string, body beeperdesktopapi.ChatReminderNewParams) error +- client.Chats.Reminders.Delete(ctx context.Context, chatID string) error ## Messages @@ -67,49 +73,51 @@ Methods: Response Types: -- beeperdesktopapi.ChatMessageReactionDeleteResponse -- beeperdesktopapi.ChatMessageReactionAddResponse +- beeperdesktopapi.ChatMessageReactionDeleteResponse +- beeperdesktopapi.ChatMessageReactionAddResponse Methods: -- client.Chats.Messages.Reactions.Delete(ctx context.Context, messageID string, params beeperdesktopapi.ChatMessageReactionDeleteParams) (\*beeperdesktopapi.ChatMessageReactionDeleteResponse, error) -- client.Chats.Messages.Reactions.Add(ctx context.Context, messageID string, params beeperdesktopapi.ChatMessageReactionAddParams) (\*beeperdesktopapi.ChatMessageReactionAddResponse, error) +- client.Chats.Messages.Reactions.Delete(ctx context.Context, reactionKey string, body beeperdesktopapi.ChatMessageReactionDeleteParams) (\*beeperdesktopapi.ChatMessageReactionDeleteResponse, error) +- client.Chats.Messages.Reactions.Add(ctx context.Context, messageID string, params beeperdesktopapi.ChatMessageReactionAddParams) (\*beeperdesktopapi.ChatMessageReactionAddResponse, error) # Messages Response Types: -- beeperdesktopapi.MessageUpdateResponse -- beeperdesktopapi.MessageSendResponse +- beeperdesktopapi.MessageUpdateResponse +- beeperdesktopapi.MessageSendResponse Methods: -- client.Messages.Update(ctx context.Context, messageID string, params beeperdesktopapi.MessageUpdateParams) (\*beeperdesktopapi.MessageUpdateResponse, error) -- client.Messages.List(ctx context.Context, chatID string, query beeperdesktopapi.MessageListParams) (\*pagination.CursorSortKey[shared.Message], error) -- client.Messages.Search(ctx context.Context, query beeperdesktopapi.MessageSearchParams) (\*pagination.CursorSearch[shared.Message], error) -- client.Messages.Send(ctx context.Context, chatID string, body beeperdesktopapi.MessageSendParams) (\*beeperdesktopapi.MessageSendResponse, error) +- client.Messages.Get(ctx context.Context, messageID string, query beeperdesktopapi.MessageGetParams) (\*shared.Message, error) +- client.Messages.Update(ctx context.Context, messageID string, params beeperdesktopapi.MessageUpdateParams) (\*beeperdesktopapi.MessageUpdateResponse, error) +- client.Messages.List(ctx context.Context, chatID string, query beeperdesktopapi.MessageListParams) (\*pagination.CursorNoLimit[shared.Message], error) +- client.Messages.Delete(ctx context.Context, messageID string, params beeperdesktopapi.MessageDeleteParams) error +- client.Messages.Search(ctx context.Context, query beeperdesktopapi.MessageSearchParams) (\*pagination.CursorSearch[shared.Message], error) +- client.Messages.Send(ctx context.Context, chatID string, body beeperdesktopapi.MessageSendParams) (\*beeperdesktopapi.MessageSendResponse, error) # Assets Response Types: -- beeperdesktopapi.AssetDownloadResponse -- beeperdesktopapi.AssetUploadResponse -- beeperdesktopapi.AssetUploadBase64Response +- beeperdesktopapi.AssetDownloadResponse +- beeperdesktopapi.AssetUploadResponse +- beeperdesktopapi.AssetUploadBase64Response Methods: -- client.Assets.Download(ctx context.Context, body beeperdesktopapi.AssetDownloadParams) (\*beeperdesktopapi.AssetDownloadResponse, error) -- client.Assets.Serve(ctx context.Context, query beeperdesktopapi.AssetServeParams) error -- client.Assets.Upload(ctx context.Context, body beeperdesktopapi.AssetUploadParams) (\*beeperdesktopapi.AssetUploadResponse, error) -- client.Assets.UploadBase64(ctx context.Context, body beeperdesktopapi.AssetUploadBase64Params) (\*beeperdesktopapi.AssetUploadBase64Response, error) +- client.Assets.Download(ctx context.Context, body beeperdesktopapi.AssetDownloadParams) (\*beeperdesktopapi.AssetDownloadResponse, error) +- client.Assets.Serve(ctx context.Context, query beeperdesktopapi.AssetServeParams) (\*http.Response, error) +- client.Assets.Upload(ctx context.Context, body beeperdesktopapi.AssetUploadParams) (\*beeperdesktopapi.AssetUploadResponse, error) +- client.Assets.UploadBase64(ctx context.Context, body beeperdesktopapi.AssetUploadBase64Params) (\*beeperdesktopapi.AssetUploadBase64Response, error) # Info Response Types: -- beeperdesktopapi.InfoGetResponse +- beeperdesktopapi.InfoGetResponse Methods: -- client.Info.Get(ctx context.Context) (\*beeperdesktopapi.InfoGetResponse, error) +- client.Info.Get(ctx context.Context) (\*beeperdesktopapi.InfoGetResponse, error) diff --git a/asset.go b/asset.go index 3f5ba72..c9379a4 100644 --- a/asset.go +++ b/asset.go @@ -11,13 +11,13 @@ import ( "net/url" "slices" - "github.com/beeper/desktop-api-go/internal/apiform" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/apiquery" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/internal/apiform" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/apiquery" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/respjson" ) // Manage assets in Beeper Desktop, like message attachments @@ -41,48 +41,49 @@ func NewAssetService(opts ...option.RequestOption) (r AssetService) { return } -// Download a Matrix asset using its mxc:// or localmxc:// URL to the device -// running Beeper Desktop and return the local file URL. +// Download a Matrix file using its mxc:// or localmxc:// URL to the device running +// Beeper Desktop and return the local file URL. func (r *AssetService) Download(ctx context.Context, body AssetDownloadParams, opts ...option.RequestOption) (res *AssetDownloadResponse, err error) { opts = slices.Concat(r.Options, opts) path := "v1/assets/download" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if // not cached. Supports Range requests for seeking in large files. -func (r *AssetService) Serve(ctx context.Context, query AssetServeParams, opts ...option.RequestOption) (err error) { +func (r *AssetService) Serve(ctx context.Context, query AssetServeParams, opts ...option.RequestOption) (res *http.Response, err error) { opts = slices.Concat(r.Options, opts) - opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) + opts = append([]option.RequestOption{option.WithHeader("Accept", "application/octet-stream")}, opts...) path := "v1/assets/serve" - err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, nil, opts...) - return + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return res, err } // Upload a file to a temporary location using multipart/form-data. Returns an -// uploadID that can be referenced when sending messages with attachments. +// uploadID that can be referenced when sending a message or materializing a draft +// attachment. func (r *AssetService) Upload(ctx context.Context, body AssetUploadParams, opts ...option.RequestOption) (res *AssetUploadResponse, err error) { opts = slices.Concat(r.Options, opts) path := "v1/assets/upload" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // Upload a file using a JSON body with base64-encoded content. Returns an uploadID -// that can be referenced when sending messages with attachments. Alternative to -// the multipart upload endpoint. +// that can be referenced when sending a message or materializing a draft +// attachment. Alternative to the multipart upload endpoint. func (r *AssetService) UploadBase64(ctx context.Context, body AssetUploadBase64Params, opts ...option.RequestOption) (res *AssetUploadBase64Response, err error) { opts = slices.Concat(r.Options, opts) path := "v1/assets/upload/base64" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } type AssetDownloadResponse struct { // Error message if the download failed. Error string `json:"error"` - // Local file URL to the downloaded asset. + // Local file URL to the downloaded file. SrcURL string `json:"srcURL"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -112,9 +113,9 @@ type AssetUploadResponse struct { Height float64 `json:"height"` // Detected or provided MIME type MimeType string `json:"mimeType"` - // Local file URL (file://) for the uploaded asset + // Local file URL (file://) for the uploaded file SrcURL string `json:"srcURL"` - // Unique upload ID for this asset + // Unique upload ID for this temporary file UploadID string `json:"uploadID"` // Width in pixels (images/videos) Width float64 `json:"width"` @@ -153,9 +154,9 @@ type AssetUploadBase64Response struct { Height float64 `json:"height"` // Detected or provided MIME type MimeType string `json:"mimeType"` - // Local file URL (file://) for the uploaded asset + // Local file URL (file://) for the uploaded file SrcURL string `json:"srcURL"` - // Unique upload ID for this asset + // Unique upload ID for this temporary file UploadID string `json:"uploadID"` // Width in pixels (images/videos) Width float64 `json:"width"` @@ -182,7 +183,7 @@ func (r *AssetUploadBase64Response) UnmarshalJSON(data []byte) error { } type AssetDownloadParams struct { - // Matrix content URL (mxc:// or localmxc://) for the asset to download. + // Matrix content URL (mxc:// or localmxc://) for the file to download. URL string `json:"url" api:"required"` paramObj } @@ -196,7 +197,7 @@ func (r *AssetDownloadParams) UnmarshalJSON(data []byte) error { } type AssetServeParams struct { - // Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs. + // File URL to serve. Accepts mxc://, localmxc://, or file:// URLs. URL string `query:"url" api:"required" json:"-"` paramObj } diff --git a/asset_test.go b/asset_test.go index 5ebe90c..d1c4b07 100644 --- a/asset_test.go +++ b/asset_test.go @@ -7,12 +7,14 @@ import ( "context" "errors" "io" + "net/http" + "net/http/httptest" "os" "testing" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestAssetDownload(t *testing.T) { @@ -40,18 +42,17 @@ func TestAssetDownload(t *testing.T) { } func TestAssetServe(t *testing.T) { - baseURL := "http://localhost:4010" - if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { - baseURL = envURL - } - if !testutil.CheckTestServer(t, baseURL) { - return - } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte("abc")) + })) + defer server.Close() + baseURL := server.URL client := beeperdesktopapi.NewClient( option.WithBaseURL(baseURL), option.WithAccessToken("My Access Token"), ) - err := client.Assets.Serve(context.TODO(), beeperdesktopapi.AssetServeParams{ + resp, err := client.Assets.Serve(context.TODO(), beeperdesktopapi.AssetServeParams{ URL: "x", }) if err != nil { @@ -61,6 +62,19 @@ func TestAssetServe(t *testing.T) { } t.Fatalf("err should be nil: %s", err.Error()) } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + var apierr *beeperdesktopapi.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } + if !bytes.Equal(b, []byte("abc")) { + t.Fatalf("return value not %s: %s", "abc", b) + } } func TestAssetUploadWithOptionalParams(t *testing.T) { diff --git a/beeperdesktopapi.go b/beeperdesktopapi.go index 5a97814..d50d11f 100644 --- a/beeperdesktopapi.go +++ b/beeperdesktopapi.go @@ -5,11 +5,11 @@ package beeperdesktopapi import ( "net/url" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/apiquery" - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/packages/respjson" - "github.com/beeper/desktop-api-go/shared" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/apiquery" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/respjson" + "github.com/beeper/desktop-api-go/v5/shared" ) // Response indicating successful app focus action. @@ -103,9 +103,9 @@ type FocusParams struct { // Optional Beeper chat ID (or local chat ID) to focus after opening the app. If // omitted, only opens/focuses the app. ChatID param.Opt[string] `json:"chatID,omitzero"` - // Optional draft attachment path to populate in the message input field. + // Optional image path to populate in the message input field. DraftAttachmentPath param.Opt[string] `json:"draftAttachmentPath,omitzero"` - // Optional draft text to populate in the message input field. + // Optional plain text to populate in the message input field. DraftText param.Opt[string] `json:"draftText,omitzero"` // Optional message ID. Jumps to that message in the chat when opening. MessageID param.Opt[string] `json:"messageID,omitzero"` diff --git a/beeperdesktopapi_test.go b/beeperdesktopapi_test.go index 3160381..b2fefa4 100644 --- a/beeperdesktopapi_test.go +++ b/beeperdesktopapi_test.go @@ -8,9 +8,9 @@ import ( "os" "testing" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestFocusWithOptionalParams(t *testing.T) { diff --git a/chat.go b/chat.go index 1e6a6f2..49060be 100644 --- a/chat.go +++ b/chat.go @@ -11,14 +11,14 @@ import ( "slices" "time" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/apiquery" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" - "github.com/beeper/desktop-api-go/packages/pagination" - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/packages/respjson" - "github.com/beeper/desktop-api-go/shared" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/apiquery" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/beeper/desktop-api-go/v5/packages/pagination" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/respjson" + "github.com/beeper/desktop-api-go/v5/shared" ) // Manage chats @@ -48,13 +48,12 @@ func NewChatService(opts ...option.RequestOption) (r ChatService) { return } -// Create a single/group chat (mode='create') or start a direct chat from merged -// user data (mode='start'). +// Create a direct or group chat from participant IDs. Returns the created chat. func (r *ChatService) New(ctx context.Context, body ChatNewParams, opts ...option.RequestOption) (res *ChatNewResponse, err error) { opts = slices.Concat(r.Options, opts) path := "v1/chats" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // Retrieve chat details including metadata, participants, and latest message @@ -62,11 +61,25 @@ func (r *ChatService) Get(ctx context.Context, chatID string, query ChatGetParam opts = slices.Concat(r.Options, opts) if chatID == "" { err = errors.New("missing required chatID parameter") - return + return nil, err } path := fmt.Sprintf("v1/chats/%s", chatID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) - return + return res, err +} + +// Update supported chat fields. Non-empty draft objects are accepted only when the +// current draft is empty. Send draft=null to clear the draft before setting new +// draft text or attachments. +func (r *ChatService) Update(ctx context.Context, chatID string, body ChatUpdateParams, opts ...option.RequestOption) (res *Chat, err error) { + opts = slices.Concat(r.Options, opts) + if chatID == "" { + err = errors.New("missing required chatID parameter") + return nil, err + } + path := fmt.Sprintf("v1/chats/%s", chatID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPatch, path, body, &res, opts...) + return res, err } // List all chats sorted by last activity (most recent first). Combines all @@ -101,15 +114,51 @@ func (r *ChatService) Archive(ctx context.Context, chatID string, body ChatArchi opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) if chatID == "" { err = errors.New("missing required chatID parameter") - return + return err } path := fmt.Sprintf("v1/chats/%s/archive", chatID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, nil, opts...) - return + return err +} + +// Mark a chat as read, optionally through a specific message ID. +func (r *ChatService) MarkRead(ctx context.Context, chatID string, body ChatMarkReadParams, opts ...option.RequestOption) (res *Chat, err error) { + opts = slices.Concat(r.Options, opts) + if chatID == "" { + err = errors.New("missing required chatID parameter") + return nil, err + } + path := fmt.Sprintf("v1/chats/%s/read", chatID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return res, err +} + +// Mark a chat as unread, optionally from a specific message ID. +func (r *ChatService) MarkUnread(ctx context.Context, chatID string, body ChatMarkUnreadParams, opts ...option.RequestOption) (res *Chat, err error) { + opts = slices.Concat(r.Options, opts) + if chatID == "" { + err = errors.New("missing required chatID parameter") + return nil, err + } + path := fmt.Sprintf("v1/chats/%s/unread", chatID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return res, err } -// Search chats by title/network or participants using Beeper Desktop's renderer -// algorithm. +// Force a delivery notification when supported by the underlying network. +// Currently intended for iMessage on macOS; unsupported networks return an error. +func (r *ChatService) NotifyAnyway(ctx context.Context, chatID string, body ChatNotifyAnywayParams, opts ...option.RequestOption) (res *Chat, err error) { + opts = slices.Concat(r.Options, opts) + if chatID == "" { + err = errors.New("missing required chatID parameter") + return nil, err + } + path := fmt.Sprintf("v1/chats/%s/notify-anyway", chatID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return res, err +} + +// Search chats by title, network, or participant names. func (r *ChatService) Search(ctx context.Context, query ChatSearchParams, opts ...option.RequestOption) (res *pagination.CursorSearch[Chat], err error) { var raw *http.Response opts = slices.Concat(r.Options, opts) @@ -127,17 +176,27 @@ func (r *ChatService) Search(ctx context.Context, query ChatSearchParams, opts . return res, nil } -// Search chats by title/network or participants using Beeper Desktop's renderer -// algorithm. +// Search chats by title, network, or participant names. func (r *ChatService) SearchAutoPaging(ctx context.Context, query ChatSearchParams, opts ...option.RequestOption) *pagination.CursorSearchAutoPager[Chat] { return pagination.NewCursorSearchAutoPager(r.Search(ctx, query, opts...)) } +// Resolve a user/contact and open a direct chat. Reuses and returns an existing +// direct chat when one is found. Available in Beeper Desktop v4.2.808+. +func (r *ChatService) Start(ctx context.Context, body ChatStartParams, opts ...option.RequestOption) (res *ChatStartResponse, err error) { + opts = slices.Concat(r.Options, opts) + path := "v1/chats/start" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return res, err +} + type Chat struct { // Unique identifier of the chat across Beeper. ID string `json:"id" api:"required"` // Account ID this chat belongs to. AccountID string `json:"accountID" api:"required"` + // Display-only human-readable account/network name. + Network string `json:"network" api:"required"` // Chat participants information. Participants ChatParticipants `json:"participants" api:"required"` // Display title of the chat as computed by the client/server. @@ -148,32 +207,66 @@ type Chat struct { Type ChatType `json:"type" api:"required"` // Number of unread messages. UnreadCount int64 `json:"unreadCount" api:"required"` + // Chat capabilities reported by the platform. + Capabilities ChatCapabilities `json:"capabilities"` + // Group chat description/topic when available. + Description string `json:"description" api:"nullable"` + // Current draft object for this chat, or null when no draft is set. + Draft ChatDraft `json:"draft" api:"nullable"` + // Local filesystem path to the chat avatar image when available. + ImgURL string `json:"imgURL" api:"nullable"` // True if chat is archived. IsArchived bool `json:"isArchived"` + // True if chat is marked low priority. + IsLowPriority bool `json:"isLowPriority"` + // True if the chat was explicitly marked unread by the authenticated user. + IsMarkedUnread bool `json:"isMarkedUnread"` // True if chat notifications are muted. IsMuted bool `json:"isMuted"` // True if chat is pinned. IsPinned bool `json:"isPinned"` + // True if messages cannot be sent in this chat. + IsReadOnly bool `json:"isReadOnly"` // Timestamp of last activity. LastActivity time.Time `json:"lastActivity" format:"date-time"` // Last read message sortKey. LastReadMessageSortKey string `json:"lastReadMessageSortKey"` // Local chat ID specific to this Beeper Desktop installation. LocalChatID string `json:"localChatID" api:"nullable"` + // Disappearing-message timer in seconds when available. + MessageExpirySeconds int64 `json:"messageExpirySeconds" api:"nullable"` + // Current reminder for this chat, or null when no reminder is set. + Reminder ChatReminder `json:"reminder" api:"nullable"` + // Current snooze state for this chat, or null when no snooze is set. + Snooze ChatSnooze `json:"snooze" api:"nullable"` + // Number of unread messages that mention the authenticated user or @room. + UnreadMentionsCount int64 `json:"unreadMentionsCount"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { ID respjson.Field AccountID respjson.Field + Network respjson.Field Participants respjson.Field Title respjson.Field Type respjson.Field UnreadCount respjson.Field + Capabilities respjson.Field + Description respjson.Field + Draft respjson.Field + ImgURL respjson.Field IsArchived respjson.Field + IsLowPriority respjson.Field + IsMarkedUnread respjson.Field IsMuted respjson.Field IsPinned respjson.Field + IsReadOnly respjson.Field LastActivity respjson.Field LastReadMessageSortKey respjson.Field LocalChatID respjson.Field + MessageExpirySeconds respjson.Field + Reminder respjson.Field + Snooze respjson.Field + UnreadMentionsCount respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` @@ -190,7 +283,7 @@ type ChatParticipants struct { // True if there are more participants than included in items. HasMore bool `json:"hasMore" api:"required"` // Participants returned for this chat (limited by the request; may be a subset). - Items []shared.User `json:"items" api:"required"` + Items []ChatParticipantsItem `json:"items" api:"required"` // Total number of participants in the chat. Total int64 `json:"total" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. @@ -209,6 +302,31 @@ func (r *ChatParticipants) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +// A chat participant. Extends User with chat membership metadata. +type ChatParticipantsItem struct { + // True if this participant has admin privileges in the chat. + IsAdmin bool `json:"isAdmin"` + // True if this participant represents a network or bridge bot. + IsNetworkBot bool `json:"isNetworkBot"` + // True if this participant has been invited but has not joined yet. + IsPending bool `json:"isPending"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + IsAdmin respjson.Field + IsNetworkBot respjson.Field + IsPending respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` + shared.User +} + +// Returns the unmodified JSON received from the API +func (r ChatParticipantsItem) RawJSON() string { return r.JSON.raw } +func (r *ChatParticipantsItem) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + // Chat type: 'single' for direct messages, 'group' for group chats. type ChatType string @@ -217,14 +335,521 @@ const ( ChatTypeGroup ChatType = "group" ) +// Chat capabilities reported by the platform. +type ChatCapabilities struct { + // Allowed Unicode reactions. Omitted means all emoji reactions are allowed. + AllowedReactions []string `json:"allowedReactions"` + // True if archive/unarchive is supported. + Archive bool `json:"archive"` + // Supported attachment message types and their per-type constraints, keyed by + // Matrix msgtype or pseudo-msgtype (for example m.image, m.video, + // org.matrix.msc3245.voice). Missing message types should be treated as rejected. + Attachments map[string]ChatCapabilitiesAttachment `json:"attachments"` + // True if custom emoji reactions are supported. + CustomEmojiReactions bool `json:"customEmojiReactions"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Delete int64 `json:"delete"` + // True if deleting chats for the authenticated user is supported. + DeleteChat bool `json:"deleteChat"` + // True if deleting chats for everyone is supported. + DeleteChatForEveryone bool `json:"deleteChatForEveryone"` + // True if deleting messages only for the authenticated user is supported. + DeleteForMe bool `json:"deleteForMe"` + // Maximum message age for delete-for-everyone, in seconds. + DeleteMaxAge int64 `json:"deleteMaxAge"` + // Disappearing-message timer capabilities. + DisappearingTimer ChatCapabilitiesDisappearingTimer `json:"disappearingTimer"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Edit int64 `json:"edit"` + // Maximum message age for edits, in seconds. + EditMaxAge int64 `json:"editMaxAge"` + // Maximum number of edits allowed for one message. + EditMaxCount int64 `json:"editMaxCount"` + // Supported rich-text formatting features keyed by feature name (for example bold, + // inline_code, code_block.syntax_highlighting). Omitted means no formatting + // support is advertised. + // + // Any of -2, -1, 0, 1, 2. + Formatting map[string]int64 `json:"formatting"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + LocationMessage int64 `json:"locationMessage"` + // True if marking chats unread is supported. + MarkAsUnread bool `json:"markAsUnread"` + // Maximum length of normal text messages. + MaxTextLength int64 `json:"maxTextLength"` + // Message request capabilities. + MessageRequest ChatCapabilitiesMessageRequest `json:"messageRequest"` + // Participant management capabilities. + ParticipantActions ChatCapabilitiesParticipantActions `json:"participantActions"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Poll int64 `json:"poll"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Reaction int64 `json:"reaction"` + // Maximum number of reactions allowed on a single message. + ReactionCount int64 `json:"reactionCount"` + // True if read receipts are supported. + ReadReceipts bool `json:"readReceipts"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Reply int64 `json:"reply"` + // Chat state update capabilities. + State ChatCapabilitiesState `json:"state"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Thread int64 `json:"thread"` + // True if typing notifications are supported. + TypingNotifications bool `json:"typingNotifications"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + AllowedReactions respjson.Field + Archive respjson.Field + Attachments respjson.Field + CustomEmojiReactions respjson.Field + Delete respjson.Field + DeleteChat respjson.Field + DeleteChatForEveryone respjson.Field + DeleteForMe respjson.Field + DeleteMaxAge respjson.Field + DisappearingTimer respjson.Field + Edit respjson.Field + EditMaxAge respjson.Field + EditMaxCount respjson.Field + Formatting respjson.Field + LocationMessage respjson.Field + MarkAsUnread respjson.Field + MaxTextLength respjson.Field + MessageRequest respjson.Field + ParticipantActions respjson.Field + Poll respjson.Field + Reaction respjson.Field + ReactionCount respjson.Field + ReadReceipts respjson.Field + Reply respjson.Field + State respjson.Field + Thread respjson.Field + TypingNotifications respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilities) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilities) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Capabilities for one attachment message type. +type ChatCapabilitiesAttachment struct { + // Supported MIME types or MIME patterns for this file message type. Missing MIME + // types should be treated as rejected. + // + // Any of -2, -1, 0, 1, 2. + MimeTypes map[string]int64 `json:"mimeTypes" api:"required"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Caption int64 `json:"caption"` + // Maximum caption length when captions are supported. + MaxCaptionLength int64 `json:"maxCaptionLength"` + // Maximum audio or video duration in seconds. + MaxDuration int64 `json:"maxDuration"` + // Maximum image or video height in pixels. + MaxHeight int64 `json:"maxHeight"` + // Maximum file size in bytes. + MaxSize int64 `json:"maxSize"` + // Maximum image or video width in pixels. + MaxWidth int64 `json:"maxWidth"` + // True if this file type can be sent as view-once media. + ViewOnce bool `json:"viewOnce"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + MimeTypes respjson.Field + Caption respjson.Field + MaxCaptionLength respjson.Field + MaxDuration respjson.Field + MaxHeight respjson.Field + MaxSize respjson.Field + MaxWidth respjson.Field + ViewOnce respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilitiesAttachment) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilitiesAttachment) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Disappearing-message timer capabilities. +type ChatCapabilitiesDisappearingTimer struct { + // True if empty timer objects should be omitted from message content. + OmitEmptyTimer bool `json:"omitEmptyTimer"` + // Allowed disappearing timer values in milliseconds. Omitted means any timer is + // allowed. + Timers []int64 `json:"timers"` + // Supported disappearing timer types. + // + // Any of "afterRead", "afterSend". + Types []string `json:"types"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + OmitEmptyTimer respjson.Field + Timers respjson.Field + Types respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilitiesDisappearingTimer) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilitiesDisappearingTimer) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Message request capabilities. +type ChatCapabilitiesMessageRequest struct { + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + AcceptWithButton int64 `json:"acceptWithButton"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + AcceptWithMessage int64 `json:"acceptWithMessage"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + AcceptWithButton respjson.Field + AcceptWithMessage respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilitiesMessageRequest) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilitiesMessageRequest) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Participant management capabilities. +type ChatCapabilitiesParticipantActions struct { + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Ban int64 `json:"ban"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Invite int64 `json:"invite"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Kick int64 `json:"kick"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Leave int64 `json:"leave"` + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + RevokeInvite int64 `json:"revokeInvite"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Ban respjson.Field + Invite respjson.Field + Kick respjson.Field + Leave respjson.Field + RevokeInvite respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilitiesParticipantActions) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilitiesParticipantActions) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Chat state update capabilities. +type ChatCapabilitiesState struct { + // Chat avatar state capability. + Avatar ChatCapabilitiesStateAvatar `json:"avatar"` + // Chat description/topic state capability. + Description ChatCapabilitiesStateDescription `json:"description"` + // Disappearing-message timer state capability. + DisappearingTimer ChatCapabilitiesStateDisappearingTimer `json:"disappearingTimer"` + // Chat title state capability. + Title ChatCapabilitiesStateTitle `json:"title"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Avatar respjson.Field + Description respjson.Field + DisappearingTimer respjson.Field + Title respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilitiesState) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilitiesState) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Chat avatar state capability. +type ChatCapabilitiesStateAvatar struct { + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Level int64 `json:"level" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Level respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilitiesStateAvatar) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilitiesStateAvatar) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Chat description/topic state capability. +type ChatCapabilitiesStateDescription struct { + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Level int64 `json:"level" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Level respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilitiesStateDescription) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilitiesStateDescription) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Disappearing-message timer state capability. +type ChatCapabilitiesStateDisappearingTimer struct { + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Level int64 `json:"level" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Level respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilitiesStateDisappearingTimer) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilitiesStateDisappearingTimer) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Chat title state capability. +type ChatCapabilitiesStateTitle struct { + // -2: rejected, -1: dropped, 0: unsupported, 1: partially supported, 2: fully + // supported. + // + // Any of -2, -1, 0, 1, 2. + Level int64 `json:"level" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Level respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatCapabilitiesStateTitle) RawJSON() string { return r.JSON.raw } +func (r *ChatCapabilitiesStateTitle) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Current draft object for this chat, or null when no draft is set. +type ChatDraft struct { + // Matrix HTML draft body. + Text string `json:"text" api:"required"` + // Draft attachments keyed by attachment ID. + Attachments map[string]ChatDraftAttachment `json:"attachments"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Text respjson.Field + Attachments respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatDraft) RawJSON() string { return r.JSON.raw } +func (r *ChatDraft) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type ChatDraftAttachment struct { + // Draft attachment identifier. + ID string `json:"id" api:"required"` + // Draft attachment type. GIF and recorded audio are mutually exclusive types. + // + // Any of "file", "gif", "recorded_audio". + Type string `json:"type" api:"required"` + // Audio duration in seconds if known. + AudioDurationSeconds float64 `json:"audioDurationSeconds"` + // Original filename if available. + FileName string `json:"fileName"` + // Local filesystem path for the draft attachment. + FilePath string `json:"filePath"` + // File size in bytes if known. + FileSize float64 `json:"fileSize"` + // MIME type if known. + MimeType string `json:"mimeType"` + // Pixel dimensions of the attachment. + Size ChatDraftAttachmentSize `json:"size"` + // Sticker identifier if the draft attachment is a sticker. + StickerID string `json:"stickerID"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + ID respjson.Field + Type respjson.Field + AudioDurationSeconds respjson.Field + FileName respjson.Field + FilePath respjson.Field + FileSize respjson.Field + MimeType respjson.Field + Size respjson.Field + StickerID respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatDraftAttachment) RawJSON() string { return r.JSON.raw } +func (r *ChatDraftAttachment) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Pixel dimensions of the attachment. +type ChatDraftAttachmentSize struct { + Height float64 `json:"height"` + Width float64 `json:"width"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Height respjson.Field + Width respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatDraftAttachmentSize) RawJSON() string { return r.JSON.raw } +func (r *ChatDraftAttachmentSize) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Current reminder for this chat, or null when no reminder is set. +type ChatReminder struct { + // Cancel reminder if someone messages in the chat. + DismissOnIncomingMessage bool `json:"dismissOnIncomingMessage"` + // Timestamp when the reminder should trigger. + RemindAt time.Time `json:"remindAt" format:"date-time"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + DismissOnIncomingMessage respjson.Field + RemindAt respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatReminder) RawJSON() string { return r.JSON.raw } +func (r *ChatReminder) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Current snooze state for this chat, or null when no snooze is set. +type ChatSnooze struct { + // Timestamp when the snooze expires. + SnoozeUntil time.Time `json:"snoozeUntil" format:"date-time"` + // Timestamp when the user set the snooze. + UserSnoozedAt time.Time `json:"userSnoozedAt" format:"date-time"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + SnoozeUntil respjson.Field + UserSnoozedAt respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r ChatSnooze) RawJSON() string { return r.JSON.raw } +func (r *ChatSnooze) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type ChatNewResponse struct { - // Newly created chat ID. + // DEPRECATED - use id instead. Compatibility alias for older clients. + // + // Deprecated: deprecated ChatID string `json:"chatID" api:"required"` - // Only returned in start mode. 'existing' means an existing chat was reused; - // 'created' means a new chat was created. + // DEPRECATED - legacy start-chat status for older clients. New clients should + // inspect the returned Chat instead. // // Any of "existing", "created". - Status ChatNewResponseStatus `json:"status"` + // + // Deprecated: deprecated + Status string `json:"status"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { ChatID respjson.Field @@ -232,6 +857,7 @@ type ChatNewResponse struct { ExtraFields map[string]respjson.Field raw string } `json:"-"` + Chat } // Returns the unmodified JSON received from the API @@ -240,15 +866,7 @@ func (r *ChatNewResponse) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } -// Only returned in start mode. 'existing' means an existing chat was reused; -// 'created' means a new chat was created. -type ChatNewResponseStatus string - -const ( - ChatNewResponseStatusExisting ChatNewResponseStatus = "existing" - ChatNewResponseStatusCreated ChatNewResponseStatus = "created" -) - +// Chat with optional last message preview. type ChatListResponse struct { // Last message preview for this chat, if available. Preview shared.Message `json:"preview"` @@ -267,31 +885,48 @@ func (r *ChatListResponse) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +type ChatStartResponse struct { + // DEPRECATED - use id instead. Compatibility alias for older clients. + // + // Deprecated: deprecated + ChatID string `json:"chatID" api:"required"` + // DEPRECATED - legacy start-chat status for older clients. New clients should + // inspect the returned Chat instead. + // + // Any of "existing", "created". + // + // Deprecated: deprecated + Status string `json:"status"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + ChatID respjson.Field + Status respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` + Chat +} + +// Returns the unmodified JSON received from the API +func (r ChatStartResponse) RawJSON() string { return r.JSON.raw } +func (r *ChatStartResponse) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type ChatNewParams struct { // Account to create or start the chat on. AccountID string `json:"accountID" api:"required"` - // Whether invite-based DM creation is allowed when required by the platform. Used - // for mode='start'. - AllowInvite param.Opt[bool] `json:"allowInvite,omitzero"` + // User IDs to include in the new chat. + ParticipantIDs []string `json:"participantIDs,omitzero" api:"required"` + // 'single' requires exactly one participantID; 'group' supports multiple + // participants and optional title. + // + // Any of "single", "group". + Type ChatNewParamsType `json:"type,omitzero" api:"required"` // Optional first message content if the platform requires it to create the chat. MessageText param.Opt[string] `json:"messageText,omitzero"` - // Optional title for group chats when mode='create'; ignored for single chats on - // most platforms. + // Optional title for group chats; ignored for single chats on most networks. Title param.Opt[string] `json:"title,omitzero"` - // Operation mode. Defaults to 'create' when omitted. - // - // Any of "create", "start". - Mode ChatNewParamsMode `json:"mode,omitzero"` - // Required when mode='create'. User IDs to include in the new chat. - ParticipantIDs []string `json:"participantIDs,omitzero"` - // Required when mode='create'. 'single' requires exactly one participantID; - // 'group' supports multiple participants and optional title. - // - // Any of "single", "group". - Type ChatNewParamsType `json:"type,omitzero"` - // Required when mode='start'. Merged user-like contact payload used to resolve the - // best identifier. - User ChatNewParamsUser `json:"user,omitzero"` paramObj } @@ -303,16 +938,8 @@ func (r *ChatNewParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } -// Operation mode. Defaults to 'create' when omitted. -type ChatNewParamsMode string - -const ( - ChatNewParamsModeCreate ChatNewParamsMode = "create" - ChatNewParamsModeStart ChatNewParamsMode = "start" -) - -// Required when mode='create'. 'single' requires exactly one participantID; -// 'group' supports multiple participants and optional title. +// 'single' requires exactly one participantID; 'group' supports multiple +// participants and optional title. type ChatNewParamsType string const ( @@ -320,43 +947,130 @@ const ( ChatNewParamsTypeGroup ChatNewParamsType = "group" ) -// Required when mode='start'. Merged user-like contact payload used to resolve the -// best identifier. -type ChatNewParamsUser struct { - // Known user ID when available. +type ChatGetParams struct { + // Maximum number of participants to return. Use -1 for all; otherwise 0-500. + // Defaults to 100. List and search endpoints return up to 20 participants per + // chat. + MaxParticipantCount param.Opt[int64] `query:"maxParticipantCount,omitzero" json:"-"` + paramObj +} + +// URLQuery serializes [ChatGetParams]'s query parameters as `url.Values`. +func (r ChatGetParams) URLQuery() (v url.Values, err error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatRepeat, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} + +type ChatUpdateParams struct { + // Group chat description/topic. Support depends on the chat account and chat + // permissions. + Description param.Opt[string] `json:"description,omitzero"` + // Local filesystem path to a group chat avatar image. Support depends on the chat + // account and chat permissions. + ImgURL param.Opt[string] `json:"imgURL,omitzero"` + // Disappearing-message timer in seconds, or null to clear when supported. + MessageExpirySeconds param.Opt[int64] `json:"messageExpirySeconds,omitzero"` + // Custom chat title. Support depends on the chat account and chat permissions. + Title param.Opt[string] `json:"title,omitzero"` + // Archive or unarchive the chat. + IsArchived param.Opt[bool] `json:"isArchived,omitzero"` + // Mark or unmark the chat as low priority when supported by the account. + IsLowPriority param.Opt[bool] `json:"isLowPriority,omitzero"` + // Mute or unmute the chat. + IsMuted param.Opt[bool] `json:"isMuted,omitzero"` + // Pin or unpin the chat when supported by the account. + IsPinned param.Opt[bool] `json:"isPinned,omitzero"` + // Draft object to set or clear. Non-empty drafts are only accepted when the + // current draft is empty. Send draft=null to clear text and attachments together + // before setting a new draft. + Draft ChatUpdateParamsDraft `json:"draft,omitzero"` + paramObj +} + +func (r ChatUpdateParams) MarshalJSON() (data []byte, err error) { + type shadow ChatUpdateParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ChatUpdateParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Draft object to set or clear. Non-empty drafts are only accepted when the +// current draft is empty. Send draft=null to clear text and attachments together +// before setting a new draft. +// +// The property Text is required. +type ChatUpdateParamsDraft struct { + // Draft text. Plain text and Markdown are converted to Matrix HTML with the same + // rules used by send and edit. + Text string `json:"text" api:"required"` + // Draft attachments keyed by attachment ID. Each attachment must reference an + // uploadID returned by the upload file endpoint. + Attachments map[string]ChatUpdateParamsDraftAttachment `json:"attachments,omitzero"` + paramObj +} + +func (r ChatUpdateParamsDraft) MarshalJSON() (data []byte, err error) { + type shadow ChatUpdateParamsDraft + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ChatUpdateParamsDraft) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// The property UploadID is required. +type ChatUpdateParamsDraftAttachment struct { + // Upload ID from uploadAsset endpoint. Required to reference uploaded files. + UploadID string `json:"uploadID" api:"required"` + // Optional draft attachment identifier. If omitted, a new identifier is generated. ID param.Opt[string] `json:"id,omitzero"` - // Email candidate. - Email param.Opt[string] `json:"email,omitzero"` - // Display name hint used for ranking only. - FullName param.Opt[string] `json:"fullName,omitzero"` - // Phone number candidate (E.164 preferred). - PhoneNumber param.Opt[string] `json:"phoneNumber,omitzero"` - // Username/handle candidate. - Username param.Opt[string] `json:"username,omitzero"` + // Duration in seconds (optional override of cached value) + Duration param.Opt[float64] `json:"duration,omitzero"` + // Filename (optional override of cached value) + FileName param.Opt[string] `json:"fileName,omitzero"` + // MIME type (optional override of cached value) + MimeType param.Opt[string] `json:"mimeType,omitzero"` + // Dimensions (optional override of cached value) + Size ChatUpdateParamsDraftAttachmentSize `json:"size,omitzero"` + // Attachment type hint (image, video, audio, file, gif, voice-note, sticker). If + // omitted, auto-detected from mimeType + // + // Any of "image", "video", "audio", "file", "gif", "voice-note", "sticker". + Type string `json:"type,omitzero"` paramObj } -func (r ChatNewParamsUser) MarshalJSON() (data []byte, err error) { - type shadow ChatNewParamsUser +func (r ChatUpdateParamsDraftAttachment) MarshalJSON() (data []byte, err error) { + type shadow ChatUpdateParamsDraftAttachment return param.MarshalObject(r, (*shadow)(&r)) } -func (r *ChatNewParamsUser) UnmarshalJSON(data []byte) error { +func (r *ChatUpdateParamsDraftAttachment) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } -type ChatGetParams struct { - // Maximum number of participants to return. Use -1 for all; otherwise 0–500. - // Defaults to all (-1). - MaxParticipantCount param.Opt[int64] `query:"maxParticipantCount,omitzero" json:"-"` +func init() { + apijson.RegisterFieldValidator[ChatUpdateParamsDraftAttachment]( + "type", "image", "video", "audio", "file", "gif", "voice-note", "sticker", + ) +} + +// Dimensions (optional override of cached value) +// +// The properties Height, Width are required. +type ChatUpdateParamsDraftAttachmentSize struct { + Height float64 `json:"height" api:"required"` + Width float64 `json:"width" api:"required"` paramObj } -// URLQuery serializes [ChatGetParams]'s query parameters as `url.Values`. -func (r ChatGetParams) URLQuery() (v url.Values, err error) { - return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ - ArrayFormat: apiquery.ArrayQueryFormatRepeat, - NestedFormat: apiquery.NestedQueryFormatBrackets, - }) +func (r ChatUpdateParamsDraftAttachmentSize) MarshalJSON() (data []byte, err error) { + type shadow ChatUpdateParamsDraftAttachmentSize + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ChatUpdateParamsDraftAttachmentSize) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) } type ChatListParams struct { @@ -403,6 +1117,46 @@ func (r *ChatArchiveParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +type ChatMarkReadParams struct { + // Optional message ID to mark read through. + MessageID param.Opt[string] `json:"messageID,omitzero"` + paramObj +} + +func (r ChatMarkReadParams) MarshalJSON() (data []byte, err error) { + type shadow ChatMarkReadParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ChatMarkReadParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type ChatMarkUnreadParams struct { + // Optional message ID to mark unread from. + MessageID param.Opt[string] `json:"messageID,omitzero"` + paramObj +} + +func (r ChatMarkUnreadParams) MarshalJSON() (data []byte, err error) { + type shadow ChatMarkUnreadParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ChatMarkUnreadParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type ChatNotifyAnywayParams struct { + paramObj +} + +func (r ChatNotifyAnywayParams) MarshalJSON() (data []byte, err error) { + type shadow ChatNotifyAnywayParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ChatNotifyAnywayParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type ChatSearchParams struct { // Include chats marked as Muted by the user, which are usually less important. // Default: true. Set to false if the user wants a more refined search. @@ -493,3 +1247,46 @@ const ( ChatSearchParamsTypeGroup ChatSearchParamsType = "group" ChatSearchParamsTypeAny ChatSearchParamsType = "any" ) + +type ChatStartParams struct { + // Account to create or start the chat on. + AccountID string `json:"accountID" api:"required"` + // Merged user-like contact payload used to resolve the best identifier. + User ChatStartParamsUser `json:"user,omitzero" api:"required"` + // Whether invite-based DM creation is allowed when required by the platform. + AllowInvite param.Opt[bool] `json:"allowInvite,omitzero"` + // Optional first message content if the platform requires it to create the chat. + MessageText param.Opt[string] `json:"messageText,omitzero"` + paramObj +} + +func (r ChatStartParams) MarshalJSON() (data []byte, err error) { + type shadow ChatStartParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ChatStartParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Merged user-like contact payload used to resolve the best identifier. +type ChatStartParamsUser struct { + // Known user ID when available. + ID param.Opt[string] `json:"id,omitzero"` + // Email candidate. + Email param.Opt[string] `json:"email,omitzero"` + // Display name hint used for ranking only. + FullName param.Opt[string] `json:"fullName,omitzero"` + // Phone number candidate (E.164 preferred). + PhoneNumber param.Opt[string] `json:"phoneNumber,omitzero"` + // Username/handle candidate. + Username param.Opt[string] `json:"username,omitzero"` + paramObj +} + +func (r ChatStartParamsUser) MarshalJSON() (data []byte, err error) { + type shadow ChatStartParamsUser + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ChatStartParamsUser) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} diff --git a/chat_test.go b/chat_test.go index 9e32411..61f7344 100644 --- a/chat_test.go +++ b/chat_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestChatNewWithOptionalParams(t *testing.T) { @@ -28,19 +28,10 @@ func TestChatNewWithOptionalParams(t *testing.T) { ) _, err := client.Chats.New(context.TODO(), beeperdesktopapi.ChatNewParams{ AccountID: "accountID", - AllowInvite: beeperdesktopapi.Bool(true), - MessageText: beeperdesktopapi.String("messageText"), - Mode: beeperdesktopapi.ChatNewParamsModeCreate, ParticipantIDs: []string{"string"}, - Title: beeperdesktopapi.String("title"), Type: beeperdesktopapi.ChatNewParamsTypeSingle, - User: beeperdesktopapi.ChatNewParamsUser{ - ID: beeperdesktopapi.String("id"), - Email: beeperdesktopapi.String("email"), - FullName: beeperdesktopapi.String("fullName"), - PhoneNumber: beeperdesktopapi.String("phoneNumber"), - Username: beeperdesktopapi.String("username"), - }, + MessageText: beeperdesktopapi.String("messageText"), + Title: beeperdesktopapi.String("title"), }) if err != nil { var apierr *beeperdesktopapi.Error @@ -67,7 +58,59 @@ func TestChatGetWithOptionalParams(t *testing.T) { context.TODO(), "!NCdzlIaMjZUmvmvyHU:beeper.com", beeperdesktopapi.ChatGetParams{ - MaxParticipantCount: beeperdesktopapi.Int(50), + MaxParticipantCount: beeperdesktopapi.Int(100), + }, + ) + if err != nil { + var apierr *beeperdesktopapi.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestChatUpdateWithOptionalParams(t *testing.T) { + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := beeperdesktopapi.NewClient( + option.WithBaseURL(baseURL), + option.WithAccessToken("My Access Token"), + ) + _, err := client.Chats.Update( + context.TODO(), + "!NCdzlIaMjZUmvmvyHU:beeper.com", + beeperdesktopapi.ChatUpdateParams{ + Description: beeperdesktopapi.String("description"), + Draft: beeperdesktopapi.ChatUpdateParamsDraft{ + Text: "text", + Attachments: map[string]beeperdesktopapi.ChatUpdateParamsDraftAttachment{ + "foo": { + UploadID: "uploadID", + ID: beeperdesktopapi.String("id"), + Duration: beeperdesktopapi.Float(0), + FileName: beeperdesktopapi.String("fileName"), + MimeType: beeperdesktopapi.String("mimeType"), + Size: beeperdesktopapi.ChatUpdateParamsDraftAttachmentSize{ + Height: 0, + Width: 0, + }, + Type: "image", + }, + }, + }, + ImgURL: beeperdesktopapi.String("imgURL"), + IsArchived: beeperdesktopapi.Bool(true), + IsLowPriority: beeperdesktopapi.Bool(true), + IsMuted: beeperdesktopapi.Bool(true), + IsPinned: beeperdesktopapi.Bool(true), + MessageExpirySeconds: beeperdesktopapi.Int(0), + Title: beeperdesktopapi.String("title"), }, ) if err != nil { @@ -92,7 +135,7 @@ func TestChatListWithOptionalParams(t *testing.T) { option.WithAccessToken("My Access Token"), ) _, err := client.Chats.List(context.TODO(), beeperdesktopapi.ChatListParams{ - AccountIDs: []string{"local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU"}, + AccountIDs: []string{"matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"}, Cursor: beeperdesktopapi.String("1725489123456|c29tZUltc2dQYWdl"), Direction: beeperdesktopapi.ChatListParamsDirectionBefore, }) @@ -133,6 +176,88 @@ func TestChatArchiveWithOptionalParams(t *testing.T) { } } +func TestChatMarkReadWithOptionalParams(t *testing.T) { + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := beeperdesktopapi.NewClient( + option.WithBaseURL(baseURL), + option.WithAccessToken("My Access Token"), + ) + _, err := client.Chats.MarkRead( + context.TODO(), + "!NCdzlIaMjZUmvmvyHU:beeper.com", + beeperdesktopapi.ChatMarkReadParams{ + MessageID: beeperdesktopapi.String("1343993"), + }, + ) + if err != nil { + var apierr *beeperdesktopapi.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestChatMarkUnreadWithOptionalParams(t *testing.T) { + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := beeperdesktopapi.NewClient( + option.WithBaseURL(baseURL), + option.WithAccessToken("My Access Token"), + ) + _, err := client.Chats.MarkUnread( + context.TODO(), + "!NCdzlIaMjZUmvmvyHU:beeper.com", + beeperdesktopapi.ChatMarkUnreadParams{ + MessageID: beeperdesktopapi.String("1343993"), + }, + ) + if err != nil { + var apierr *beeperdesktopapi.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestChatNotifyAnyway(t *testing.T) { + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := beeperdesktopapi.NewClient( + option.WithBaseURL(baseURL), + option.WithAccessToken("My Access Token"), + ) + _, err := client.Chats.NotifyAnyway( + context.TODO(), + "!NCdzlIaMjZUmvmvyHU:beeper.com", + beeperdesktopapi.ChatNotifyAnywayParams{}, + ) + if err != nil { + var apierr *beeperdesktopapi.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + func TestChatSearchWithOptionalParams(t *testing.T) { baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -146,7 +271,7 @@ func TestChatSearchWithOptionalParams(t *testing.T) { option.WithAccessToken("My Access Token"), ) _, err := client.Chats.Search(context.TODO(), beeperdesktopapi.ChatSearchParams{ - AccountIDs: []string{"local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"}, + AccountIDs: []string{"matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"}, Cursor: beeperdesktopapi.String("1725489123456|c29tZUltc2dQYWdl"), Direction: beeperdesktopapi.ChatSearchParamsDirectionBefore, Inbox: beeperdesktopapi.ChatSearchParamsInboxPrimary, @@ -167,3 +292,36 @@ func TestChatSearchWithOptionalParams(t *testing.T) { t.Fatalf("err should be nil: %s", err.Error()) } } + +func TestChatStartWithOptionalParams(t *testing.T) { + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := beeperdesktopapi.NewClient( + option.WithBaseURL(baseURL), + option.WithAccessToken("My Access Token"), + ) + _, err := client.Chats.Start(context.TODO(), beeperdesktopapi.ChatStartParams{ + AccountID: "accountID", + User: beeperdesktopapi.ChatStartParamsUser{ + ID: beeperdesktopapi.String("id"), + Email: beeperdesktopapi.String("email"), + FullName: beeperdesktopapi.String("fullName"), + PhoneNumber: beeperdesktopapi.String("phoneNumber"), + Username: beeperdesktopapi.String("username"), + }, + AllowInvite: beeperdesktopapi.Bool(true), + MessageText: beeperdesktopapi.String("messageText"), + }) + if err != nil { + var apierr *beeperdesktopapi.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} diff --git a/chatmessage.go b/chatmessage.go index 90c990e..86afd8a 100644 --- a/chatmessage.go +++ b/chatmessage.go @@ -3,7 +3,7 @@ package beeperdesktopapi import ( - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5/option" ) // Manage chat messages diff --git a/chatmessagereaction.go b/chatmessagereaction.go index f53de17..141ed76 100644 --- a/chatmessagereaction.go +++ b/chatmessagereaction.go @@ -7,15 +7,13 @@ import ( "errors" "fmt" "net/http" - "net/url" "slices" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/apiquery" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/respjson" ) // Manage message reactions @@ -39,20 +37,24 @@ func NewChatMessageReactionService(opts ...option.RequestOption) (r ChatMessageR return } -// Remove the authenticated user's reaction from an existing message. -func (r *ChatMessageReactionService) Delete(ctx context.Context, messageID string, params ChatMessageReactionDeleteParams, opts ...option.RequestOption) (res *ChatMessageReactionDeleteResponse, err error) { +// Remove the reaction added by the authenticated user from an existing message. +func (r *ChatMessageReactionService) Delete(ctx context.Context, reactionKey string, body ChatMessageReactionDeleteParams, opts ...option.RequestOption) (res *ChatMessageReactionDeleteResponse, err error) { opts = slices.Concat(r.Options, opts) - if params.ChatID == "" { + if body.ChatID == "" { err = errors.New("missing required chatID parameter") - return + return nil, err } - if messageID == "" { + if body.MessageID == "" { err = errors.New("missing required messageID parameter") - return + return nil, err } - path := fmt.Sprintf("v1/chats/%s/messages/%s/reactions", params.ChatID, messageID) - err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, params, &res, opts...) - return + if reactionKey == "" { + err = errors.New("missing required reactionKey parameter") + return nil, err + } + path := fmt.Sprintf("v1/chats/%s/messages/%s/reactions/%s", body.ChatID, body.MessageID, reactionKey) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, &res, opts...) + return res, err } // Add a reaction to an existing message. @@ -60,25 +62,27 @@ func (r *ChatMessageReactionService) Add(ctx context.Context, messageID string, opts = slices.Concat(r.Options, opts) if params.ChatID == "" { err = errors.New("missing required chatID parameter") - return + return nil, err } if messageID == "" { err = errors.New("missing required messageID parameter") - return + return nil, err } path := fmt.Sprintf("v1/chats/%s/messages/%s/reactions", params.ChatID, messageID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, params, &res, opts...) - return + return res, err } type ChatMessageReactionDeleteResponse struct { - // Unique identifier of the chat. + // Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + // installation when available. ChatID string `json:"chatID" api:"required"` // Message ID. MessageID string `json:"messageID" api:"required"` - // Reaction key that was removed + // Reaction key that was removed. ReactionKey string `json:"reactionKey" api:"required"` - // Whether the reaction was successfully removed + // Always true. Indicates the reaction removal was queued; failures return an error + // response. Success bool `json:"success" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -98,15 +102,17 @@ func (r *ChatMessageReactionDeleteResponse) UnmarshalJSON(data []byte) error { } type ChatMessageReactionAddResponse struct { - // Unique identifier of the chat. + // Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + // installation when available. ChatID string `json:"chatID" api:"required"` // Message ID. MessageID string `json:"messageID" api:"required"` - // Reaction key that was added + // Reaction key that was added. ReactionKey string `json:"reactionKey" api:"required"` - // Whether the reaction was successfully added + // Always true. Indicates the reaction was queued; failures return an error + // response. Success bool `json:"success" api:"required"` - // Transaction ID used for the reaction event + // Transaction ID used for send tracking. TransactionID string `json:"transactionID" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -127,28 +133,21 @@ func (r *ChatMessageReactionAddResponse) UnmarshalJSON(data []byte) error { } type ChatMessageReactionDeleteParams struct { - // Unique identifier of the chat. + // Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + // installation when available. ChatID string `path:"chatID" api:"required" json:"-"` - // Reaction key to remove - ReactionKey string `query:"reactionKey" api:"required" json:"-"` + // Message ID. + MessageID string `path:"messageID" api:"required" json:"-"` paramObj } -// URLQuery serializes [ChatMessageReactionDeleteParams]'s query parameters as -// `url.Values`. -func (r ChatMessageReactionDeleteParams) URLQuery() (v url.Values, err error) { - return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ - ArrayFormat: apiquery.ArrayQueryFormatRepeat, - NestedFormat: apiquery.NestedQueryFormatBrackets, - }) -} - type ChatMessageReactionAddParams struct { - // Unique identifier of the chat. + // Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + // installation when available. ChatID string `path:"chatID" api:"required" json:"-"` // Reaction key to add (emoji, shortcode, or custom emoji key) ReactionKey string `json:"reactionKey" api:"required"` - // Optional transaction ID for deduplication and local echo tracking + // Optional transaction ID for deduplication and send tracking TransactionID param.Opt[string] `json:"transactionID,omitzero"` paramObj } diff --git a/chatmessagereaction_test.go b/chatmessagereaction_test.go index d67651f..71a25d6 100644 --- a/chatmessagereaction_test.go +++ b/chatmessagereaction_test.go @@ -8,9 +8,9 @@ import ( "os" "testing" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestChatMessageReactionDelete(t *testing.T) { @@ -27,10 +27,10 @@ func TestChatMessageReactionDelete(t *testing.T) { ) _, err := client.Chats.Messages.Reactions.Delete( context.TODO(), - "messageID", + "x", beeperdesktopapi.ChatMessageReactionDeleteParams{ - ChatID: "!NCdzlIaMjZUmvmvyHU:beeper.com", - ReactionKey: "x", + ChatID: "!NCdzlIaMjZUmvmvyHU:beeper.com", + MessageID: "1343993", }, ) if err != nil { @@ -56,7 +56,7 @@ func TestChatMessageReactionAddWithOptionalParams(t *testing.T) { ) _, err := client.Chats.Messages.Reactions.Add( context.TODO(), - "messageID", + "1343993", beeperdesktopapi.ChatMessageReactionAddParams{ ChatID: "!NCdzlIaMjZUmvmvyHU:beeper.com", ReactionKey: "x", diff --git a/chatreminder.go b/chatreminder.go index c6b979b..afcc735 100644 --- a/chatreminder.go +++ b/chatreminder.go @@ -8,11 +8,12 @@ import ( "fmt" "net/http" "slices" + "time" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/beeper/desktop-api-go/v5/packages/param" ) // Manage reminders for chats @@ -42,11 +43,11 @@ func (r *ChatReminderService) New(ctx context.Context, chatID string, body ChatR opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) if chatID == "" { err = errors.New("missing required chatID parameter") - return + return err } path := fmt.Sprintf("v1/chats/%s/reminders", chatID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, nil, opts...) - return + return err } // Clear an existing reminder from a chat @@ -55,11 +56,11 @@ func (r *ChatReminderService) Delete(ctx context.Context, chatID string, opts .. opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) if chatID == "" { err = errors.New("missing required chatID parameter") - return + return err } path := fmt.Sprintf("v1/chats/%s/reminders", chatID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) - return + return err } type ChatReminderNewParams struct { @@ -78,10 +79,10 @@ func (r *ChatReminderNewParams) UnmarshalJSON(data []byte) error { // Reminder configuration // -// The property RemindAtMs is required. +// The property RemindAt is required. type ChatReminderNewParamsReminder struct { - // Unix timestamp in milliseconds when reminder should trigger - RemindAtMs float64 `json:"remindAtMs" api:"required"` + // Timestamp when the reminder should trigger. + RemindAt time.Time `json:"remindAt" api:"required" format:"date-time"` // Cancel reminder if someone messages in the chat DismissOnIncomingMessage param.Opt[bool] `json:"dismissOnIncomingMessage,omitzero"` paramObj diff --git a/chatreminder_test.go b/chatreminder_test.go index cc2c7fd..76ed918 100644 --- a/chatreminder_test.go +++ b/chatreminder_test.go @@ -7,10 +7,11 @@ import ( "errors" "os" "testing" + "time" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestChatReminderNewWithOptionalParams(t *testing.T) { @@ -30,7 +31,7 @@ func TestChatReminderNewWithOptionalParams(t *testing.T) { "!NCdzlIaMjZUmvmvyHU:beeper.com", beeperdesktopapi.ChatReminderNewParams{ Reminder: beeperdesktopapi.ChatReminderNewParamsReminder{ - RemindAtMs: 0, + RemindAt: time.Now(), DismissOnIncomingMessage: beeperdesktopapi.Bool(true), }, }, diff --git a/client.go b/client.go index d05c0b7..ec43f74 100644 --- a/client.go +++ b/client.go @@ -7,9 +7,10 @@ import ( "net/http" "os" "slices" + "strings" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" ) // Client creates a struct with services and top level methods that help with @@ -25,26 +26,35 @@ type Client struct { Messages MessageService // Manage assets in Beeper Desktop, like message attachments Assets AssetService - // Control the Beeper Desktop application + // Server discovery and capability metadata. Use /v1/info before authentication + // setup. Info InfoService } // DefaultClientOptions read from the environment (BEEPER_ACCESS_TOKEN, -// BEEPER_DESKTOP_BASE_URL). This should be used to initialize new clients. +// BEEPER_BASE_URL). This should be used to initialize new clients. func DefaultClientOptions() []option.RequestOption { - defaults := []option.RequestOption{option.WithEnvironmentLocal()} - if o, ok := os.LookupEnv("BEEPER_DESKTOP_BASE_URL"); ok { + defaults := []option.RequestOption{option.WithHTTPClient(defaultHTTPClient()), option.WithEnvironmentLocal()} + if o, ok := os.LookupEnv("BEEPER_BASE_URL"); ok { defaults = append(defaults, option.WithBaseURL(o)) } if o, ok := os.LookupEnv("BEEPER_ACCESS_TOKEN"); ok { defaults = append(defaults, option.WithAccessToken(o)) } + if o, ok := os.LookupEnv("BEEPER_CUSTOM_HEADERS"); ok { + for _, line := range strings.Split(o, "\n") { + colon := strings.Index(line, ":") + if colon >= 0 { + defaults = append(defaults, option.WithHeader(strings.TrimSpace(line[:colon]), strings.TrimSpace(line[colon+1:]))) + } + } + } return defaults } // NewClient generates a new client with the default option read from the -// environment (BEEPER_ACCESS_TOKEN, BEEPER_DESKTOP_BASE_URL). The option passed in -// as arguments are applied after these default arguments, and all option will be +// environment (BEEPER_ACCESS_TOKEN, BEEPER_BASE_URL). The option passed in as +// arguments are applied after these default arguments, and all option will be // passed down to the services and requests that this client makes. func NewClient(opts ...option.RequestOption) (r Client) { opts = append(DefaultClientOptions(), opts...) @@ -130,12 +140,12 @@ func (r *Client) Delete(ctx context.Context, path string, params any, res any, o } // Focus Beeper Desktop and optionally navigate to a specific chat, message, or -// pre-fill draft text and attachment. +// pre-fill plain text and an image path. func (r *Client) Focus(ctx context.Context, body FocusParams, opts ...option.RequestOption) (res *FocusResponse, err error) { opts = slices.Concat(r.Options, opts) path := "v1/focus" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // Returns matching chats, participant name matches in groups, and the first page @@ -145,5 +155,5 @@ func (r *Client) Search(ctx context.Context, query SearchParams, opts ...option. opts = slices.Concat(r.Options, opts) path := "v1/search" err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) - return + return res, err } diff --git a/client_test.go b/client_test.go index 29200a2..64a3cdd 100644 --- a/client_test.go +++ b/client_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal" + "github.com/beeper/desktop-api-go/v5/option" ) type closureTransport struct { @@ -38,7 +38,7 @@ func TestUserAgentHeader(t *testing.T) { }, }), ) - client.Accounts.List(context.Background()) + _, _ = client.Accounts.List(context.Background()) if userAgent != fmt.Sprintf("BeeperDesktop/Go %s", internal.PackageVersion) { t.Errorf("Expected User-Agent to be correct, but got: %#v", userAgent) } diff --git a/default_http_client.go b/default_http_client.go new file mode 100644 index 0000000..a05bc48 --- /dev/null +++ b/default_http_client.go @@ -0,0 +1,24 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package beeperdesktopapi + +import ( + "net/http" + "time" +) + +// defaultResponseHeaderTimeout bounds the time between a fully written request +// and the server's response headers. It does not apply to the response body, +// so long-running streams are unaffected. Without this, a server that accepts +// the connection but never responds would hang the request indefinitely. +const defaultResponseHeaderTimeout = 10 * time.Minute + +// defaultHTTPClient returns an [*http.Client] used when the caller does not +// supply one via [option.WithHTTPClient]. It clones [http.DefaultTransport] +// and adds a [http.Transport.ResponseHeaderTimeout] so stuck connections +// fail fast instead of compounding across retries. +func defaultHTTPClient() *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ResponseHeaderTimeout = defaultResponseHeaderTimeout + return &http.Client{Transport: transport} +} diff --git a/field.go b/field.go index 8aba1fb..74fc1f8 100644 --- a/field.go +++ b/field.go @@ -1,7 +1,7 @@ package beeperdesktopapi import ( - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" "io" "time" ) diff --git a/go.mod b/go.mod index 24af450..34fab02 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/beeper/desktop-api-go +module github.com/beeper/desktop-api-go/v5 go 1.22 diff --git a/info.go b/info.go index 6d53b58..c230686 100644 --- a/info.go +++ b/info.go @@ -7,13 +7,14 @@ import ( "net/http" "slices" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" - "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/beeper/desktop-api-go/v5/packages/respjson" ) -// Control the Beeper Desktop application +// Server discovery and capability metadata. Use /v1/info before authentication +// setup. // // InfoService contains methods and other services that help with interacting with // the beeperdesktop API. @@ -34,13 +35,14 @@ func NewInfoService(opts ...option.RequestOption) (r InfoService) { return } -// Returns app, platform, server, and endpoint discovery metadata for this Beeper -// Desktop instance. +// Returns app, platform, server, endpoint discovery, OAuth, and WebSocket metadata +// for this Beeper Desktop instance. func (r *InfoService) Get(ctx context.Context, opts ...option.RequestOption) (res *InfoGetResponse, err error) { - opts = slices.Concat(r.Options, opts) + var preClientOpts = []option.RequestOption{requestconfig.WithSecurity(requestconfig.Security{})} + opts = slices.Concat(preClientOpts, r.Options, opts) path := "v1/info" err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } type InfoGetResponse struct { @@ -169,7 +171,7 @@ func (r *InfoGetResponsePlatform) UnmarshalJSON(data []byte) error { } type InfoGetResponseServer struct { - // Base URL of the Connect server + // Base URL of the Beeper Desktop API server BaseURL string `json:"base_url" api:"required"` // Listening host Hostname string `json:"hostname" api:"required"` diff --git a/info_test.go b/info_test.go index 1c55d30..7a3d349 100644 --- a/info_test.go +++ b/info_test.go @@ -8,9 +8,9 @@ import ( "os" "testing" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestInfoGet(t *testing.T) { diff --git a/internal/apierror/apierror.go b/internal/apierror/apierror.go index 0fa3a87..8b02aad 100644 --- a/internal/apierror/apierror.go +++ b/internal/apierror/apierror.go @@ -7,8 +7,8 @@ import ( "net/http" "net/http/httputil" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/packages/respjson" ) // Error represents an error that originates from the API, i.e. when a request is diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go index 3a61344..e03f1f9 100644 --- a/internal/apiform/encoder.go +++ b/internal/apiform/encoder.go @@ -13,7 +13,7 @@ import ( "sync" "time" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" ) var encoders sync.Map // map[encoderEntry]encoderFunc @@ -58,7 +58,7 @@ type encoderField struct { } type encoderEntry struct { - reflect.Type + typ reflect.Type dateFormat string arrayFmt string root bool @@ -76,7 +76,7 @@ func (e *encoder) marshal(value any, writer *multipart.Writer) error { func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { entry := encoderEntry{ - Type: t, + typ: t, dateFormat: e.dateFormat, arrayFmt: e.arrayFmt, root: e.root, @@ -183,6 +183,18 @@ func (e *encoder) newPrimitiveTypeEncoder(t reflect.Type) encoderFunc { func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc { itemEncoder := e.typeEncoder(t.Elem()) keyFn := e.arrayKeyEncoder() + if e.arrayFmt == "comma" { + return func(key string, v reflect.Value, writer *multipart.Writer) error { + if v.Len() == 0 { + return nil + } + elements := make([]string, v.Len()) + for i := 0; i < v.Len(); i++ { + elements[i] = fmt.Sprint(v.Index(i).Interface()) + } + return writer.WriteField(key, strings.Join(elements, ",")) + } + } return func(key string, v reflect.Value, writer *multipart.Writer) error { if keyFn == nil { return fmt.Errorf("apiform: unsupported array format") @@ -265,6 +277,14 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { } return typeEncoderFn(key, value, writer) } + } else if ptag.defaultValue != nil { + typeEncoderFn := e.typeEncoder(field.Type) + encoderFn = func(key string, value reflect.Value, writer *multipart.Writer) error { + if value.IsZero() { + return typeEncoderFn(key, reflect.ValueOf(ptag.defaultValue), writer) + } + return typeEncoderFn(key, value, writer) + } } else { encoderFn = e.typeEncoder(field.Type) } @@ -469,5 +489,5 @@ func WriteExtras(writer *multipart.Writer, extras map[string]any) (err error) { break } } - return + return err } diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go index 31fb2f6..873b763 100644 --- a/internal/apiform/form_test.go +++ b/internal/apiform/form_test.go @@ -2,7 +2,7 @@ package apiform import ( "bytes" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" "io" "mime/multipart" "strings" @@ -123,6 +123,11 @@ type StructUnion struct { param.APIUnion } +type ConstantStruct struct { + Anchor string `form:"anchor" default:"created_at"` + Seconds int `form:"seconds"` +} + type MultipartMarshalerParent struct { Middle MultipartMarshalerMiddleNext `form:"middle"` } @@ -554,6 +559,37 @@ Content-Disposition: form-data; name="union" Union: UnionTime(time.Date(2010, 05, 23, 0, 0, 0, 0, time.UTC)), }, }, + "constant_zero_value": { + `--xxx +Content-Disposition: form-data; name="anchor" + +created_at +--xxx +Content-Disposition: form-data; name="seconds" + +3600 +--xxx-- +`, + ConstantStruct{ + Seconds: 3600, + }, + }, + "constant_explicit_value": { + `--xxx +Content-Disposition: form-data; name="anchor" + +created_at_override +--xxx +Content-Disposition: form-data; name="seconds" + +3600 +--xxx-- +`, + ConstantStruct{ + Anchor: "created_at_override", + Seconds: 3600, + }, + }, "deeply-nested-struct,brackets": { `--xxx Content-Disposition: form-data; name="middle[middleNext][child]" @@ -585,14 +621,17 @@ func TestEncode(t *testing.T) { t.Run(name, func(t *testing.T) { buf := bytes.NewBuffer(nil) writer := multipart.NewWriter(buf) - writer.SetBoundary("xxx") + err := writer.SetBoundary("xxx") + if err != nil { + t.Errorf("setting boundary for %v failed with error %v", test.val, err) + } - var arrayFmt string = "indices:dots" + arrayFmt := "indices:dots" if tags := strings.Split(name, ","); len(tags) > 1 { arrayFmt = tags[1] } - err := MarshalWithSettings(test.val, writer, arrayFmt) + err = MarshalWithSettings(test.val, writer, arrayFmt) if err != nil { t.Errorf("serialization of %v failed with error %v", test.val, err) } diff --git a/internal/apiform/richparam.go b/internal/apiform/richparam.go index 76206f9..547aabc 100644 --- a/internal/apiform/richparam.go +++ b/internal/apiform/richparam.go @@ -1,7 +1,7 @@ package apiform import ( - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" "mime/multipart" "reflect" ) diff --git a/internal/apiform/tag.go b/internal/apiform/tag.go index b353617..f0c9d14 100644 --- a/internal/apiform/tag.go +++ b/internal/apiform/tag.go @@ -9,13 +9,15 @@ const apiStructTag = "api" const jsonStructTag = "json" const formStructTag = "form" const formatStructTag = "format" +const defaultStructTag = "default" type parsedStructTag struct { - name string - required bool - extras bool - metadata bool - omitzero bool + name string + required bool + extras bool + metadata bool + omitzero bool + defaultValue any } func parseFormStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) { @@ -24,7 +26,7 @@ func parseFormStructTag(field reflect.StructField) (tag parsedStructTag, ok bool raw, ok = field.Tag.Lookup(jsonStructTag) } if !ok { - return + return tag, ok } parts := strings.Split(raw, ",") if len(parts) == 0 { @@ -45,7 +47,21 @@ func parseFormStructTag(field reflect.StructField) (tag parsedStructTag, ok bool } parseApiStructTag(field, &tag) - return + parseDefaultStructTag(field, &tag) + return tag, ok +} + +func parseDefaultStructTag(field reflect.StructField, tag *parsedStructTag) { + if field.Type.Kind() != reflect.String { + // Only strings are currently supported + return + } + + raw, ok := field.Tag.Lookup(defaultStructTag) + if !ok { + return + } + tag.defaultValue = raw } func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) { @@ -60,11 +76,13 @@ func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) { tag.extras = true case "required": tag.required = true + case "metadata": + tag.metadata = true } } } func parseFormatStructTag(field reflect.StructField) (format string, ok bool) { format, ok = field.Tag.Lookup(formatStructTag) - return + return format, ok } diff --git a/internal/apijson/decodeparam_test.go b/internal/apijson/decodeparam_test.go index 81dec85..8a0576f 100644 --- a/internal/apijson/decodeparam_test.go +++ b/internal/apijson/decodeparam_test.go @@ -3,8 +3,8 @@ package apijson_test import ( "encoding/json" "fmt" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/packages/param" "reflect" "testing" ) diff --git a/internal/apijson/decoder.go b/internal/apijson/decoder.go index f9eef63..bab1a38 100644 --- a/internal/apijson/decoder.go +++ b/internal/apijson/decoder.go @@ -7,7 +7,7 @@ package apijson import ( "encoding/json" "fmt" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" "reflect" "strconv" "sync" @@ -80,7 +80,7 @@ type decoderField struct { } type decoderEntry struct { - reflect.Type + typ reflect.Type dateFormat string root bool } @@ -108,7 +108,7 @@ func (d *decoderBuilder) unmarshalWithExactness(raw []byte, to any) (exactness, func (d *decoderBuilder) typeDecoder(t reflect.Type) decoderFunc { entry := decoderEntry{ - Type: t, + typ: t, dateFormat: d.dateFormat, root: d.root, } @@ -393,7 +393,7 @@ func (d *decoderBuilder) newStructTypeDecoder(t reflect.Type) decoderFunc { for _, decoder := range anonymousDecoders { // ignore errors - decoder.fn(node, value.FieldByIndex(decoder.idx), state) + _ = decoder.fn(node, value.FieldByIndex(decoder.idx), state) } for _, inlineDecoder := range inlineDecoders { @@ -462,7 +462,7 @@ func (d *decoderBuilder) newStructTypeDecoder(t reflect.Type) decoderFunc { // Handle null [param.Opt] if itemNode.Type == gjson.Null && dest.IsValid() && dest.Type().Implements(reflect.TypeOf((*param.Optional)(nil)).Elem()) { - dest.Addr().Interface().(json.Unmarshaler).UnmarshalJSON([]byte(itemNode.Raw)) + _ = dest.Addr().Interface().(json.Unmarshaler).UnmarshalJSON([]byte(itemNode.Raw)) continue } @@ -684,8 +684,5 @@ func guardUnknown(state *decoderState, v reflect.Value) bool { constantString, ok := v.Interface().(interface{ Default() string }) named := v.Type() != stringType - if guardStrict(state, ok && named && v.Equal(reflect.ValueOf(constantString.Default()))) { - return true - } - return false + return guardStrict(state, ok && named && v.Equal(reflect.ValueOf(constantString.Default()))) } diff --git a/internal/apijson/decoderesp_test.go b/internal/apijson/decoderesp_test.go index 4ccbb41..9d335b1 100644 --- a/internal/apijson/decoderesp_test.go +++ b/internal/apijson/decoderesp_test.go @@ -2,8 +2,8 @@ package apijson_test import ( "encoding/json" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/packages/respjson" "testing" ) diff --git a/internal/apijson/encoder.go b/internal/apijson/encoder.go index ab7a3c1..ee17217 100644 --- a/internal/apijson/encoder.go +++ b/internal/apijson/encoder.go @@ -12,6 +12,8 @@ import ( "time" "github.com/tidwall/sjson" + + shimjson "github.com/beeper/desktop-api-go/v5/internal/encoding/json" ) var encoders sync.Map // map[encoderEntry]encoderFunc @@ -44,7 +46,7 @@ type encoderField struct { } type encoderEntry struct { - reflect.Type + typ reflect.Type dateFormat string root bool } @@ -61,7 +63,7 @@ func (e *encoder) marshal(value any) ([]byte, error) { func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { entry := encoderEntry{ - Type: t, + typ: t, dateFormat: e.dateFormat, root: e.root, } @@ -271,6 +273,12 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { if err != nil { return nil, err } + if ef.tag.defaultValue != nil && (!field.IsValid() || field.IsZero()) { + encoded, err = shimjson.Marshal(ef.tag.defaultValue) + if err != nil { + return nil, err + } + } if encoded == nil { continue } @@ -286,28 +294,7 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { return nil, err } } - return - } -} - -func (e *encoder) newFieldTypeEncoder(t reflect.Type) encoderFunc { - f, _ := t.FieldByName("Value") - enc := e.typeEncoder(f.Type) - - return func(value reflect.Value) (json []byte, err error) { - present := value.FieldByName("Present") - if !present.Bool() { - return nil, nil - } - null := value.FieldByName("Null") - if null.Bool() { - return []byte("null"), nil - } - raw := value.FieldByName("Raw") - if !raw.IsNil() { - return e.typeEncoder(raw.Type())(raw) - } - return enc(value.FieldByName("Value")) + return json, err } } diff --git a/internal/apijson/enum.go b/internal/apijson/enum.go index 5bef11c..a1626a5 100644 --- a/internal/apijson/enum.go +++ b/internal/apijson/enum.go @@ -4,7 +4,6 @@ import ( "fmt" "reflect" "slices" - "sync" "github.com/tidwall/gjson" ) @@ -15,7 +14,6 @@ import ( type validationEntry struct { field reflect.StructField - required bool legalValues struct { strings []string // 1 represents true, 0 represents false, -1 represents either @@ -24,9 +22,6 @@ type validationEntry struct { } } -type validatorFunc func(reflect.Value) exactness - -var validators sync.Map var validationRegistry = map[reflect.Type][]validationEntry{} func RegisterFieldValidator[T any, V string | bool | int | float64](fieldName string, values ...V) { @@ -111,9 +106,9 @@ func (state *decoderState) validateBool(v reflect.Value) { return } b := v.Bool() - if state.validator.legalValues.bools == 1 && b == false { + if state.validator.legalValues.bools == 1 && !b { state.exactness = loose - } else if state.validator.legalValues.bools == 0 && b == true { + } else if state.validator.legalValues.bools == 0 && b { state.exactness = loose } } diff --git a/internal/apijson/json_test.go b/internal/apijson/json_test.go index fac9fcc..2853bf9 100644 --- a/internal/apijson/json_test.go +++ b/internal/apijson/json_test.go @@ -87,7 +87,7 @@ type JSONFieldStruct struct { C string `json:"c"` D string `json:"d"` ExtraFields map[string]int64 `json:"" api:"extrafields"` - JSON JSONFieldStructJSON `json:",metadata"` + JSON JSONFieldStructJSON `json:"-" api:"metadata"` } type JSONFieldStructJSON struct { @@ -113,12 +113,12 @@ type Union interface { type Inline struct { InlineField Primitives `json:",inline"` - JSON InlineJSON `json:",metadata"` + JSON InlineJSON `json:"-" api:"metadata"` } type InlineArray struct { InlineField []string `json:",inline"` - JSON InlineJSON `json:",metadata"` + JSON InlineJSON `json:"-" api:"metadata"` } type InlineJSON struct { @@ -268,7 +268,7 @@ type MarshallingUnionStruct struct { func (r *MarshallingUnionStruct) UnmarshalJSON(data []byte) (err error) { *r = MarshallingUnionStruct{} err = UnmarshalRoot(data, &r.Union) - return + return err } func (r MarshallingUnionStruct) MarshalJSON() (data []byte, err error) { @@ -614,3 +614,20 @@ func TestEncode(t *testing.T) { }) } } + +type StructWithDefault struct { + Type string `json:"type" default:"foo"` +} + +func TestDefault(t *testing.T) { + value := StructWithDefault{} + expected := `{"type":"foo"}` + + raw, err := Marshal(value) + if err != nil { + t.Fatalf("serialization of %v failed with error %v", value, err) + } + if string(raw) != expected { + t.Fatalf("expected %+#v to serialize to %s but got %s", value, expected, string(raw)) + } +} diff --git a/internal/apijson/subfield.go b/internal/apijson/subfield.go index 45d128a..adffc27 100644 --- a/internal/apijson/subfield.go +++ b/internal/apijson/subfield.go @@ -1,7 +1,7 @@ package apijson import ( - "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/packages/respjson" "reflect" ) diff --git a/internal/apijson/tag.go b/internal/apijson/tag.go index 49731b8..efcaf8c 100644 --- a/internal/apijson/tag.go +++ b/internal/apijson/tag.go @@ -8,19 +8,21 @@ import ( const apiStructTag = "api" const jsonStructTag = "json" const formatStructTag = "format" +const defaultStructTag = "default" type parsedStructTag struct { - name string - required bool - extras bool - metadata bool - inline bool + name string + required bool + extras bool + metadata bool + inline bool + defaultValue any } func parseJSONStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) { raw, ok := field.Tag.Lookup(jsonStructTag) if !ok { - return + return tag, ok } parts := strings.Split(raw, ",") if len(parts) == 0 { @@ -42,7 +44,21 @@ func parseJSONStructTag(field reflect.StructField) (tag parsedStructTag, ok bool // the `api` struct tag is only used alongside `json` for custom behaviour parseApiStructTag(field, &tag) - return + parseDefaultStructTag(field, &tag) + return tag, ok +} + +func parseDefaultStructTag(field reflect.StructField, tag *parsedStructTag) { + if field.Type.Kind() != reflect.String { + // Only strings are currently supported + return + } + + raw, ok := field.Tag.Lookup(defaultStructTag) + if !ok { + return + } + tag.defaultValue = raw } func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) { @@ -57,11 +73,13 @@ func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) { tag.extras = true case "required": tag.required = true + case "metadata": + tag.metadata = true } } } func parseFormatStructTag(field reflect.StructField) (format string, ok bool) { format, ok = field.Tag.Lookup(formatStructTag) - return + return format, ok } diff --git a/internal/apijson/union.go b/internal/apijson/union.go index 6eeb391..d780af5 100644 --- a/internal/apijson/union.go +++ b/internal/apijson/union.go @@ -2,7 +2,7 @@ package apijson import ( "errors" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" "reflect" "github.com/tidwall/gjson" diff --git a/internal/apiquery/encoder.go b/internal/apiquery/encoder.go index 3cfc65e..79994ec 100644 --- a/internal/apiquery/encoder.go +++ b/internal/apiquery/encoder.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" ) var encoders sync.Map // map[reflect.Type]encoderFunc @@ -29,7 +29,7 @@ type encoderField struct { } type encoderEntry struct { - reflect.Type + typ reflect.Type dateFormat string root bool settings QuerySettings @@ -42,7 +42,7 @@ type Pair struct { func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { entry := encoderEntry{ - Type: t, + typ: t, dateFormat: e.dateFormat, root: e.root, settings: e.settings, @@ -103,7 +103,7 @@ func (e *encoder) newTypeEncoder(t reflect.Type) encoderFunc { encoder := e.typeEncoder(t.Elem()) return func(key string, value reflect.Value) (pairs []Pair, err error) { if !value.IsValid() || value.IsNil() { - return + return pairs, err } return encoder(key, value.Elem()) } @@ -193,7 +193,7 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { return func(key string, value reflect.Value) (pairs []Pair, err error) { for _, ef := range encoderFields { - var subkey string = e.renderKeyPath(key, ef.tag.name) + subkey := e.renderKeyPath(key, ef.tag.name) if ef.tag.inline { subkey = key } @@ -205,7 +205,7 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { } pairs = append(pairs, subpairs...) } - return + return pairs, err } } @@ -256,7 +256,7 @@ func (e *encoder) newMapEncoder(t reflect.Type) encoderFunc { } pairs = append(pairs, subpairs...) } - return + return pairs, err } } @@ -300,7 +300,7 @@ func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc { } pairs = append(pairs, subpairs...) } - return + return pairs, err } case ArrayQueryFormatIndices: panic("The array indices format is not supported yet") @@ -315,7 +315,7 @@ func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc { } pairs = append(pairs, subpairs...) } - return + return pairs, err } default: panic(fmt.Sprintf("Unknown ArrayFormat value: %d", e.settings.ArrayFormat)) @@ -372,27 +372,6 @@ func (e *encoder) newPrimitiveTypeEncoder(t reflect.Type) encoderFunc { } } -func (e *encoder) newFieldTypeEncoder(t reflect.Type) encoderFunc { - f, _ := t.FieldByName("Value") - enc := e.typeEncoder(f.Type) - - return func(key string, value reflect.Value) ([]Pair, error) { - present := value.FieldByName("Present") - if !present.Bool() { - return nil, nil - } - null := value.FieldByName("Null") - if null.Bool() { - return nil, fmt.Errorf("apiquery: field cannot be null") - } - raw := value.FieldByName("Raw") - if !raw.IsNil() { - return e.typeEncoder(raw.Type())(key, raw) - } - return enc(key, value.FieldByName("Value")) - } -} - func (e *encoder) newTimeTypeEncoder(_ reflect.Type) encoderFunc { format := e.dateFormat return func(key string, value reflect.Value) ([]Pair, error) { diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go index 6ccb35e..aa5af6c 100644 --- a/internal/apiquery/query_test.go +++ b/internal/apiquery/query_test.go @@ -1,7 +1,7 @@ package apiquery import ( - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" "net/url" "testing" "time" diff --git a/internal/apiquery/richparam.go b/internal/apiquery/richparam.go index c542ee7..953395f 100644 --- a/internal/apiquery/richparam.go +++ b/internal/apiquery/richparam.go @@ -1,7 +1,7 @@ package apiquery import ( - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" "reflect" ) diff --git a/internal/apiquery/tag.go b/internal/apiquery/tag.go index 772c40e..9e413ad 100644 --- a/internal/apiquery/tag.go +++ b/internal/apiquery/tag.go @@ -18,7 +18,7 @@ type parsedStructTag struct { func parseQueryStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) { raw, ok := field.Tag.Lookup(queryStructTag) if !ok { - return + return tag, ok } parts := strings.Split(raw, ",") if len(parts) == 0 { @@ -35,10 +35,10 @@ func parseQueryStructTag(field reflect.StructField) (tag parsedStructTag, ok boo tag.inline = true } } - return + return tag, ok } func parseFormatStructTag(field reflect.StructField) (format string, ok bool) { format, ok = field.Tag.Lookup(formatStructTag) - return + return format, ok } diff --git a/internal/encoding/json/decode.go b/internal/encoding/json/decode.go index 2432c3b..9a317a1 100644 --- a/internal/encoding/json/decode.go +++ b/internal/encoding/json/decode.go @@ -14,7 +14,7 @@ import ( "encoding" "encoding/base64" "fmt" - "github.com/beeper/desktop-api-go/internal/encoding/json/shims" + "github.com/beeper/desktop-api-go/v5/internal/encoding/json/shims" "reflect" "strconv" "strings" diff --git a/internal/encoding/json/encode.go b/internal/encoding/json/encode.go index a292df9..041ca3d 100644 --- a/internal/encoding/json/encode.go +++ b/internal/encoding/json/encode.go @@ -19,8 +19,8 @@ import ( "encoding" "encoding/base64" "fmt" - "github.com/beeper/desktop-api-go/internal/encoding/json/sentinel" - "github.com/beeper/desktop-api-go/internal/encoding/json/shims" + "github.com/beeper/desktop-api-go/v5/internal/encoding/json/sentinel" + "github.com/beeper/desktop-api-go/v5/internal/encoding/json/shims" "math" "reflect" "slices" diff --git a/internal/encoding/json/sentinel/null.go b/internal/encoding/json/sentinel/null.go index 48e0af0..ef387aa 100644 --- a/internal/encoding/json/sentinel/null.go +++ b/internal/encoding/json/sentinel/null.go @@ -1,7 +1,7 @@ package sentinel import ( - "github.com/beeper/desktop-api-go/internal/encoding/json/shims" + "github.com/beeper/desktop-api-go/v5/internal/encoding/json/shims" "reflect" "sync" ) diff --git a/internal/encoding/json/sentinel/sentinel_test.go b/internal/encoding/json/sentinel/sentinel_test.go index ddc509f..659b2df 100644 --- a/internal/encoding/json/sentinel/sentinel_test.go +++ b/internal/encoding/json/sentinel/sentinel_test.go @@ -1,8 +1,8 @@ package sentinel_test import ( - "github.com/beeper/desktop-api-go/internal/encoding/json/sentinel" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/internal/encoding/json/sentinel" + "github.com/beeper/desktop-api-go/v5/packages/param" "reflect" "slices" "testing" diff --git a/internal/encoding/json/time.go b/internal/encoding/json/time.go index c19b606..7ad4a61 100644 --- a/internal/encoding/json/time.go +++ b/internal/encoding/json/time.go @@ -2,7 +2,7 @@ package json import ( - "github.com/beeper/desktop-api-go/internal/encoding/json/shims" + "github.com/beeper/desktop-api-go/v5/internal/encoding/json/shims" "reflect" "time" ) diff --git a/internal/paramutil/field.go b/internal/paramutil/field.go index b7e301c..157f3fc 100644 --- a/internal/paramutil/field.go +++ b/internal/paramutil/field.go @@ -1,8 +1,8 @@ package paramutil import ( - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/respjson" ) func AddrIfPresent[T comparable](v param.Opt[T]) *T { diff --git a/internal/paramutil/union.go b/internal/paramutil/union.go index 8da3a48..b55f02b 100644 --- a/internal/paramutil/union.go +++ b/internal/paramutil/union.go @@ -2,7 +2,7 @@ package paramutil import ( "fmt" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" "reflect" ) diff --git a/internal/requestconfig/requestconfig.go b/internal/requestconfig/requestconfig.go index a8694b9..115b152 100644 --- a/internal/requestconfig/requestconfig.go +++ b/internal/requestconfig/requestconfig.go @@ -18,10 +18,10 @@ import ( "strings" "time" - "github.com/beeper/desktop-api-go/internal" - "github.com/beeper/desktop-api-go/internal/apierror" - "github.com/beeper/desktop-api-go/internal/apiform" - "github.com/beeper/desktop-api-go/internal/apiquery" + "github.com/beeper/desktop-api-go/v5/internal" + "github.com/beeper/desktop-api-go/v5/internal/apierror" + "github.com/beeper/desktop-api-go/v5/internal/apiform" + "github.com/beeper/desktop-api-go/v5/internal/apiquery" ) func getDefaultHeaders() map[string]string { @@ -121,7 +121,13 @@ func NewRequestConfig(ctx context.Context, method string, u string, body any, ds } params := q.Encode() if params != "" { - u = u + "?" + params + parsed, _ := url.Parse(u) + if parsed.RawQuery != "" { + parsed.RawQuery = parsed.RawQuery + "&" + params + u = parsed.String() + } else { + u = u + "?" + params + } } } if body, ok := body.([]byte); ok { @@ -171,11 +177,17 @@ func NewRequestConfig(ctx context.Context, method string, u string, body any, ds Body: reader, } cfg.ResponseBodyInto = dst + cfg.Security = Security{ + BearerAuth: true, + } err = cfg.Apply(opts...) if err != nil { return nil, err } + // This must run after `cfg.Apply(...)` above so we know which specific security scheme to add + ApplySecurity(cfg) + // This must run after `cfg.Apply(...)` above in case the request timeout gets modified. We also only // apply our own logic for it if it's still "0" from above. If it's not, then it was deleted or modified // by the user and we should respect that. @@ -213,6 +225,8 @@ type RequestConfig struct { HTTPClient *http.Client Middlewares []middleware AccessToken string + // Configure which security scheme(s) should be enabled for this request + Security Security // If ResponseBodyInto not nil, then we will attempt to deserialize into // ResponseBodyInto. If Destination is a []byte, then it will return the body as // is. @@ -461,7 +475,7 @@ func (cfg *RequestConfig) Execute() (err error) { // Close the response body before retrying to prevent connection leaks if res != nil && res.Body != nil { - res.Body.Close() + _ = res.Body.Close() } select { @@ -489,7 +503,7 @@ func (cfg *RequestConfig) Execute() (err error) { if res.StatusCode >= 400 { contents, err := io.ReadAll(res.Body) - res.Body.Close() + _ = res.Body.Close() if err != nil { return err } @@ -520,7 +534,7 @@ func (cfg *RequestConfig) Execute() (err error) { } contents, err := io.ReadAll(res.Body) - res.Body.Close() + _ = res.Body.Close() if err != nil { return fmt.Errorf("error reading response body: %w", err) } @@ -634,3 +648,31 @@ func WithDefaultBaseURL(baseURL string) RequestOption { return nil }) } + +type Security struct { + BearerAuth bool +} + +func WithSecurity(security Security) RequestOption { + return RequestOptionFunc(func(r *RequestConfig) error { + r.Security = security + return nil + }) +} + +// WithBearerAuthSecurity() should only be used within a method, not provided to at +// the client-level. +func WithBearerAuthSecurity() RequestOption { + return RequestOptionFunc(func(r *RequestConfig) error { + r.Security = Security{ + BearerAuth: true, + } + return nil + }) +} + +func ApplySecurity(r RequestConfig) { + if r.Security.BearerAuth && r.AccessToken != "" && r.Request.Header.Get("Authorization") == "" { + r.Request.Header.Set("authorization", fmt.Sprintf("Bearer %s", r.AccessToken)) + } +} diff --git a/internal/version.go b/internal/version.go index 67c4d40..c87941c 100644 --- a/internal/version.go +++ b/internal/version.go @@ -2,4 +2,4 @@ package internal -const PackageVersion = "0.5.0" // x-release-please-version +const PackageVersion = "5.0.0" // x-release-please-version diff --git a/message.go b/message.go index 1d09982..c25d60c 100644 --- a/message.go +++ b/message.go @@ -11,14 +11,14 @@ import ( "slices" "time" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/apiquery" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" - "github.com/beeper/desktop-api-go/packages/pagination" - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/packages/respjson" - "github.com/beeper/desktop-api-go/shared" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/apiquery" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/beeper/desktop-api-go/v5/packages/pagination" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/respjson" + "github.com/beeper/desktop-api-go/v5/shared" ) // Manage messages in chats @@ -42,31 +42,48 @@ func NewMessageService(opts ...option.RequestOption) (r MessageService) { return } +// Retrieve a message by final message ID, pendingMessageID, or Matrix event ID. +// Chat ID may be a Beeper chat ID or local chat ID. +func (r *MessageService) Get(ctx context.Context, messageID string, query MessageGetParams, opts ...option.RequestOption) (res *shared.Message, err error) { + opts = slices.Concat(r.Options, opts) + if query.ChatID == "" { + err = errors.New("missing required chatID parameter") + return nil, err + } + if messageID == "" { + err = errors.New("missing required messageID parameter") + return nil, err + } + path := fmt.Sprintf("v1/chats/%s/messages/%s", query.ChatID, messageID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return res, err +} + // Edit the text content of an existing message. Messages with attachments cannot // be edited. func (r *MessageService) Update(ctx context.Context, messageID string, params MessageUpdateParams, opts ...option.RequestOption) (res *MessageUpdateResponse, err error) { opts = slices.Concat(r.Options, opts) if params.ChatID == "" { err = errors.New("missing required chatID parameter") - return + return nil, err } if messageID == "" { err = errors.New("missing required messageID parameter") - return + return nil, err } path := fmt.Sprintf("v1/chats/%s/messages/%s", params.ChatID, messageID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPut, path, params, &res, opts...) - return + return res, err } // List all messages in a chat with cursor-based pagination. Sorted by timestamp. -func (r *MessageService) List(ctx context.Context, chatID string, query MessageListParams, opts ...option.RequestOption) (res *pagination.CursorSortKey[shared.Message], err error) { +func (r *MessageService) List(ctx context.Context, chatID string, query MessageListParams, opts ...option.RequestOption) (res *pagination.CursorNoLimit[shared.Message], err error) { var raw *http.Response opts = slices.Concat(r.Options, opts) opts = append([]option.RequestOption{option.WithResponseInto(&raw)}, opts...) if chatID == "" { err = errors.New("missing required chatID parameter") - return + return nil, err } path := fmt.Sprintf("v1/chats/%s/messages", chatID) cfg, err := requestconfig.NewRequestConfig(ctx, http.MethodGet, path, query, &res, opts...) @@ -82,11 +99,29 @@ func (r *MessageService) List(ctx context.Context, chatID string, query MessageL } // List all messages in a chat with cursor-based pagination. Sorted by timestamp. -func (r *MessageService) ListAutoPaging(ctx context.Context, chatID string, query MessageListParams, opts ...option.RequestOption) *pagination.CursorSortKeyAutoPager[shared.Message] { - return pagination.NewCursorSortKeyAutoPager(r.List(ctx, chatID, query, opts...)) +func (r *MessageService) ListAutoPaging(ctx context.Context, chatID string, query MessageListParams, opts ...option.RequestOption) *pagination.CursorNoLimitAutoPager[shared.Message] { + return pagination.NewCursorNoLimitAutoPager(r.List(ctx, chatID, query, opts...)) +} + +// Delete a message by final message ID. Pending message IDs are not accepted +// because messages cannot be deleted while sending. +func (r *MessageService) Delete(ctx context.Context, messageID string, params MessageDeleteParams, opts ...option.RequestOption) (err error) { + opts = slices.Concat(r.Options, opts) + opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) + if params.ChatID == "" { + err = errors.New("missing required chatID parameter") + return err + } + if messageID == "" { + err = errors.New("missing required messageID parameter") + return err + } + path := fmt.Sprintf("v1/chats/%s/messages/%s", params.ChatID, messageID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, params, nil, opts...) + return err } -// Search messages across chats using Beeper's message index +// Search messages across chats. func (r *MessageService) Search(ctx context.Context, query MessageSearchParams, opts ...option.RequestOption) (res *pagination.CursorSearch[shared.Message], err error) { var raw *http.Response opts = slices.Concat(r.Options, opts) @@ -104,7 +139,7 @@ func (r *MessageService) Search(ctx context.Context, query MessageSearchParams, return res, nil } -// Search messages across chats using Beeper's message index +// Search messages across chats. func (r *MessageService) SearchAutoPaging(ctx context.Context, query MessageSearchParams, opts ...option.RequestOption) *pagination.CursorSearchAutoPager[shared.Message] { return pagination.NewCursorSearchAutoPager(r.Search(ctx, query, opts...)) } @@ -115,28 +150,33 @@ func (r *MessageService) Send(ctx context.Context, chatID string, body MessageSe opts = slices.Concat(r.Options, opts) if chatID == "" { err = errors.New("missing required chatID parameter") - return + return nil, err } path := fmt.Sprintf("v1/chats/%s/messages", chatID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } type MessageUpdateResponse struct { - // Unique identifier of the chat. - ChatID string `json:"chatID" api:"required"` - // Message ID. + // DEPRECATED - use id instead. Compatibility alias for older clients. + // + // Deprecated: deprecated MessageID string `json:"messageID" api:"required"` - // Whether the message was successfully edited + // DEPRECATED - compatibility field. Successful responses are already represented + // by the 200 status code. + // + // Any of true. + // + // Deprecated: deprecated Success bool `json:"success" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { - ChatID respjson.Field MessageID respjson.Field Success respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` + shared.Message } // Returns the unmodified JSON received from the API @@ -146,9 +186,12 @@ func (r *MessageUpdateResponse) UnmarshalJSON(data []byte) error { } type MessageSendResponse struct { - // Unique identifier of the chat. + // Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + // installation when available. ChatID string `json:"chatID" api:"required"` - // Pending message ID + // Pending ID assigned to the message before the network confirms the send. Pass it + // to GET /v1/chats/{chatID}/messages/{messageID} to resolve, or wait for the + // matching message.upserted over the WebSocket. PendingMessageID string `json:"pendingMessageID" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -165,8 +208,16 @@ func (r *MessageSendResponse) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +type MessageGetParams struct { + // Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + // installation when available. + ChatID string `path:"chatID" api:"required" json:"-"` + paramObj +} + type MessageUpdateParams struct { - // Unique identifier of the chat. + // Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + // installation when available. ChatID string `path:"chatID" api:"required" json:"-"` // New text content for the message Text string `json:"text" api:"required"` @@ -209,6 +260,24 @@ const ( MessageListParamsDirectionBefore MessageListParamsDirection = "before" ) +type MessageDeleteParams struct { + // Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + // installation when available. + ChatID string `path:"chatID" api:"required" json:"-"` + // True to request deletion for everyone when the network supports it; false to + // delete only for the authenticated user when supported. + ForEveryone param.Opt[bool] `query:"forEveryone,omitzero" json:"-"` + paramObj +} + +// URLQuery serializes [MessageDeleteParams]'s query parameters as `url.Values`. +func (r MessageDeleteParams) URLQuery() (v url.Values, err error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatRepeat, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} + type MessageSearchParams struct { // Exclude messages marked Low Priority by the user. Default: true. Set to false to // include all. @@ -283,7 +352,8 @@ const ( type MessageSendParams struct { // Provide a message ID to send this as a reply to an existing message ReplyToMessageID param.Opt[string] `json:"replyToMessageID,omitzero"` - // Text content of the message you want to send. You may use markdown. + // Draft text. Plain text and Markdown are converted to Matrix HTML with the same + // rules used by send and edit. Text param.Opt[string] `json:"text,omitzero"` // Single attachment to send with the message Attachment MessageSendParamsAttachment `json:"attachment,omitzero"` @@ -312,10 +382,10 @@ type MessageSendParamsAttachment struct { MimeType param.Opt[string] `json:"mimeType,omitzero"` // Dimensions (optional override of cached value) Size MessageSendParamsAttachmentSize `json:"size,omitzero"` - // Special attachment type (gif, voiceNote, sticker). If omitted, auto-detected - // from mimeType + // Attachment type hint (image, video, audio, file, gif, voice-note, sticker). If + // omitted, auto-detected from mimeType // - // Any of "gif", "voiceNote", "sticker". + // Any of "image", "video", "audio", "file", "gif", "voice-note", "sticker". Type string `json:"type,omitzero"` paramObj } @@ -330,7 +400,7 @@ func (r *MessageSendParamsAttachment) UnmarshalJSON(data []byte) error { func init() { apijson.RegisterFieldValidator[MessageSendParamsAttachment]( - "type", "gif", "voiceNote", "sticker", + "type", "image", "video", "audio", "file", "gif", "voice-note", "sticker", ) } diff --git a/message_test.go b/message_test.go index cb17cd7..f26bdb1 100644 --- a/message_test.go +++ b/message_test.go @@ -9,11 +9,39 @@ import ( "testing" "time" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) +func TestMessageGet(t *testing.T) { + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := beeperdesktopapi.NewClient( + option.WithBaseURL(baseURL), + option.WithAccessToken("My Access Token"), + ) + _, err := client.Messages.Get( + context.TODO(), + "1343993", + beeperdesktopapi.MessageGetParams{ + ChatID: "!NCdzlIaMjZUmvmvyHU:beeper.com", + }, + ) + if err != nil { + var apierr *beeperdesktopapi.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + func TestMessageUpdate(t *testing.T) { baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -28,7 +56,7 @@ func TestMessageUpdate(t *testing.T) { ) _, err := client.Messages.Update( context.TODO(), - "messageID", + "1343993", beeperdesktopapi.MessageUpdateParams{ ChatID: "!NCdzlIaMjZUmvmvyHU:beeper.com", Text: "x", @@ -72,6 +100,35 @@ func TestMessageListWithOptionalParams(t *testing.T) { } } +func TestMessageDeleteWithOptionalParams(t *testing.T) { + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := beeperdesktopapi.NewClient( + option.WithBaseURL(baseURL), + option.WithAccessToken("My Access Token"), + ) + err := client.Messages.Delete( + context.TODO(), + "1343993", + beeperdesktopapi.MessageDeleteParams{ + ChatID: "!NCdzlIaMjZUmvmvyHU:beeper.com", + ForEveryone: beeperdesktopapi.Bool(true), + }, + ) + if err != nil { + var apierr *beeperdesktopapi.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + func TestMessageSearchWithOptionalParams(t *testing.T) { baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -85,7 +142,7 @@ func TestMessageSearchWithOptionalParams(t *testing.T) { option.WithAccessToken("My Access Token"), ) _, err := client.Messages.Search(context.TODO(), beeperdesktopapi.MessageSearchParams{ - AccountIDs: []string{"local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU"}, + AccountIDs: []string{"matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"}, ChatIDs: []string{"!NCdzlIaMjZUmvmvyHU:beeper.com", "1231073"}, ChatType: beeperdesktopapi.MessageSearchParamsChatTypeGroup, Cursor: beeperdesktopapi.String("1725489123456|c29tZUltc2dQYWdl"), @@ -133,7 +190,7 @@ func TestMessageSendWithOptionalParams(t *testing.T) { Height: 0, Width: 0, }, - Type: "gif", + Type: "image", }, ReplyToMessageID: beeperdesktopapi.String("replyToMessageID"), Text: beeperdesktopapi.String("text"), diff --git a/option/requestoption.go b/option/requestoption.go index 8c24072..101a747 100644 --- a/option/requestoption.go +++ b/option/requestoption.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/beeper/desktop-api-go/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" "github.com/tidwall/sjson" ) @@ -270,6 +270,6 @@ func WithEnvironmentLocal() RequestOption { func WithAccessToken(value string) RequestOption { return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { r.AccessToken = value - return r.Apply(WithHeader("authorization", fmt.Sprintf("Bearer %s", r.AccessToken))) + return nil }) } diff --git a/packages/pagination/pagination.go b/packages/pagination/pagination.go index fd2a5d9..ee67f4a 100644 --- a/packages/pagination/pagination.go +++ b/packages/pagination/pagination.go @@ -4,13 +4,12 @@ package pagination import ( "net/http" - "reflect" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/internal/requestconfig" - "github.com/beeper/desktop-api-go/option" - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/internal/requestconfig" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/respjson" ) // aliased to make [param.APIUnion] private when embedding @@ -234,109 +233,3 @@ func (r *CursorNoLimitAutoPager[T]) Err() error { func (r *CursorNoLimitAutoPager[T]) Index() int { return r.run } - -type CursorSortKey[T any] struct { - Items []T `json:"items"` - HasMore bool `json:"hasMore"` - // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. - JSON struct { - Items respjson.Field - HasMore respjson.Field - ExtraFields map[string]respjson.Field - raw string - } `json:"-"` - cfg *requestconfig.RequestConfig - res *http.Response -} - -// Returns the unmodified JSON received from the API -func (r CursorSortKey[T]) RawJSON() string { return r.JSON.raw } -func (r *CursorSortKey[T]) UnmarshalJSON(data []byte) error { - return apijson.UnmarshalRoot(data, r) -} - -// GetNextPage returns the next page as defined by this pagination style. When -// there is no next page, this function will return a 'nil' for the page value, but -// will not return an error -func (r *CursorSortKey[T]) GetNextPage() (res *CursorSortKey[T], err error) { - if len(r.Items) == 0 { - return nil, nil - } - - if r.JSON.HasMore.Valid() && r.HasMore == false { - return nil, nil - } - items := r.Items - if items == nil || len(items) == 0 { - return nil, nil - } - cfg := r.cfg.Clone(r.cfg.Context) - value := reflect.ValueOf(items[len(items)-1]) - field := value.FieldByName("SortKey") - err = cfg.Apply(option.WithQuery("cursor", field.Interface().(string))) - if err != nil { - return nil, err - } - var raw *http.Response - cfg.ResponseInto = &raw - cfg.ResponseBodyInto = &res - err = cfg.Execute() - if err != nil { - return nil, err - } - res.SetPageConfig(cfg, raw) - return res, nil -} - -func (r *CursorSortKey[T]) SetPageConfig(cfg *requestconfig.RequestConfig, res *http.Response) { - if r == nil { - r = &CursorSortKey[T]{} - } - r.cfg = cfg - r.res = res -} - -type CursorSortKeyAutoPager[T any] struct { - page *CursorSortKey[T] - cur T - idx int - run int - err error - paramObj -} - -func NewCursorSortKeyAutoPager[T any](page *CursorSortKey[T], err error) *CursorSortKeyAutoPager[T] { - return &CursorSortKeyAutoPager[T]{ - page: page, - err: err, - } -} - -func (r *CursorSortKeyAutoPager[T]) Next() bool { - if r.page == nil || len(r.page.Items) == 0 { - return false - } - if r.idx >= len(r.page.Items) { - r.idx = 0 - r.page, r.err = r.page.GetNextPage() - if r.err != nil || r.page == nil || len(r.page.Items) == 0 { - return false - } - } - r.cur = r.page.Items[r.idx] - r.run += 1 - r.idx += 1 - return true -} - -func (r *CursorSortKeyAutoPager[T]) Current() T { - return r.cur -} - -func (r *CursorSortKeyAutoPager[T]) Err() error { - return r.err -} - -func (r *CursorSortKeyAutoPager[T]) Index() int { - return r.run -} diff --git a/packages/param/encoder.go b/packages/param/encoder.go index 26c2ae3..a944e57 100644 --- a/packages/param/encoder.go +++ b/packages/param/encoder.go @@ -7,7 +7,7 @@ import ( "strings" "time" - shimjson "github.com/beeper/desktop-api-go/internal/encoding/json" + shimjson "github.com/beeper/desktop-api-go/v5/internal/encoding/json" "github.com/tidwall/sjson" ) diff --git a/packages/param/encoder_test.go b/packages/param/encoder_test.go index 230c916..63769fb 100644 --- a/packages/param/encoder_test.go +++ b/packages/param/encoder_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" ) type Struct struct { diff --git a/packages/param/null.go b/packages/param/null.go index 1bf7c37..d740ca1 100644 --- a/packages/param/null.go +++ b/packages/param/null.go @@ -1,6 +1,6 @@ package param -import "github.com/beeper/desktop-api-go/internal/encoding/json/sentinel" +import "github.com/beeper/desktop-api-go/v5/internal/encoding/json/sentinel" // NullMap returns a non-nil map with a length of 0. // When used with [MarshalObject] or [MarshalUnion], it will be marshaled as null. diff --git a/packages/param/null_test.go b/packages/param/null_test.go index 9c07938..7775700 100644 --- a/packages/param/null_test.go +++ b/packages/param/null_test.go @@ -2,7 +2,7 @@ package param_test import ( "encoding/json" - "github.com/beeper/desktop-api-go/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/param" "testing" ) diff --git a/packages/param/option.go b/packages/param/option.go index cdbe395..d3291b9 100644 --- a/packages/param/option.go +++ b/packages/param/option.go @@ -3,7 +3,7 @@ package param import ( "encoding/json" "fmt" - shimjson "github.com/beeper/desktop-api-go/internal/encoding/json" + shimjson "github.com/beeper/desktop-api-go/v5/internal/encoding/json" "time" ) diff --git a/packages/param/param.go b/packages/param/param.go index e68a5ef..1cdd474 100644 --- a/packages/param/param.go +++ b/packages/param/param.go @@ -2,7 +2,7 @@ package param import ( "encoding/json" - "github.com/beeper/desktop-api-go/internal/encoding/json/sentinel" + "github.com/beeper/desktop-api-go/v5/internal/encoding/json/sentinel" "reflect" ) diff --git a/packages/respjson/decoder_test.go b/packages/respjson/decoder_test.go index 65d6604..e08e97f 100644 --- a/packages/respjson/decoder_test.go +++ b/packages/respjson/decoder_test.go @@ -3,8 +3,8 @@ package respjson_test import ( "encoding/json" "fmt" - "github.com/beeper/desktop-api-go/internal/apijson" - rj "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + rj "github.com/beeper/desktop-api-go/v5/packages/respjson" "reflect" "testing" ) diff --git a/paginationauto_test.go b/paginationauto_test.go index d1b301a..3b34ed7 100644 --- a/paginationauto_test.go +++ b/paginationauto_test.go @@ -7,9 +7,9 @@ import ( "os" "testing" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestAutoPagination(t *testing.T) { @@ -25,9 +25,9 @@ func TestAutoPagination(t *testing.T) { option.WithAccessToken("My Access Token"), ) iter := client.Messages.SearchAutoPaging(context.TODO(), beeperdesktopapi.MessageSearchParams{ - AccountIDs: []string{"local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"}, + AccountIDs: []string{"discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"}, Limit: beeperdesktopapi.Int(10), - Query: beeperdesktopapi.String("deployment"), + Query: beeperdesktopapi.String("oauth"), }) // The mock server isn't going to give us real pagination for i := 0; i < 3 && iter.Next(); i++ { diff --git a/paginationmanual_test.go b/paginationmanual_test.go index 78b1ddc..a19a401 100644 --- a/paginationmanual_test.go +++ b/paginationmanual_test.go @@ -7,9 +7,9 @@ import ( "os" "testing" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestManualPagination(t *testing.T) { @@ -25,9 +25,9 @@ func TestManualPagination(t *testing.T) { option.WithAccessToken("My Access Token"), ) page, err := client.Messages.Search(context.TODO(), beeperdesktopapi.MessageSearchParams{ - AccountIDs: []string{"local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI"}, + AccountIDs: []string{"discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"}, Limit: beeperdesktopapi.Int(10), - Query: beeperdesktopapi.String("deployment"), + Query: beeperdesktopapi.String("oauth"), }) if err != nil { t.Fatalf("err should be nil: %s", err.Error()) diff --git a/scripts/bootstrap b/scripts/bootstrap index 5ab3066..46547f1 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response diff --git a/scripts/mock b/scripts/mock index bcf3b39..9c7c439 100755 --- a/scripts/mock +++ b/scripts/mock @@ -19,34 +19,34 @@ fi echo "==> Starting mock server with URL ${URL}" -# Run prism mock on the given spec +# Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stdy/cli@0.22.1 -- steady --version - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & - # Wait for server to come online (max 30s) + # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" attempts=0 - while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do + if ! kill -0 $! 2>/dev/null; then + echo + cat .stdy.log + exit 1 + fi attempts=$((attempts + 1)) if [ "$attempts" -ge 300 ]; then echo - echo "Timed out waiting for Prism server to start" - cat .prism.log + echo "Timed out waiting for Steady server to start" + cat .stdy.log exit 1 fi echo -n "." sleep 0.1 done - if grep -q "✖ fatal" ".prism.log"; then - cat .prism.log - exit 1 - fi - echo else - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index c26b122..8b48c7f 100755 --- a/scripts/test +++ b/scripts/test @@ -9,8 +9,8 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color -function prism_is_running() { - curl --silent "http://localhost:4010" >/dev/null 2>&1 +function steady_is_running() { + curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1 } kill_server_on_port() { @@ -25,7 +25,7 @@ function is_overriding_api_base_url() { [ -n "$TEST_API_BASE_URL" ] } -if ! is_overriding_api_base_url && ! prism_is_running ; then +if ! is_overriding_api_base_url && ! steady_is_running ; then # When we exit this script, make sure to kill the background mock server process trap 'kill_server_on_port 4010' EXIT @@ -36,19 +36,19 @@ fi if is_overriding_api_base_url ; then echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" echo -elif ! prism_is_running ; then - echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" +elif ! steady_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server" echo -e "running against your OpenAPI spec." echo echo -e "To run the server, pass in the path or url of your OpenAPI" - echo -e "spec to the prism command:" + echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 else - echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}" echo fi diff --git a/shared/constant/constants.go b/shared/constant/constants.go index 87c5596..0f62c9f 100644 --- a/shared/constant/constants.go +++ b/shared/constant/constants.go @@ -3,7 +3,7 @@ package constant import ( - shimjson "github.com/beeper/desktop-api-go/internal/encoding/json" + shimjson "github.com/beeper/desktop-api-go/v5/internal/encoding/json" ) type Constant[T any] interface { diff --git a/shared/shared.go b/shared/shared.go index 3a7424c..d658c39 100644 --- a/shared/shared.go +++ b/shared/shared.go @@ -3,11 +3,12 @@ package shared import ( + "encoding/json" "time" - "github.com/beeper/desktop-api-go/internal/apijson" - "github.com/beeper/desktop-api-go/packages/param" - "github.com/beeper/desktop-api-go/packages/respjson" + "github.com/beeper/desktop-api-go/v5/internal/apijson" + "github.com/beeper/desktop-api-go/v5/packages/param" + "github.com/beeper/desktop-api-go/v5/packages/respjson" ) // aliased to make [param.APIUnion] private when embedding @@ -21,8 +22,8 @@ type Attachment struct { // // Any of "unknown", "img", "video", "audio". Type AttachmentType `json:"type" api:"required"` - // Attachment identifier (typically an mxc:// URL). Use with /v1/assets/download to - // get a local file path. + // Attachment identifier (typically an mxc:// URL). Use the download file endpoint + // to get a local file path. ID string `json:"id"` // Duration in seconds (audio/video). Duration float64 `json:"duration"` @@ -43,25 +44,28 @@ type Attachment struct { PosterImg string `json:"posterImg"` // Pixel dimensions of the attachment: width/height in px. Size AttachmentSize `json:"size"` - // Public URL or local file path to fetch the asset. May be temporary or local-only + // Public URL or local file path to fetch the file. May be temporary or local-only // to this device; download promptly if durable access is needed. SrcURL string `json:"srcURL"` + // Attachment transcription if available. + Transcription AttachmentTranscription `json:"transcription"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { - Type respjson.Field - ID respjson.Field - Duration respjson.Field - FileName respjson.Field - FileSize respjson.Field - IsGif respjson.Field - IsSticker respjson.Field - IsVoiceNote respjson.Field - MimeType respjson.Field - PosterImg respjson.Field - Size respjson.Field - SrcURL respjson.Field - ExtraFields map[string]respjson.Field - raw string + Type respjson.Field + ID respjson.Field + Duration respjson.Field + FileName respjson.Field + FileSize respjson.Field + IsGif respjson.Field + IsSticker respjson.Field + IsVoiceNote respjson.Field + MimeType respjson.Field + PosterImg respjson.Field + Size respjson.Field + SrcURL respjson.Field + Transcription respjson.Field + ExtraFields map[string]respjson.Field + raw string } `json:"-"` } @@ -100,14 +104,40 @@ func (r *AttachmentSize) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +// Attachment transcription if available. +type AttachmentTranscription struct { + // Transcription engine. + Engine string `json:"engine" api:"required"` + // Transcribed text. + Transcription string `json:"transcription" api:"required"` + // Detected or selected language. + Language string `json:"language"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Engine respjson.Field + Transcription respjson.Field + Language respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r AttachmentTranscription) RawJSON() string { return r.JSON.raw } +func (r *AttachmentTranscription) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type Message struct { // Message ID. ID string `json:"id" api:"required"` // Beeper account ID the message belongs to. AccountID string `json:"accountID" api:"required"` - // Unique identifier of the chat. + // Chat ID. Input routes also accept the local chat ID from this Beeper Desktop + // installation when available. ChatID string `json:"chatID" api:"required"` - // Sender user ID. + // Matrix-style fully-qualified sender user ID, usually including a bridge prefix + // and homeserver. SenderID string `json:"senderID" api:"required"` // A unique, sortable key used to sort messages. SortKey string `json:"sortKey" api:"required"` @@ -115,18 +145,32 @@ type Message struct { Timestamp time.Time `json:"timestamp" api:"required" format:"date-time"` // Attachments included with this message, if any. Attachments []Attachment `json:"attachments"` + // Timestamp when the message was edited, if known. + EditedTimestamp time.Time `json:"editedTimestamp" format:"date-time"` + // True if the message has been deleted. + IsDeleted bool `json:"isDeleted"` + // True if the message is hidden from normal display. + IsHidden bool `json:"isHidden"` // True if the authenticated user sent the message. IsSender bool `json:"isSender"` // True if the message is unread for the authenticated user. May be omitted. IsUnread bool `json:"isUnread"` // ID of the message this is a reply to, if any. LinkedMessageID string `json:"linkedMessageID"` + // Link previews included with this message, if any. + Links []MessageLink `json:"links"` + // Mentioned user IDs, @room, or null for legacy messages that require text + // scanning. + Mentions []string `json:"mentions" api:"nullable"` // Reactions to the message, if any. Reactions []Reaction `json:"reactions"` + // Read receipt state for this message, when available. + Seen MessageSeenUnion `json:"seen" format:"date-time"` // Resolved sender display name (impersonator/full name/username/participant name). SenderName string `json:"senderName"` - // Plain-text body if present. May include a JSON fallback with text entities for - // rich messages. + // Message send status for this message, when reported by the bridge. + SendStatus MessageSendStatus `json:"sendStatus"` + // Matrix HTML body if present. Text string `json:"text"` // Message content type. Useful for distinguishing reactions, media messages, and // state events from regular text messages. @@ -143,11 +187,18 @@ type Message struct { SortKey respjson.Field Timestamp respjson.Field Attachments respjson.Field + EditedTimestamp respjson.Field + IsDeleted respjson.Field + IsHidden respjson.Field IsSender respjson.Field IsUnread respjson.Field LinkedMessageID respjson.Field + Links respjson.Field + Mentions respjson.Field Reactions respjson.Field + Seen respjson.Field SenderName respjson.Field + SendStatus respjson.Field Text respjson.Field Type respjson.Field ExtraFields map[string]respjson.Field @@ -161,6 +212,175 @@ func (r *Message) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +// Link preview included with a message. +type MessageLink struct { + // Link preview title. + Title string `json:"title" api:"required"` + // Resolved link URL. + URL string `json:"url" api:"required"` + // Favicon URL if available. May be temporary or local-only to this device; + // download promptly if durable access is needed. + Favicon string `json:"favicon"` + // Preview image URL if available. May be temporary or local-only to this device; + // download promptly if durable access is needed. + Img string `json:"img"` + // Preview image dimensions. + ImgSize MessageLinkImgSize `json:"imgSize"` + // Original URL when the displayed URL is shortened or redirected. + OriginalURL string `json:"originalURL"` + // Link preview summary. + Summary string `json:"summary"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Title respjson.Field + URL respjson.Field + Favicon respjson.Field + Img respjson.Field + ImgSize respjson.Field + OriginalURL respjson.Field + Summary respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r MessageLink) RawJSON() string { return r.JSON.raw } +func (r *MessageLink) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Preview image dimensions. +type MessageLinkImgSize struct { + Height float64 `json:"height"` + Width float64 `json:"width"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Height respjson.Field + Width respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r MessageLinkImgSize) RawJSON() string { return r.JSON.raw } +func (r *MessageLinkImgSize) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// MessageSeenUnion contains all possible properties and values from [bool], +// [time.Time], [map[string]MessageSeenByParticipantItemUnion]. +// +// Use the methods beginning with 'As' to cast the union to one of its variants. +// +// If the underlying value is not a json object, one of the following properties +// will be valid: OfBoolean OfTimestamp] +type MessageSeenUnion struct { + // This field will be present if the value is a [bool] instead of an object. + OfBoolean bool `json:",inline"` + // This field will be present if the value is a [time.Time] instead of an object. + OfTimestamp time.Time `json:",inline"` + JSON struct { + OfBoolean respjson.Field + OfTimestamp respjson.Field + raw string + } `json:"-"` +} + +func (u MessageSeenUnion) AsBoolean() (v bool) { + apijson.UnmarshalRoot(json.RawMessage(u.JSON.raw), &v) + return +} + +func (u MessageSeenUnion) AsTimestamp() (v time.Time) { + apijson.UnmarshalRoot(json.RawMessage(u.JSON.raw), &v) + return +} + +func (u MessageSeenUnion) AsByParticipant() (v map[string]MessageSeenByParticipantItemUnion) { + apijson.UnmarshalRoot(json.RawMessage(u.JSON.raw), &v) + return +} + +// Returns the unmodified JSON received from the API +func (u MessageSeenUnion) RawJSON() string { return u.JSON.raw } + +func (r *MessageSeenUnion) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// MessageSeenByParticipantItemUnion contains all possible properties and values +// from [bool], [time.Time]. +// +// Use the methods beginning with 'As' to cast the union to one of its variants. +// +// If the underlying value is not a json object, one of the following properties +// will be valid: OfBoolean OfTimestamp] +type MessageSeenByParticipantItemUnion struct { + // This field will be present if the value is a [bool] instead of an object. + OfBoolean bool `json:",inline"` + // This field will be present if the value is a [time.Time] instead of an object. + OfTimestamp time.Time `json:",inline"` + JSON struct { + OfBoolean respjson.Field + OfTimestamp respjson.Field + raw string + } `json:"-"` +} + +func (u MessageSeenByParticipantItemUnion) AsBoolean() (v bool) { + apijson.UnmarshalRoot(json.RawMessage(u.JSON.raw), &v) + return +} + +func (u MessageSeenByParticipantItemUnion) AsTimestamp() (v time.Time) { + apijson.UnmarshalRoot(json.RawMessage(u.JSON.raw), &v) + return +} + +// Returns the unmodified JSON received from the API +func (u MessageSeenByParticipantItemUnion) RawJSON() string { return u.JSON.raw } + +func (r *MessageSeenByParticipantItemUnion) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Message send status for this message, when reported by the bridge. +type MessageSendStatus struct { + // Current status of the message send attempt. + // + // Any of "SUCCESS", "PENDING", "FAIL_RETRIABLE", "FAIL_PERMANENT". + Status string `json:"status" api:"required"` + // Timestamp for the send status event. + Timestamp time.Time `json:"timestamp" api:"required" format:"date-time"` + // User IDs the message was delivered to, when reported by the network. + DeliveredToUsers []string `json:"deliveredToUsers"` + // Internal bridge error detail. Intended for diagnostics, not end-user display. + InternalError string `json:"internalError"` + // Human-readable send status or failure message. + Message string `json:"message"` + // Machine-readable failure reason. Present when the send status is a failure. + Reason string `json:"reason"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Status respjson.Field + Timestamp respjson.Field + DeliveredToUsers respjson.Field + InternalError respjson.Field + Message respjson.Field + Reason respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r MessageSendStatus) RawJSON() string { return r.JSON.raw } +func (r *MessageSendStatus) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + // Message content type. Useful for distinguishing reactions, media messages, and // state events from regular text messages. type MessageType string @@ -179,8 +399,9 @@ const ( ) type Reaction struct { - // Reaction ID, typically ${participantID}${reactionKey} if multiple reactions - // allowed, or just participantID otherwise. + // Reaction ID. When a participant can react more than once, the ID is the + // participant ID concatenated with the reaction key; otherwise it equals the + // participant ID. ID string `json:"id" api:"required"` // User ID of the participant who reacted. ParticipantID string `json:"participantID" api:"required"` @@ -221,8 +442,9 @@ type User struct { Email string `json:"email"` // Display name as shown in clients (e.g., 'Alice Example'). May include emojis. FullName string `json:"fullName"` - // Avatar image URL if available. May be temporary or local-only to this device; - // download promptly if durable access is needed. + // Avatar image URL if available. This may be a remote URL, Matrix media URL, data + // URL, or local filesystem URL depending on source and endpoint. May be temporary + // or local-only to this device; download promptly if durable access is needed. ImgURL string `json:"imgURL"` // True if this user represents the authenticated account's own identity. IsSelf bool `json:"isSelf"` diff --git a/usage_test.go b/usage_test.go index 1b24b2a..a13b0e0 100644 --- a/usage_test.go +++ b/usage_test.go @@ -7,9 +7,9 @@ import ( "os" "testing" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/internal/testutil" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/internal/testutil" + "github.com/beeper/desktop-api-go/v5/option" ) func TestUsage(t *testing.T) { @@ -25,6 +25,7 @@ func TestUsage(t *testing.T) { option.WithAccessToken("My Access Token"), ) page, err := client.Chats.Search(context.TODO(), beeperdesktopapi.ChatSearchParams{ + AccountIDs: []string{"matrix", "discordgo", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc"}, IncludeMuted: beeperdesktopapi.Bool(true), Limit: beeperdesktopapi.Int(3), Type: beeperdesktopapi.ChatSearchParamsTypeSingle,