├── .gitignore ├── cat.jpg ├── .travis.yml ├── internal ├── cursors │ ├── types.go │ └── cursors.go ├── authorizer │ ├── types.go │ └── authorizer.go ├── authenticator │ └── authenticator.go ├── common │ └── helpers.go └── core │ ├── types.go │ └── core.go ├── Gopkg.toml ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── LICENSE.md ├── aliases.go ├── Gopkg.lock ├── README.md ├── CHANGELOG.md ├── client.go └── client_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | -------------------------------------------------------------------------------- /cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pusher/chatkit-server-go/HEAD/cat.jpg -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: go 3 | go: 4 | - "1.9" 5 | - "1.10" 6 | - "1.11" 7 | - tip 8 | script: 9 | go test -v ./... 10 | -------------------------------------------------------------------------------- /internal/cursors/types.go: -------------------------------------------------------------------------------- 1 | package cursors 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Cursor represents a read cursor. 8 | type Cursor struct { 9 | CursorType uint `json:"cursor_type"` 10 | RoomID string `json:"room_id"` 11 | UserID string `json:"user_id"` 12 | Position uint `json:"position"` 13 | UpdatedAt time.Time `json:"updated_at"` 14 | } 15 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | name = "github.com/pusher/pusher-platform-go" 30 | version = "0.1.2" 31 | 32 | [[constraint]] 33 | name = "github.com/smartystreets/goconvey" 34 | version = "1.6.3" 35 | 36 | [prune] 37 | go-tests = true 38 | unused-packages = true 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | 24 | **Expected behavior** 25 | A clear and concise description of what you expected to happen. 26 | 27 | **Screenshots** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Additional context** 31 | SDK version: 32 | Platform/ OS/ Browser: 33 | 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Pusher Ltd. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | **Is your feature request related to a problem? Please describe.** 15 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 16 | 17 | **Describe the solution you'd like** 18 | A clear and concise description of what you want to happen. 19 | 20 | **Describe alternatives you've considered** 21 | A clear and concise description of any alternative solutions or features you've considered. 22 | 23 | **Additional context** 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /internal/authorizer/types.go: -------------------------------------------------------------------------------- 1 | package authorizer 2 | 3 | import "encoding/json" 4 | 5 | // Role represents a chatkit authorizer role. 6 | type Role struct { 7 | Name string `json:"name"` // Name of new role 8 | Permissions []string `json:"permissions"` // List of permissions for role 9 | Scope string `json:"scope"` // Scope of the new role (global or room) 10 | } 11 | 12 | func (r *Role) UnmarshalJSON(b []byte) error { 13 | // Custom unmarshal logic because some routes give us a "name" and some 14 | // give us a "role_name". 15 | var raw struct { 16 | Name string `json:"name"` 17 | RoleName string `json:"role_name"` 18 | Permissions []string `json:"permissions"` 19 | Scope string `json:"scope"` 20 | } 21 | 22 | if err := json.Unmarshal(b, &raw); err != nil { 23 | return err 24 | } 25 | 26 | if raw.Name == "" { 27 | raw.Name = raw.RoleName 28 | } 29 | 30 | *r = Role{ 31 | Name: raw.Name, 32 | Permissions: raw.Permissions, 33 | Scope: raw.Scope, 34 | } 35 | 36 | return nil 37 | } 38 | 39 | // CreateRoleOptions contains information to pass to the CreateRole method. 40 | type CreateRoleOptions struct { 41 | Name string 42 | Permissions []string 43 | } 44 | 45 | // AssignRoleOptions contains information to pass to the AssignRoleToUser method. 46 | type AssignRoleOptions struct { 47 | CreateRoleOptions 48 | } 49 | 50 | // UserRole represents the type of role associated with a user. 51 | type UserRole struct { 52 | Name string `json:"name"` // Name of the role 53 | RoomID *string `json:"room_id,omitempty"` // Optional room id. If empty, the scope is global 54 | } 55 | 56 | // UpdateRolePermissionsOptions contains permissions to add/remove 57 | // permissions to/ from a role. 58 | type UpdateRolePermissionsOptions struct { 59 | PermissionsToAdd []string `json:"add_permissions,omitempty"` // Permissions to add 60 | PermissionsToRemove []string `json:"remove_permissions,omitempty"` // Permissions to remove 61 | } 62 | -------------------------------------------------------------------------------- /internal/authenticator/authenticator.go: -------------------------------------------------------------------------------- 1 | // Package authenticator exposes an interface that performs authentication and authorization. 2 | // It is primarily used to generate JWT access tokens. 3 | package authenticator 4 | 5 | import ( 6 | auth "github.com/pusher/pusher-platform-go/auth" 7 | ) 8 | 9 | // Exposes helper methods for authentication. 10 | // Extends the `auth.Authenticator` interface. 11 | type Service interface { 12 | Authenticate(payload auth.Payload, options auth.Options) (*auth.Response, error) 13 | GenerateAccessToken(options auth.Options) (auth.TokenWithExpiry, error) 14 | GenerateSUToken(options auth.Options) (auth.TokenWithExpiry, error) 15 | } 16 | 17 | type authenticator struct { 18 | platformAuthenticator auth.Authenticator 19 | } 20 | 21 | // NewService returns a new instance of an authenticator that conforms to the `Service` interface. 22 | func NewService( 23 | instanceID string, 24 | keyID string, 25 | keySecret string, 26 | ) Service { 27 | return &authenticator{ 28 | platformAuthenticator: auth.New(instanceID, keyID, keySecret), 29 | } 30 | } 31 | 32 | // Authenticate should be used within a token providing endpoint to 33 | // generate access tokens for a user. 34 | func (a *authenticator) Authenticate( 35 | payload auth.Payload, 36 | options auth.Options, 37 | ) (*auth.Response, error) { 38 | return a.platformAuthenticator.Do(payload, options) 39 | } 40 | 41 | // GenerateAccessToken returns a TokenWithExpiry based on the options provided. 42 | func (a *authenticator) GenerateAccessToken( 43 | options auth.Options, 44 | ) (auth.TokenWithExpiry, error) { 45 | return a.platformAuthenticator.GenerateAccessToken(options) 46 | } 47 | 48 | // GenerateSUToken returns a TokenWithExpiry with the `su` claim set to true. 49 | func (a *authenticator) GenerateSUToken(options auth.Options) (auth.TokenWithExpiry, error) { 50 | return a.GenerateAccessToken(auth.Options{ 51 | UserID: options.UserID, 52 | ServiceClaims: options.ServiceClaims, 53 | Su: true, 54 | TokenExpiry: options.TokenExpiry, 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /aliases.go: -------------------------------------------------------------------------------- 1 | package chatkit 2 | 3 | import ( 4 | "github.com/pusher/chatkit-server-go/internal/authorizer" 5 | "github.com/pusher/chatkit-server-go/internal/core" 6 | "github.com/pusher/chatkit-server-go/internal/cursors" 7 | 8 | auth "github.com/pusher/pusher-platform-go/auth" 9 | platformclient "github.com/pusher/pusher-platform-go/client" 10 | ) 11 | 12 | const GrantTypeClientCredentials = auth.GrantTypeClientCredentials 13 | 14 | type ( 15 | AuthenticatePayload = auth.Payload 16 | AuthenticateOptions = auth.Options 17 | 18 | ErrorResponse = platformclient.ErrorResponse 19 | RequestOptions = platformclient.RequestOptions 20 | 21 | CreateRoleOptions = authorizer.CreateRoleOptions 22 | UpdateRolePermissionsOptions = authorizer.UpdateRolePermissionsOptions 23 | Role = authorizer.Role 24 | 25 | Cursor = cursors.Cursor 26 | 27 | GetUsersOptions = core.GetUsersOptions 28 | CreateUserOptions = core.CreateUserOptions 29 | UpdateUserOptions = core.UpdateUserOptions 30 | GetRoomsOptions = core.GetRoomsOptions 31 | CreateRoomOptions = core.CreateRoomOptions 32 | UpdateRoomOptions = core.UpdateRoomOptions 33 | SendMessageOptions = core.SendMessageOptions 34 | SendMultipartMessageOptions = core.SendMultipartMessageOptions 35 | SendSimpleMessageOptions = core.SendSimpleMessageOptions 36 | NewPart = core.NewPart 37 | NewInlinePart = core.NewInlinePart 38 | NewURLPart = core.NewURLPart 39 | NewAttachmentPart = core.NewAttachmentPart 40 | GetRoomMessagesOptions = core.GetRoomMessagesOptions 41 | DeleteMessageOptions = core.DeleteMessageOptions 42 | EditMessageOptions = core.EditMessageOptions 43 | EditSimpleMessageOptions = core.EditSimpleMessageOptions 44 | EditMultipartMessageOptions = core.EditMultipartMessageOptions 45 | FetchMultipartMessageOptions = core.FetchMultipartMessageOptions 46 | FetchMultipartMessagesOptions = core.FetchMultipartMessagesOptions 47 | User = core.User 48 | Room = core.Room 49 | RoomWithoutMembers = core.RoomWithoutMembers 50 | Message = core.Message 51 | MultipartMessage = core.MultipartMessage 52 | Part = core.Part 53 | Attachment = core.Attachment 54 | ) 55 | 56 | var ExplicitlyResetPushNotificationTitleOverride = &core.ExplicitlyResetPushNotificationTitleOverride 57 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | digest = "1:f14d1b50e0075fb00177f12a96dd7addf93d1e2883c25befd17285b779549795" 7 | name = "github.com/gopherjs/gopherjs" 8 | packages = ["js"] 9 | pruneopts = "UT" 10 | revision = "3e4dfb77656c424b6d1196a4d5fed0fcf63677cc" 11 | 12 | [[projects]] 13 | digest = "1:4b63210654b1f2b664f74ec434a1bb1cb442b3d75742cc064a10808d1cca6361" 14 | name = "github.com/jtolds/gls" 15 | packages = ["."] 16 | pruneopts = "UT" 17 | revision = "b4936e06046bbecbb94cae9c18127ebe510a2cb9" 18 | version = "v4.20" 19 | 20 | [[projects]] 21 | digest = "1:a485c5e5d2dfe43525400261971e534809374e6feb40322b0ce9d9d2194ea979" 22 | name = "github.com/pusher/jwt-go" 23 | packages = ["."] 24 | pruneopts = "UT" 25 | revision = "f46fb7ef125ee49d7b4219e50386b304c4300fbb" 26 | source = "github.com/pusher/jwt-go.git" 27 | version = "v3.0.1" 28 | 29 | [[projects]] 30 | digest = "1:3be05ca31ff952d889fa8785a0025e39fad3da73e4d6dc8a4bd85dc3ba510184" 31 | name = "github.com/pusher/pusher-platform-go" 32 | packages = [ 33 | "auth", 34 | "client", 35 | "instance", 36 | ] 37 | pruneopts = "UT" 38 | revision = "9972b66271c7eb8c0508dfb16e90d7f80caa5e5f" 39 | version = "0.1.2" 40 | 41 | [[projects]] 42 | digest = "1:237af0cf68bac89e21af72e6cd6b64f388854895e75f82ad08c6c011e1a8286c" 43 | name = "github.com/smartystreets/assertions" 44 | packages = [ 45 | ".", 46 | "internal/go-diff/diffmatchpatch", 47 | "internal/go-render/render", 48 | "internal/oglematchers", 49 | ] 50 | pruneopts = "UT" 51 | revision = "f487f9de1cd36ebab28235b9373028812fb47cbd" 52 | version = "1.10.1" 53 | 54 | [[projects]] 55 | digest = "1:a3e081e593ee8e3b0a9af6a5dcac964c67a40c4f2034b5345b2ad78d05920728" 56 | name = "github.com/smartystreets/goconvey" 57 | packages = [ 58 | "convey", 59 | "convey/gotest", 60 | "convey/reporting", 61 | ] 62 | pruneopts = "UT" 63 | revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857" 64 | version = "1.6.3" 65 | 66 | [[projects]] 67 | branch = "master" 68 | digest = "1:b521f10a2d8fa85c04a8ef4e62f2d1e14d303599a55d64dabf9f5a02f84d35eb" 69 | name = "golang.org/x/sync" 70 | packages = ["errgroup"] 71 | pruneopts = "UT" 72 | revision = "112230192c580c3556b8cee6403af37a4fc5f28c" 73 | 74 | [solve-meta] 75 | analyzer-name = "dep" 76 | analyzer-version = 1 77 | input-imports = [ 78 | "github.com/pusher/pusher-platform-go/auth", 79 | "github.com/pusher/pusher-platform-go/client", 80 | "github.com/pusher/pusher-platform-go/instance", 81 | "github.com/smartystreets/goconvey/convey", 82 | "golang.org/x/sync/errgroup", 83 | ] 84 | solver-name = "gps-cdcl" 85 | solver-version = 1 86 | -------------------------------------------------------------------------------- /internal/common/helpers.go: -------------------------------------------------------------------------------- 1 | // Package common provides helpers that are shared across the other packages. 2 | package common 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | 12 | "github.com/pusher/pusher-platform-go/auth" 13 | "github.com/pusher/pusher-platform-go/client" 14 | "github.com/pusher/pusher-platform-go/instance" 15 | ) 16 | 17 | // DecodeResponseBody takes an io.Reader and decodes the body into a destination struct 18 | func DecodeResponseBody(body io.Reader, dest interface{}) error { 19 | decoder := json.NewDecoder(body) 20 | err := decoder.Decode(dest) 21 | if err != nil { 22 | return fmt.Errorf("Failed to decode response body: %s", err.Error()) 23 | } 24 | 25 | return nil 26 | } 27 | 28 | // CreateRequestBody takes a struct/ map and converts it into an io.Reader 29 | func CreateRequestBody(target interface{}) (io.Reader, error) { 30 | bodyBytes, err := json.Marshal(target) 31 | if err != nil { 32 | return nil, fmt.Errorf("Failed to marshal into json: %v", err) 33 | } 34 | 35 | return bytes.NewReader(bodyBytes), nil 36 | } 37 | 38 | // generateTokenFromInstance generates a token with the given options. 39 | func generateTokenFromInstance(inst instance.Instance, options auth.Options) (string, error) { 40 | tokenWithExpiry, err := inst.GenerateAccessToken(options) 41 | if err != nil { 42 | return "", fmt.Errorf("Failed to generate token: %s", err.Error()) 43 | } 44 | 45 | return tokenWithExpiry.Token, nil 46 | } 47 | 48 | // RequestWithToken makes a request and includes token generation as a part of it. 49 | // It generates a token with the `su` claim. 50 | func RequestWithSuToken( 51 | inst instance.Instance, 52 | ctx context.Context, 53 | options client.RequestOptions, 54 | ) (*http.Response, error) { 55 | token, err := generateTokenFromInstance(inst, auth.Options{Su: true}) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return inst.Request(ctx, client.RequestOptions{ 61 | Method: options.Method, 62 | Path: options.Path, 63 | Body: options.Body, 64 | Headers: options.Headers, 65 | QueryParams: options.QueryParams, 66 | Jwt: &token, 67 | }) 68 | } 69 | 70 | // RequestWithUserToken makes a request and includes the user id as part of the `sub` claim. 71 | func RequestWithUserToken( 72 | inst instance.Instance, 73 | ctx context.Context, 74 | userID string, 75 | options client.RequestOptions, 76 | ) (*http.Response, error) { 77 | token, err := generateTokenFromInstance(inst, auth.Options{UserID: &userID, Su: true}) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | return inst.Request(ctx, client.RequestOptions{ 83 | Method: options.Method, 84 | Path: options.Path, 85 | Body: options.Body, 86 | Headers: options.Headers, 87 | QueryParams: options.QueryParams, 88 | Jwt: &token, 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chatkit Retirement Announcement 2 | We are sorry to say that as of April 23 2020, we will be fully retiring our 3 | Chatkit product. We understand that this will be disappointing to customers who 4 | have come to rely on the service, and are very sorry for the disruption that 5 | this will cause for them. Our sales and customer support teams are available at 6 | this time to handle enquiries and will support existing Chatkit customers as 7 | far as they can with transition. All Chatkit billing has now ceased , and 8 | customers will pay no more up to or beyond their usage for the remainder of the 9 | service. You can read more about our decision to retire Chatkit here: 10 | [https://blog.pusher.com/narrowing-our-product-focus](https://blog.pusher.com/narrowing-our-product-focus). 11 | If you are interested in learning about how you can build chat with Pusher 12 | Channels, check out our tutorials. 13 | 14 | # chatkit-server-go [![godoc-badge][]][GoDoc] [![Build Status](https://travis-ci.org/pusher/chatkit-server-go.svg?branch=master)](https://travis-ci.org/pusher/chatkit-server-go) 15 | 16 | Golang server SDK for [Pusher Chatkit][]. 17 | 18 | This package provides an interface to interact with the Chatkit service. It allows 19 | interacting with the core Chatkit service and other subservices. 20 | 21 | Please report any bugs or feature requests via a GitHub issue on this repo. 22 | 23 | ## Installation 24 | 25 | $ go get github.com/pusher/chatkit-server-go 26 | 27 | ## Go versions 28 | 29 | This library requires Go versions >=1.9. 30 | 31 | ## Getting started 32 | 33 | Before processing, ensure that you have created an account on the [Chatkit Dashboard](https://dash.pusher.com). 34 | You'll then have to create a Chatkit instance and acquire credentials for it (all of which is available in the dashboard.) 35 | 36 | A new client may be instantiated as follows 37 | 38 | ```go 39 | import "github.com/pusher/chatkit-server-go" 40 | 41 | client, err := chatkit.NewClient("", "") 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // use client to make calls to the service 47 | ``` 48 | 49 | ## Deprecated versions 50 | 51 | Versions of the library below [1.0.0](https://github.com/pusher/chatkit-server-go/releases/tag/1.0.0) are deprecated and support for them will soon be dropped. 52 | 53 | It is highly recommended that you upgrade to the latest version if you're on an older version. To view a list of changes, 54 | please refer to the [CHANGELOG][]. 55 | 56 | 57 | ## Tests 58 | 59 | To run the tests, a Chatkit instance is required along with its credentials. `CHATKIT_INSTANCE_LOCATOR` and `CHATKIT_INSTANCE_KEY` are required 60 | to be set as environment variables. Note that the tests run against an actual cluster and are to be treated as integration tests. 61 | 62 | $ go test -v 63 | 64 | ## Documentation 65 | 66 | Refer to [GoDoc][]. 67 | 68 | ## License 69 | 70 | This code is free to use under the terms of the MIT license. Please refer to 71 | LICENSE.md for more information. 72 | 73 | [GoDoc]: http://godoc.org/github.com/pusher/chatkit-server-go 74 | [Pusher Chatkit]: https://pusher.com/chatkit 75 | [godoc-badge]: https://godoc.org/github.com/pusher/chatkit-server-go?status.svg 76 | [CHANGELOG]: CHANGELOG.md 77 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased](https://github.com/pusher/chatkit-server-go/compare/3.1.0...HEAD) 9 | 10 | ## [3.3.0](https://github.com/pusher/chatkit-server-go/compare/3.1.0...3.3.0) 11 | 12 | ### Additions 13 | 14 | - Adds message editing via `Edit{Simple,Multipart,}Message`. 15 | 16 | ### Fixes 17 | 18 | - Parameters passed in URL components are now URL encoded. 19 | - Response bodies are Close'd even if an err occurs. 20 | 21 | ## 3.2.0 Yanked 22 | 23 | ## [3.1.0](https://github.com/pusher/chatkit-server-go/compare/3.0.0...3.1.0) 24 | 25 | ### Additions 26 | 27 | - Support for fetching a message by its message ID, via `FetchMultipartMessage`. 28 | 29 | ## [3.0.0](https://github.com/pusher/chatkit-server-go/compare/3.0.0...2.1.1) 30 | 31 | ### Changes 32 | 33 | - Return new type from GetRooms that better indicates that room members are 34 | not returned 35 | 36 | ## [2.1.1](https://github.com/pusher/chatkit-server-go/compare/2.1.1...2.1.0) 37 | 38 | ### Fixes 39 | 40 | - Make it possible to clear a previously defined push notification title override 41 | 42 | ## [2.1.0](https://github.com/pusher/chatkit-server-go/compare/2.1.0...2.0.0) 43 | 44 | ### Additions 45 | 46 | - Support for `PushNotificationTitleOverride` attribute in the Room model and 47 | corresponding Update and Create structs. 48 | 49 | ## [2.0.0](https://github.com/pusher/chatkit-server-go/compare/1.2.0...2.0.0) 50 | 51 | ### Additions 52 | 53 | - Support for user specified room IDs. Provide the `ID` parameter to the 54 | `CreateRoom` method. 55 | 56 | ### Changes 57 | 58 | - The `DeleteMessage` method now _requires_ a room ID parameter, `RoomID`, and 59 | the `ID` parameter has been renamed to `MessageId` to avoid ambiguity. 60 | 61 | ## [1.2.0](https://github.com/pusher/chatkit-server-go/compare/1.1.0...1.2.0) 62 | 63 | - Multipart message support: `SendSimpleMessage`, `SendMultipartMessage`, 64 | `FetchMessagesMultipart`, and `SubscribeToRoomMultipart` deal in the 65 | multipart message format. 66 | 67 | ## [1.1.0](https://github.com/pusher/chatkit-server-go/compare/1.0.0...1.1.0) - 2018-11-07 68 | 69 | ### Additions 70 | 71 | - A `CustomData` attribute for the `UpdateRoomOptions` and `CreateRoomOptions` structs. 72 | - A `CustomData` attribute for the `Room` model. 73 | 74 | ## [1.0.0](https://github.com/pusher/chatkit-server-go/compare/0.2.0...1.0.0) - 2018-10-30 75 | 76 | The SDK has been rewritten from the ground up, so best to assume that 77 | everything has changed and refer to the [GoDoc][]. 78 | 79 | ## [0.2.0](https://github.com/pusher/chatkit-server-go/compare/0.1.0...0.2.0) - 2018-04-24 80 | 81 | ### Changes 82 | 83 | - `TokenManager` renamed to `Authenticator` 84 | 85 | ### Removals 86 | 87 | - `NewChatkitUserToken` has been removed 88 | - `NewChatkitSUToken` has been removed 89 | 90 | ### Additions 91 | 92 | - `NewChatkitToken` has been added and essentially replaces `NewChatkitSUToken` and `NewChatkitUserToken` 93 | - `Authenticate` added to `Client` 94 | 95 | [godoc]: http://godoc.org/github.com/pusher/chatkit-server-go 96 | -------------------------------------------------------------------------------- /internal/cursors/cursors.go: -------------------------------------------------------------------------------- 1 | // Package cursors exposes an interface that allows making requests to the Chatkit cursors service. 2 | package cursors 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | 10 | "github.com/pusher/chatkit-server-go/internal/common" 11 | 12 | "github.com/pusher/pusher-platform-go/client" 13 | "github.com/pusher/pusher-platform-go/instance" 14 | ) 15 | 16 | const readCursorType = 0 17 | 18 | // Exposes methods to interact with the cursors API. 19 | type Service interface { 20 | GetUserReadCursors(ctx context.Context, userID string) ([]Cursor, error) 21 | SetReadCursor(ctx context.Context, userID string, roomID string, position uint) error 22 | GetReadCursorsForRoom(ctx context.Context, roomID string) ([]Cursor, error) 23 | GetReadCursor(ctx context.Context, userID string, roomID string) (Cursor, error) 24 | 25 | // Generic requests 26 | Request(ctx context.Context, options client.RequestOptions) (*http.Response, error) 27 | } 28 | 29 | type cursorsService struct { 30 | underlyingInstance instance.Instance 31 | } 32 | 33 | // Returns a new cursorsService instance conforming to 34 | // the Service interface 35 | func NewService(platformInstance instance.Instance) Service { 36 | return &cursorsService{ 37 | underlyingInstance: platformInstance, 38 | } 39 | } 40 | 41 | // GetUserReadCursors retrieves cursors for a user. 42 | func (cs *cursorsService) GetUserReadCursors(ctx context.Context, userID string) ([]Cursor, error) { 43 | if userID == "" { 44 | return nil, errors.New("You must provide the ID of the user whos read cursors you want to fetch") 45 | } 46 | 47 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 48 | Method: http.MethodGet, 49 | Path: fmt.Sprintf("/cursors/%d/users/%s", readCursorType, userID), 50 | }) 51 | if err != nil { 52 | return nil, err 53 | } 54 | defer response.Body.Close() 55 | 56 | var cursors []Cursor 57 | err = common.DecodeResponseBody(response.Body, &cursors) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | return cursors, nil 63 | } 64 | 65 | // SetReadCursor sets a read cursor for a given room and user. 66 | func (cs *cursorsService) SetReadCursor( 67 | ctx context.Context, 68 | userID string, 69 | roomID string, 70 | position uint, 71 | ) error { 72 | if userID == "" { 73 | return errors.New("You must provide the ID of the user whose read cursor you want to set") 74 | } 75 | 76 | requestBody, err := common.CreateRequestBody(map[string]uint{"position": position}) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 82 | Method: http.MethodPut, 83 | Path: fmt.Sprintf( 84 | "/cursors/%d/rooms/%s/users/%s", 85 | readCursorType, 86 | roomID, 87 | userID, 88 | ), 89 | Body: requestBody, 90 | }) 91 | if err != nil { 92 | return err 93 | } 94 | defer response.Body.Close() 95 | 96 | return nil 97 | } 98 | 99 | // GetReadCursorsForRoom retrieves read cursors for a given room. 100 | func (cs *cursorsService) GetReadCursorsForRoom(ctx context.Context, roomID string) ([]Cursor, error) { 101 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 102 | Method: http.MethodGet, 103 | Path: fmt.Sprintf("/cursors/%d/rooms/%s", readCursorType, roomID), 104 | }) 105 | if err != nil { 106 | return nil, err 107 | } 108 | defer response.Body.Close() 109 | 110 | var cursors []Cursor 111 | err = common.DecodeResponseBody(response.Body, &cursors) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | return cursors, nil 117 | } 118 | 119 | // GetReadCursor fetches a single cursor for a given user and room. 120 | func (cs *cursorsService) GetReadCursor( 121 | ctx context.Context, 122 | userID string, 123 | roomID string, 124 | ) (Cursor, error) { 125 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 126 | Method: http.MethodGet, 127 | Path: fmt.Sprintf("/cursors/%d/rooms/%s/users/%s", readCursorType, roomID, userID), 128 | }) 129 | if err != nil { 130 | return Cursor{}, nil 131 | } 132 | defer response.Body.Close() 133 | 134 | var cursor Cursor 135 | err = common.DecodeResponseBody(response.Body, &cursor) 136 | if err != nil { 137 | return Cursor{}, nil 138 | } 139 | 140 | return cursor, nil 141 | } 142 | 143 | // Request allows performing requests to the cursors service and returns the raw http response. 144 | func (cs *cursorsService) Request( 145 | ctx context.Context, 146 | options client.RequestOptions, 147 | ) (*http.Response, error) { 148 | return cs.underlyingInstance.Request(ctx, options) 149 | } 150 | -------------------------------------------------------------------------------- /internal/core/types.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "io" 5 | "time" 6 | ) 7 | 8 | // User represents a chatkit user. 9 | type User struct { 10 | ID string `json:"id"` // ID of the user 11 | Name string `json:"name"` // Name associated with the user 12 | AvatarURL string `json:"avatar_url,omitempty"` // Link to a photo/ image of the user 13 | CustomData map[string]interface{} `json:"custom_data,omitempty"` // A custom data object associated with the user 14 | CreatedAt time.Time `json:"created_at"` // Creation timestamp 15 | UpdatedAt time.Time `json:"updated_at"` // Updating timestamp 16 | } 17 | 18 | // Room represents a chatkit room. 19 | type Room struct { 20 | RoomWithoutMembers 21 | MemberUserIDs []string `json:"member_user_ids,omitempty"` // List of user id's in the room 22 | } 23 | 24 | // RoomWithoutMembers represents a chatkit room without listing its members. 25 | type RoomWithoutMembers struct { 26 | ID string `json:"id"` // ID assigned to a room 27 | CreatedByID string `json:"created_by_id"` // User ID that created the room 28 | Name string `json:"name"` // Name assigned to the room 29 | PushNotificationTitleOverride *string `json:"push_notification_title_override,omitempty"` // Optionally override Push Notification title 30 | Private bool `json:"private"` // Indicates if room is private or not 31 | CustomData interface{} `json:"custom_data,omitempty"` // Custom data that can be added to rooms 32 | CreatedAt time.Time `json:"created_at"` // Creation timestamp 33 | UpdatedAt time.Time `json:"updated_at"` // Updation timestamp 34 | } 35 | 36 | type messageIsh interface { 37 | isMessageIsh() 38 | } 39 | 40 | // Message represents a message sent to a chatkit room. 41 | type Message struct { 42 | ID uint `json:"id"` // Message ID 43 | UserID string `json:"user_id"` // User that sent the message 44 | RoomID string `json:"room_id"` // Room the message was sent to 45 | Text string `json:"text"` // Content of the message 46 | CreatedAt time.Time `json:"created_at"` // Creation timestamp 47 | UpdatedAt time.Time `json:"updated_at"` // Updation timestamp 48 | } 49 | 50 | func (Message) isMessageIsh() {} 51 | 52 | // MultipartMessage represents a message sent to a chatkit room. 53 | type MultipartMessage struct { 54 | ID uint `json:"id"` // Message ID 55 | UserID string `json:"user_id"` // User that sent the message 56 | RoomID string `json:"room_id"` // Room the message was sent to 57 | Parts []Part `json:"parts"` // Parts composing the message 58 | CreatedAt time.Time `json:"created_at"` // Creation timestamp 59 | UpdatedAt time.Time `json:"updated_at"` // Updation timestamp 60 | } 61 | 62 | func (MultipartMessage) isMessageIsh() {} 63 | 64 | type Part struct { 65 | Type string `json:"type"` 66 | Content *string `json:"content,omitempty"` 67 | URL *string `json:"url,omitempty"` 68 | Attachment *Attachment `json:"attachment,omitempty"` 69 | } 70 | 71 | type Attachment struct { 72 | ID string `json:"id"` 73 | DownloadURL string `json:"download_url"` 74 | RefreshURL string `json:"refresh_url"` 75 | Expiration time.Time `json:"expiration"` 76 | Name string `json:"name"` 77 | CustomData interface{} `json:"custom_data,omitempty"` 78 | Size uint `json:"size"` 79 | } 80 | 81 | // GetUsersOptions contains parameters to pass when fetching users. 82 | type GetUsersOptions struct { 83 | FromTimestamp string 84 | Limit uint 85 | } 86 | 87 | // CreateUserOptions contains parameters to pass when creating a new user. 88 | type CreateUserOptions struct { 89 | ID string `json:"id"` 90 | Name string `json:"name"` 91 | AvatarURL *string `json:"avatar_url,omitempty"` 92 | CustomData interface{} `json:"custom_data,omitempty"` 93 | } 94 | 95 | // UpdateUserOptions contains parameters to pass when updating a user. 96 | type UpdateUserOptions struct { 97 | Name *string `json:"name,omitempty"` 98 | AvatarUrl *string `json:"avatar_url,omitempty"` 99 | CustomData interface{} `json:"custom_data,omitempty"` 100 | } 101 | 102 | // CreateRoomOptions contains parameters to pass when creating a new room. 103 | type CreateRoomOptions struct { 104 | ID *string `json:"id,omitempty"` 105 | Name string `json:"name"` 106 | PushNotificationTitleOverride *string `json:"push_notification_title_override,omitempty"` 107 | Private bool `json:"private"` 108 | UserIDs []string `json:"user_ids,omitempty"` // User ID's to be added to the room during creation 109 | CustomData interface{} `json:"custom_data,omitempty"` 110 | CreatorID string 111 | } 112 | 113 | // GetRoomsOptions contains parameters to pass to fetch rooms. 114 | type GetRoomsOptions struct { 115 | FromID *string `json:"from_id,omitempty"` 116 | IncludePrivate bool `json:"include_private"` 117 | } 118 | 119 | // UpdateRoomOptions contains parameters to pass when updating a room. 120 | type UpdateRoomOptions struct { 121 | Name *string `json:"name,omitempty"` 122 | PushNotificationTitleOverride *string `json:"push_notification_title_override,omitempty"` 123 | Private *bool `json:"private,omitempty"` 124 | CustomData interface{} `json:"custom_data,omitempty"` 125 | } 126 | 127 | // ExplicitlyResetPushNotificationTitleOverride when used in the UpdateRoomOptions 128 | // signifies that the override is to be removed entirely 129 | var ExplicitlyResetPushNotificationTitleOverride = "null" 130 | 131 | // SendMessageOptions contains parameters to pass when sending a new message. 132 | type SendMessageOptions = SendSimpleMessageOptions 133 | 134 | // SendMultipartMessageOptions contains parameters to pass when sending a new message. 135 | type SendMultipartMessageOptions struct { 136 | RoomID string 137 | SenderID string 138 | Parts []NewPart 139 | } 140 | 141 | // SendSimpleMessageOptions contains parameters to pass when sending a new message. 142 | type SendSimpleMessageOptions struct { 143 | RoomID string 144 | Text string 145 | SenderID string 146 | } 147 | 148 | type EditMessageOptions = EditSimpleMessageOptions 149 | 150 | // EditSimpleMessageOption contains parameters to pass when editing an existing message. 151 | type EditSimpleMessageOptions struct { 152 | SenderID string 153 | Text string 154 | } 155 | 156 | type EditMultipartMessageOptions struct { 157 | SenderID string 158 | Parts []NewPart 159 | } 160 | 161 | type NewPart interface { 162 | isNewPart() 163 | } 164 | 165 | type NewInlinePart struct { 166 | Type string `json:"type"` 167 | Content string `json:"content"` 168 | } 169 | 170 | func (p NewInlinePart) isNewPart() {} 171 | 172 | type NewURLPart struct { 173 | Type string `json:"type"` 174 | URL string `json:"url"` 175 | } 176 | 177 | func (p NewURLPart) isNewPart() {} 178 | 179 | // NewAttachmentPart has no JSON annotations because it cannot be sent directly 180 | // to the backend. The attachment must first be uploaded and a 181 | // newAttachmentPartUploaded sent instead. 182 | type NewAttachmentPart struct { 183 | Type string 184 | Name *string 185 | CustomData interface{} 186 | File io.Reader 187 | } 188 | 189 | func (p NewAttachmentPart) isNewPart() {} 190 | 191 | type newAttachmentPartUploaded struct { 192 | Type string `json:"type"` 193 | Attachment uploadedAttachment `json:"attachment"` 194 | } 195 | 196 | type uploadedAttachment struct { 197 | ID string `json:"id"` 198 | } 199 | 200 | // FetchMultipartMessageOptions contains parameters to pass when fetching a single message from a room. 201 | type FetchMultipartMessageOptions struct { 202 | RoomID string 203 | MessageID uint 204 | } 205 | 206 | type fetchMessagesOptions struct { 207 | InitialID *uint // Starting ID of messages to retrieve 208 | Direction *string // One of older or newer 209 | Limit *uint // Number of messages to retrieve 210 | } 211 | 212 | // FetchMultipartMessagesOptions contains parameters to pass when fetching messages from a room. 213 | type FetchMultipartMessagesOptions = fetchMessagesOptions 214 | 215 | // GetRoomMessagesOptions contains parameters to pass when fetching messages from a room. 216 | type GetRoomMessagesOptions = fetchMessagesOptions 217 | 218 | type DeleteMessageOptions struct { 219 | RoomID string 220 | MessageID uint 221 | } 222 | -------------------------------------------------------------------------------- /internal/authorizer/authorizer.go: -------------------------------------------------------------------------------- 1 | // Package core expoeses the Authorizer API that allows making requests to the 2 | // Chatkit Authorizer service. This allows manipulation of Roles and Permissions. 3 | // 4 | // All methods that are part of the interface require a JWT token with a 5 | // `su` to be able to access them. 6 | package authorizer 7 | 8 | import ( 9 | "context" 10 | "errors" 11 | "fmt" 12 | "net/http" 13 | "net/url" 14 | 15 | "github.com/pusher/chatkit-server-go/internal/common" 16 | 17 | "github.com/pusher/pusher-platform-go/client" 18 | "github.com/pusher/pusher-platform-go/instance" 19 | ) 20 | 21 | const ( 22 | scopeGlobal = "global" 23 | scopeRoom = "room" 24 | ) 25 | 26 | // Exposes methods to interact with the roles and permissions API. 27 | type Service interface { 28 | // Roles 29 | GetRoles(ctx context.Context) ([]Role, error) 30 | CreateGlobalRole(ctx context.Context, options CreateRoleOptions) error 31 | CreateRoomRole(ctx context.Context, options CreateRoleOptions) error 32 | DeleteGlobalRole(ctx context.Context, roleName string) error 33 | DeleteRoomRole(ctx context.Context, roleName string) error 34 | 35 | // Permissions 36 | GetPermissionsForGlobalRole(ctx context.Context, roleName string) ([]string, error) 37 | GetPermissionsForRoomRole(ctx context.Context, roleName string) ([]string, error) 38 | UpdatePermissionsForGlobalRole( 39 | ctx context.Context, 40 | roleName string, 41 | options UpdateRolePermissionsOptions, 42 | ) error 43 | UpdatePermissionsForRoomRole( 44 | ctx context.Context, 45 | roleName string, 46 | options UpdateRolePermissionsOptions, 47 | ) error 48 | 49 | // User roles 50 | GetUserRoles(ctx context.Context, userID string) ([]Role, error) 51 | AssignGlobalRoleToUser(ctx context.Context, userID string, roleName string) error 52 | AssignRoomRoleToUser(ctx context.Context, userID string, roomID string, roleName string) error 53 | RemoveGlobalRoleForUser(ctx context.Context, userID string) error 54 | RemoveRoomRoleForUser(ctx context.Context, userID string, roomID string) error 55 | 56 | // Generic requests 57 | Request(ctx context.Context, options client.RequestOptions) (*http.Response, error) 58 | } 59 | 60 | type authorizerService struct { 61 | underlyingInstance instance.Instance 62 | } 63 | 64 | // Returns an new authorizerService instance conforming to the Service interface. 65 | func NewService(platformInstance instance.Instance) Service { 66 | return &authorizerService{ 67 | underlyingInstance: platformInstance, 68 | } 69 | } 70 | 71 | // GetRoles fetches all roles for an instance. 72 | func (as *authorizerService) GetRoles(ctx context.Context) ([]Role, error) { 73 | response, err := common.RequestWithSuToken(as.underlyingInstance, ctx, client.RequestOptions{ 74 | Method: http.MethodGet, 75 | Path: "/roles", 76 | }) 77 | if err != nil { 78 | return nil, err 79 | } 80 | defer response.Body.Close() 81 | 82 | var roles []Role 83 | err = common.DecodeResponseBody(response.Body, &roles) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | return roles, nil 89 | } 90 | 91 | // CreateGlobalRole creates a global role for an instance. 92 | func (as *authorizerService) CreateGlobalRole(ctx context.Context, options CreateRoleOptions) error { 93 | return as.createRole(ctx, Role{ 94 | Name: options.Name, 95 | Permissions: options.Permissions, 96 | Scope: scopeGlobal, 97 | }) 98 | } 99 | 100 | // CreateRoomRole creates a room role for an instance. 101 | func (as *authorizerService) CreateRoomRole(ctx context.Context, options CreateRoleOptions) error { 102 | return as.createRole(ctx, Role{ 103 | Name: options.Name, 104 | Permissions: options.Permissions, 105 | Scope: scopeRoom, 106 | }) 107 | } 108 | 109 | // createRole is used by CreateGlobalRole and CreateRoomRole. 110 | func (as *authorizerService) createRole(ctx context.Context, role Role) error { 111 | if role.Name == "" { 112 | return errors.New("You must provide a name for the role") 113 | } 114 | 115 | if role.Permissions == nil { 116 | return errors.New("You must provide permissions of the role") 117 | } 118 | 119 | requestBody, err := common.CreateRequestBody(&role) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | response, err := common.RequestWithSuToken(as.underlyingInstance, ctx, client.RequestOptions{ 125 | Method: http.MethodPost, 126 | Path: "/roles", 127 | Body: requestBody, 128 | }) 129 | if err != nil { 130 | return err 131 | } 132 | defer response.Body.Close() 133 | 134 | return nil 135 | } 136 | 137 | // DeleteGlobalRole deletes a role with the given name at the global scope. 138 | func (as *authorizerService) DeleteGlobalRole(ctx context.Context, roleName string) error { 139 | return as.deleteRole(ctx, roleName, scopeGlobal) 140 | } 141 | 142 | // DeleteRoomRole deletes a role with the given name at the room scope. 143 | func (as *authorizerService) DeleteRoomRole(ctx context.Context, roleName string) error { 144 | return as.deleteRole(ctx, roleName, scopeRoom) 145 | } 146 | 147 | // deleteRole is used by DeleteGlobalRole and DeleteRoomRole. 148 | func (as *authorizerService) deleteRole(ctx context.Context, roleName string, scope string) error { 149 | response, err := common.RequestWithSuToken(as.underlyingInstance, ctx, client.RequestOptions{ 150 | Method: http.MethodDelete, 151 | Path: fmt.Sprintf("/roles/%s/scope/%s", roleName, scope), 152 | }) 153 | if err != nil { 154 | return err 155 | } 156 | defer response.Body.Close() 157 | 158 | return nil 159 | } 160 | 161 | // GetPermissionsForGlobalRole retrieves a list of permissions associated with the role. 162 | func (as *authorizerService) GetPermissionsForGlobalRole( 163 | ctx context.Context, 164 | roleName string, 165 | ) ([]string, error) { 166 | return as.getPermissions(ctx, roleName, scopeGlobal) 167 | } 168 | 169 | // GetPermissionsForRoomRole retrieves a list of permissions associated with the role. 170 | func (as *authorizerService) GetPermissionsForRoomRole( 171 | ctx context.Context, 172 | roleName string, 173 | ) ([]string, error) { 174 | return as.getPermissions(ctx, roleName, scopeRoom) 175 | } 176 | 177 | // getPermissions is used by GetPermissionsForGlobalRole and GetPermissionsForRoomRole. 178 | func (as *authorizerService) getPermissions( 179 | ctx context.Context, 180 | roleName string, 181 | scope string, 182 | ) ([]string, error) { 183 | response, err := common.RequestWithSuToken(as.underlyingInstance, ctx, client.RequestOptions{ 184 | Method: http.MethodGet, 185 | Path: fmt.Sprintf("/roles/%s/scope/%s/permissions", roleName, scope), 186 | }) 187 | if err != nil { 188 | return nil, err 189 | } 190 | defer response.Body.Close() 191 | 192 | var rolePermissions []string 193 | err = common.DecodeResponseBody(response.Body, &rolePermissions) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | return rolePermissions, nil 199 | } 200 | 201 | // UpdatePermissionsForGlobalRole allows updating permissions associated with a global role. 202 | func (as *authorizerService) UpdatePermissionsForGlobalRole( 203 | ctx context.Context, 204 | roleName string, 205 | options UpdateRolePermissionsOptions, 206 | ) error { 207 | return as.updatePermissions(ctx, roleName, options, scopeGlobal) 208 | } 209 | 210 | // UpdatePermissionsForRoomRole allows updating permissions associated with a room role. 211 | func (as *authorizerService) UpdatePermissionsForRoomRole( 212 | ctx context.Context, 213 | roleName string, 214 | options UpdateRolePermissionsOptions, 215 | ) error { 216 | return as.updatePermissions(ctx, roleName, options, scopeRoom) 217 | } 218 | 219 | // updatePermissions is used by UpdatePermissionsForGlobalRole and UpdatePermissionsForRoomRole. 220 | func (as *authorizerService) updatePermissions( 221 | ctx context.Context, 222 | roleName string, 223 | options UpdateRolePermissionsOptions, 224 | scope string, 225 | ) error { 226 | if (options.PermissionsToAdd == nil || len(options.PermissionsToAdd) == 0) && 227 | (options.PermissionsToRemove == nil || len(options.PermissionsToRemove) == 0) { 228 | return errors.New("PermissionsToAdd and PermissionsToRemove cannot both be empty") 229 | } 230 | 231 | requestBody, err := common.CreateRequestBody(&options) 232 | if err != nil { 233 | return err 234 | } 235 | 236 | response, err := common.RequestWithSuToken(as.underlyingInstance, ctx, client.RequestOptions{ 237 | Method: http.MethodPut, 238 | Path: fmt.Sprintf("/roles/%s/scope/%s/permissions", roleName, scope), 239 | Body: requestBody, 240 | }) 241 | if err != nil { 242 | return err 243 | } 244 | defer response.Body.Close() 245 | 246 | return nil 247 | } 248 | 249 | // GetUserRoles fetches roles associated with a user. 250 | func (as *authorizerService) GetUserRoles(ctx context.Context, userID string) ([]Role, error) { 251 | if userID == "" { 252 | return nil, errors.New("You must provide the ID of the user whose roles you want to fetch") 253 | } 254 | 255 | response, err := common.RequestWithSuToken(as.underlyingInstance, ctx, client.RequestOptions{ 256 | Method: http.MethodGet, 257 | Path: fmt.Sprintf("/users/%s/roles", userID), 258 | }) 259 | if err != nil { 260 | return nil, err 261 | } 262 | defer response.Body.Close() 263 | 264 | var roles []Role 265 | err = common.DecodeResponseBody(response.Body, &roles) 266 | if err != nil { 267 | return nil, err 268 | } 269 | 270 | return roles, nil 271 | } 272 | 273 | // AssignGlobalRoleToUser assigns a previously created global role to a user. 274 | func (as *authorizerService) AssignGlobalRoleToUser( 275 | ctx context.Context, 276 | userID string, 277 | roleName string, 278 | ) error { 279 | return as.assignRoleToUser(ctx, userID, roleName, nil) 280 | } 281 | 282 | // AssignRoomRoleToUser assigns a previously created room role to a user. 283 | func (as *authorizerService) AssignRoomRoleToUser( 284 | ctx context.Context, 285 | userID string, 286 | roomID string, 287 | roleName string, 288 | ) error { 289 | return as.assignRoleToUser(ctx, userID, roleName, &roomID) 290 | } 291 | 292 | // assignRoleToUser is used by AssignGlobalRoleToUser and AssignRoomRoleToUser. 293 | func (as *authorizerService) assignRoleToUser( 294 | ctx context.Context, 295 | userID string, 296 | roleName string, 297 | roomID *string, 298 | ) error { 299 | if userID == "" { 300 | return errors.New("You must provide the ID of the user you want to assign a role to") 301 | } 302 | 303 | if roleName == "" { 304 | return errors.New("You must provide the role name of the role you want to assign") 305 | } 306 | 307 | requestBody, err := common.CreateRequestBody(&UserRole{Name: roleName, RoomID: roomID}) 308 | if err != nil { 309 | return err 310 | } 311 | 312 | response, err := common.RequestWithSuToken(as.underlyingInstance, ctx, client.RequestOptions{ 313 | Method: http.MethodPut, 314 | Path: fmt.Sprintf("/users/%s/roles", userID), 315 | Body: requestBody, 316 | }) 317 | if err != nil { 318 | return err 319 | } 320 | defer response.Body.Close() 321 | 322 | return nil 323 | } 324 | 325 | // RemoveGlobalRoleForUser removes a role that was previously assigned to a user. 326 | // A user can only have one global role assigned at a time. 327 | func (as *authorizerService) RemoveGlobalRoleForUser(ctx context.Context, userID string) error { 328 | return as.removeRoleForUser(ctx, userID, nil) 329 | } 330 | 331 | // RemoveRoomRoleForUser removes a role that was previously assigned to a user. 332 | // One user can have several room roles assigned to them (1 per room) 333 | func (as *authorizerService) RemoveRoomRoleForUser( 334 | ctx context.Context, 335 | userID string, 336 | roomID string, 337 | ) error { 338 | return as.removeRoleForUser(ctx, userID, &roomID) 339 | } 340 | 341 | // removeRole is used by RemoveGlobalRoleForUser and RemoveRoomRoleForUser 342 | func (as *authorizerService) removeRoleForUser( 343 | ctx context.Context, 344 | userID string, 345 | roomID *string, 346 | ) error { 347 | if userID == "" { 348 | return errors.New("You must provide the ID of the user you want to remove a role for") 349 | } 350 | 351 | queryParams := url.Values{} 352 | if roomID != nil { 353 | queryParams.Add("room_id", *roomID) 354 | } 355 | 356 | response, err := common.RequestWithSuToken(as.underlyingInstance, ctx, client.RequestOptions{ 357 | Method: http.MethodDelete, 358 | Path: fmt.Sprintf("/users/%s/roles", userID), 359 | QueryParams: &queryParams, 360 | }) 361 | if err != nil { 362 | return err 363 | } 364 | defer response.Body.Close() 365 | 366 | return nil 367 | } 368 | 369 | // Request allows performing requests to the authorizer service that return a raw http response. 370 | func (as *authorizerService) Request( 371 | ctx context.Context, 372 | options client.RequestOptions, 373 | ) (*http.Response, error) { 374 | return as.underlyingInstance.Request(ctx, options) 375 | } 376 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Package chatkit is the Golang server SDK for Pusher Chatkit. 2 | // This package provides functionality to interact with various Chatkit services. 3 | // 4 | // More information can be found in the Chatkit docs: https://docs.pusher.com/chatkit/overview/. 5 | // 6 | // Please report any bugs or feature requests at: https://github.com/pusher/chatkit-server-go. 7 | package chatkit 8 | 9 | import ( 10 | "context" 11 | "net/http" 12 | 13 | "github.com/pusher/chatkit-server-go/internal/authenticator" 14 | "github.com/pusher/chatkit-server-go/internal/authorizer" 15 | "github.com/pusher/chatkit-server-go/internal/core" 16 | "github.com/pusher/chatkit-server-go/internal/cursors" 17 | 18 | "github.com/pusher/pusher-platform-go/auth" 19 | platformclient "github.com/pusher/pusher-platform-go/client" 20 | "github.com/pusher/pusher-platform-go/instance" 21 | ) 22 | 23 | // Public interface for the library. 24 | // It allows interacting with different Chatkit services. 25 | type Client struct { 26 | coreServiceV2 core.Service 27 | coreServiceV6 core.Service 28 | authorizerService authorizer.Service 29 | cursorsService cursors.Service 30 | authenticatorService authenticator.Service 31 | } 32 | 33 | // NewClient returns an instantiated instance that fulfils the Client interface. 34 | func NewClient(instanceLocator string, key string) (*Client, error) { 35 | locatorComponents, err := instance.ParseInstanceLocator(instanceLocator) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | keyComponents, err := instance.ParseKey(key) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | baseClient := platformclient.New(platformclient.Options{ 46 | Host: locatorComponents.Host(), 47 | }) 48 | 49 | coreInstanceV2, err := instance.New(instance.Options{ 50 | Locator: instanceLocator, 51 | Key: key, 52 | ServiceName: "chatkit", 53 | ServiceVersion: "v2", 54 | Client: baseClient, 55 | }) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | coreInstanceV6, err := instance.New(instance.Options{ 61 | Locator: instanceLocator, 62 | Key: key, 63 | ServiceName: "chatkit", 64 | ServiceVersion: "v6", 65 | Client: baseClient, 66 | }) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | authorizerInstance, err := instance.New(instance.Options{ 72 | Locator: instanceLocator, 73 | Key: key, 74 | ServiceName: "chatkit_authorizer", 75 | ServiceVersion: "v2", 76 | Client: baseClient, 77 | }) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | cursorsInstance, err := instance.New(instance.Options{ 83 | Locator: instanceLocator, 84 | Key: key, 85 | ServiceName: "chatkit_cursors", 86 | ServiceVersion: "v2", 87 | Client: baseClient, 88 | }) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | return &Client{ 94 | coreServiceV2: core.NewService(coreInstanceV2), 95 | coreServiceV6: core.NewService(coreInstanceV6), 96 | authorizerService: authorizer.NewService(authorizerInstance), 97 | cursorsService: cursors.NewService(cursorsInstance), 98 | authenticatorService: authenticator.NewService( 99 | locatorComponents.InstanceID, 100 | keyComponents.Key, 101 | keyComponents.Secret, 102 | ), 103 | }, nil 104 | } 105 | 106 | // GetUserReadCursors returns a list of cursors that have been set across different rooms 107 | // for the user. 108 | func (c *Client) GetUserReadCursors(ctx context.Context, userID string) ([]Cursor, error) { 109 | return c.cursorsService.GetUserReadCursors(ctx, userID) 110 | } 111 | 112 | // SetReadCursor sets the cursor position for a room for a user. 113 | // The position points to the message ID of a message that was sent to that room. 114 | func (c *Client) SetReadCursor(ctx context.Context, userID string, roomID string, position uint) error { 115 | return c.cursorsService.SetReadCursor(ctx, userID, roomID, position) 116 | } 117 | 118 | // GetReadCursorsForRoom returns a list of cursors that have been set for a room. 119 | // This returns cursors irrespective of the user that set them. 120 | func (c *Client) GetReadCursorsForRoom(ctx context.Context, roomID string) ([]Cursor, error) { 121 | return c.cursorsService.GetReadCursorsForRoom(ctx, roomID) 122 | } 123 | 124 | // GetReadCursor returns a single cursor that was set by a user in a room. 125 | func (c *Client) GetReadCursor(ctx context.Context, userID string, roomID string) (Cursor, error) { 126 | return c.cursorsService.GetReadCursor(ctx, userID, roomID) 127 | } 128 | 129 | // CursorsRequest allows performing a request to the cursors service that returns a raw HTTP 130 | // response. 131 | func (c *Client) CursorsRequest( 132 | ctx context.Context, 133 | options platformclient.RequestOptions, 134 | ) (*http.Response, error) { 135 | return c.cursorsService.Request(ctx, options) 136 | } 137 | 138 | // GetRoles retrieves all roles associated with an instance. 139 | func (c *Client) GetRoles(ctx context.Context) ([]Role, error) { 140 | return c.authorizerService.GetRoles(ctx) 141 | } 142 | 143 | // CreateGlobalRole allows creating a globally scoped role. 144 | func (c *Client) CreateGlobalRole(ctx context.Context, options CreateRoleOptions) error { 145 | return c.authorizerService.CreateGlobalRole(ctx, options) 146 | } 147 | 148 | // CreateRoomRole allows creating a room scoped role. 149 | func (c *Client) CreateRoomRole(ctx context.Context, options CreateRoleOptions) error { 150 | return c.authorizerService.CreateRoomRole(ctx, options) 151 | } 152 | 153 | // DeleteGlobalRole deletes a previously created globally scoped role. 154 | func (c *Client) DeleteGlobalRole(ctx context.Context, roleName string) error { 155 | return c.authorizerService.DeleteGlobalRole(ctx, roleName) 156 | } 157 | 158 | // DeleteRoomRole deletes a previously created room scoped role. 159 | func (c *Client) DeleteRoomRole(ctx context.Context, roleName string) error { 160 | return c.authorizerService.DeleteRoomRole(ctx, roleName) 161 | } 162 | 163 | // GetPermissionsForGlobalRole returns permissions associated with a previously created global role. 164 | func (c *Client) GetPermissionsForGlobalRole( 165 | ctx context.Context, 166 | roleName string, 167 | ) ([]string, error) { 168 | return c.authorizerService.GetPermissionsForGlobalRole(ctx, roleName) 169 | } 170 | 171 | // GetPermissionsForRoomRole returns permissions associated with a previously created room role. 172 | func (c *Client) GetPermissionsForRoomRole( 173 | ctx context.Context, 174 | roleName string, 175 | ) ([]string, error) { 176 | return c.authorizerService.GetPermissionsForRoomRole(ctx, roleName) 177 | } 178 | 179 | // UpdatePermissionsForGlobalRole allows adding or removing permissions from a previously created 180 | // globally scoped role. 181 | func (c *Client) UpdatePermissionsForGlobalRole( 182 | ctx context.Context, 183 | roleName string, 184 | options UpdateRolePermissionsOptions, 185 | ) error { 186 | return c.authorizerService.UpdatePermissionsForGlobalRole(ctx, roleName, options) 187 | } 188 | 189 | // UpdatePermissionsForRoomROle allows adding or removing permissions from a previously created 190 | // room scoped role. 191 | func (c *Client) UpdatePermissionsForRoomRole( 192 | ctx context.Context, 193 | roleName string, 194 | options UpdateRolePermissionsOptions, 195 | ) error { 196 | return c.authorizerService.UpdatePermissionsForRoomRole(ctx, roleName, options) 197 | } 198 | 199 | // GetUserRoles returns roles assosciated with a user. 200 | func (c *Client) GetUserRoles(ctx context.Context, userID string) ([]Role, error) { 201 | return c.authorizerService.GetUserRoles(ctx, userID) 202 | } 203 | 204 | // AssignGlobalRoleToUser assigns a previously created globally scoped role to a user. 205 | func (c *Client) AssignGlobalRoleToUser(ctx context.Context, userID string, roleName string) error { 206 | return c.authorizerService.AssignGlobalRoleToUser(ctx, userID, roleName) 207 | } 208 | 209 | // AssignRoomRoleToUser assigns a previously created room scoped role to a user. 210 | func (c *Client) AssignRoomRoleToUser( 211 | ctx context.Context, 212 | userID string, 213 | roomID string, 214 | roleName string, 215 | ) error { 216 | return c.authorizerService.AssignRoomRoleToUser(ctx, userID, roomID, roleName) 217 | } 218 | 219 | // RemoveGlobalRoleForUser removes a previously assigned globally scoped role from a user. 220 | // Users can only have one globall scoped role associated at any point. 221 | func (c *Client) RemoveGlobalRoleForUser(ctx context.Context, userID string) error { 222 | return c.authorizerService.RemoveGlobalRoleForUser(ctx, userID) 223 | } 224 | 225 | // RemoveRoomRoleForUser removes a previously assigned room scoped role from a user. 226 | // Users can have multiple room roles associated with them, but only one role per room. 227 | func (c *Client) RemoveRoomRoleForUser(ctx context.Context, userID string, roomID string) error { 228 | return c.authorizerService.RemoveRoomRoleForUser(ctx, userID, roomID) 229 | } 230 | 231 | // AuthorizerRequest allows performing requests to the authorizer service 232 | // and returns a raw HTTP response. 233 | func (c *Client) AuthorizerRequest( 234 | ctx context.Context, 235 | options platformclient.RequestOptions, 236 | ) (*http.Response, error) { 237 | return c.authorizerService.Request(ctx, options) 238 | } 239 | 240 | // GetUser retrieves a previously created Chatkit user. 241 | func (c *Client) GetUser(ctx context.Context, userID string) (User, error) { 242 | return c.coreServiceV6.GetUser(ctx, userID) 243 | } 244 | 245 | // GetUsers retrieves a list of users based on the options provided. 246 | func (c *Client) GetUsers(ctx context.Context, options *GetUsersOptions) ([]User, error) { 247 | return c.coreServiceV6.GetUsers(ctx, options) 248 | } 249 | 250 | // GetUsersByID retrieves a list of users for the given id's. 251 | func (c *Client) GetUsersByID(ctx context.Context, userIDs []string) ([]User, error) { 252 | return c.coreServiceV6.GetUsersByID(ctx, userIDs) 253 | } 254 | 255 | // CreateUser creates a new chatkit user. 256 | func (c *Client) CreateUser(ctx context.Context, options CreateUserOptions) error { 257 | return c.coreServiceV6.CreateUser(ctx, options) 258 | } 259 | 260 | // CreateUsers creates a batch of users. 261 | func (c *Client) CreateUsers(ctx context.Context, users []CreateUserOptions) error { 262 | return c.coreServiceV6.CreateUsers(ctx, users) 263 | } 264 | 265 | // UpdateUser allows updating a previously created user. 266 | func (c *Client) UpdateUser(ctx context.Context, userID string, options UpdateUserOptions) error { 267 | return c.coreServiceV6.UpdateUser(ctx, userID, options) 268 | } 269 | 270 | // DeleteUser deletes a previously created user. 271 | func (c *Client) DeleteUser(ctx context.Context, userID string) error { 272 | return c.coreServiceV6.DeleteUser(ctx, userID) 273 | } 274 | 275 | // GetRoom retrieves an existing room. 276 | func (c *Client) GetRoom(ctx context.Context, roomID string) (Room, error) { 277 | return c.coreServiceV6.GetRoom(ctx, roomID) 278 | } 279 | 280 | // GetRooms retrieves a list of rooms based on the options provided. 281 | func (c *Client) GetRooms(ctx context.Context, options GetRoomsOptions) ([]core.RoomWithoutMembers, error) { 282 | return c.coreServiceV6.GetRooms(ctx, options) 283 | } 284 | 285 | // GetUserRooms retrieves a list of rooms the user is an existing member of. 286 | func (c *Client) GetUserRooms(ctx context.Context, userID string) ([]Room, error) { 287 | return c.coreServiceV6.GetUserRooms(ctx, userID) 288 | } 289 | 290 | // GetUserJoinableRooms retrieves a list of rooms the use can join (not an existing member of) 291 | // Private rooms are not returned as part of the response. 292 | func (c *Client) GetUserJoinableRooms(ctx context.Context, userID string) ([]Room, error) { 293 | return c.coreServiceV6.GetUserJoinableRooms(ctx, userID) 294 | } 295 | 296 | // CreateRoom creates a new room. 297 | func (c *Client) CreateRoom(ctx context.Context, options CreateRoomOptions) (Room, error) { 298 | return c.coreServiceV6.CreateRoom(ctx, options) 299 | } 300 | 301 | // UpdateRoom allows updating an existing room. 302 | func (c *Client) UpdateRoom(ctx context.Context, roomID string, options UpdateRoomOptions) error { 303 | return c.coreServiceV6.UpdateRoom(ctx, roomID, options) 304 | } 305 | 306 | // DeleteRoom deletes an existing room. 307 | func (c *Client) DeleteRoom(ctx context.Context, roomID string) error { 308 | return c.coreServiceV6.DeleteRoom(ctx, roomID) 309 | } 310 | 311 | // AddUsersToRoom adds new users to an existing room. 312 | func (c *Client) AddUsersToRoom(ctx context.Context, roomID string, userIDs []string) error { 313 | return c.coreServiceV6.AddUsersToRoom(ctx, roomID, userIDs) 314 | } 315 | 316 | // RemoveUsersFromRoom removes existing members from a room. 317 | func (c *Client) RemoveUsersFromRoom(ctx context.Context, roomID string, userIDs []string) error { 318 | return c.coreServiceV6.RemoveUsersFromRoom(ctx, roomID, userIDs) 319 | } 320 | 321 | // SendMessage publishes a new message to a room. 322 | func (c *Client) SendMessage(ctx context.Context, options SendMessageOptions) (uint, error) { 323 | return c.coreServiceV2.SendMessage(ctx, options) 324 | } 325 | 326 | // SendMultipartMessage publishes a new multipart message to a room. 327 | func (c *Client) SendMultipartMessage( 328 | ctx context.Context, 329 | options SendMultipartMessageOptions, 330 | ) (uint, error) { 331 | return c.coreServiceV6.SendMultipartMessage(ctx, options) 332 | } 333 | 334 | // SendSimpleMessage publishes a new simple multipart message to a room. 335 | func (c *Client) SendSimpleMessage( 336 | ctx context.Context, 337 | options SendSimpleMessageOptions, 338 | ) (uint, error) { 339 | return c.coreServiceV6.SendSimpleMessage(ctx, options) 340 | } 341 | 342 | // GetRoomMessages retrieves messages previously sent to a room based on the options provided. 343 | func (c *Client) GetRoomMessages( 344 | ctx context.Context, 345 | roomID string, 346 | options GetRoomMessagesOptions, 347 | ) ([]Message, error) { 348 | return c.coreServiceV2.GetRoomMessages(ctx, roomID, options) 349 | } 350 | 351 | // FetchMultipartMessage retrieves a single message previously sent to a room based on the options provided. 352 | func (c *Client) FetchMultipartMessage( 353 | ctx context.Context, 354 | options FetchMultipartMessageOptions, 355 | ) (MultipartMessage, error) { 356 | return c.coreServiceV6.FetchMultipartMessage(ctx, options) 357 | } 358 | 359 | // FetchMultipartMessages retrieves messages previously sent to a room based on 360 | // the options provided. 361 | func (c *Client) FetchMultipartMessages( 362 | ctx context.Context, 363 | roomID string, 364 | options GetRoomMessagesOptions, 365 | ) ([]MultipartMessage, error) { 366 | return c.coreServiceV6.FetchMultipartMessages(ctx, roomID, options) 367 | } 368 | 369 | // DeleteMessage allows a previously sent message to be deleted. 370 | func (c *Client) DeleteMessage(ctx context.Context, options DeleteMessageOptions) error { 371 | return c.coreServiceV6.DeleteMessage(ctx, options) 372 | } 373 | 374 | // EditMessage identifies an existing message by both its room and message id 375 | // in order to replace it's content and sender id with updated values. 376 | func (c *Client) EditMessage(ctx context.Context, roomID string, messageID uint, options EditMessageOptions) error { 377 | return c.coreServiceV2.EditMessage(ctx, roomID, messageID, options) 378 | } 379 | 380 | // EditMultipartMessage identifies an existing message by both its room and message id 381 | // in order to replace it's content and sender id with updated values. 382 | func (c *Client) EditMultipartMessage(ctx context.Context, roomID string, messageID uint, options EditMultipartMessageOptions) error { 383 | return c.coreServiceV6.EditMultipartMessage(ctx, roomID, messageID, options) 384 | } 385 | 386 | // EditSimpleMessage identifies an existing message by both its room and message id 387 | // in order to replace it's content and sender id with updated values. 388 | func (c *Client) EditSimpleMessage(ctx context.Context, roomID string, messageID uint, options EditSimpleMessageOptions) error { 389 | return c.coreServiceV6.EditSimpleMessage(ctx, roomID, messageID, options) 390 | } 391 | 392 | // CoreRequest allows making requests to the core chatkit service and returns a raw HTTP response. 393 | func (c *Client) CoreRequest( 394 | ctx context.Context, 395 | options platformclient.RequestOptions, 396 | ) (*http.Response, error) { 397 | return c.coreServiceV6.Request(ctx, options) 398 | } 399 | 400 | // Authenticate returns a token response along with headers and status code to be used within 401 | // the context of a token provider. 402 | // Currently, the only supported GrantType is GrantTypeClientCredentials. 403 | func (c *Client) Authenticate(payload auth.Payload, options auth.Options) (*auth.Response, error) { 404 | return c.authenticatorService.Authenticate(payload, options) 405 | } 406 | 407 | // GenerateAccessToken generates a JWT token based on the options provided. 408 | func (c *Client) GenerateAccessToken(options auth.Options) (auth.TokenWithExpiry, error) { 409 | return c.authenticatorService.GenerateAccessToken(options) 410 | } 411 | 412 | // GenerateSuToken generates a JWT token with the `su` claim. 413 | func (c *Client) GenerateSUToken(options auth.Options) (auth.TokenWithExpiry, error) { 414 | return c.authenticatorService.GenerateSUToken(options) 415 | } 416 | -------------------------------------------------------------------------------- /internal/core/core.go: -------------------------------------------------------------------------------- 1 | // Package core exposes an interface that allows making requests to the core 2 | // Chatkit API to allow operations to be performed against Users, Rooms and Messages. 3 | package core 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "net/http" 13 | "net/url" 14 | "strconv" 15 | 16 | "golang.org/x/sync/errgroup" 17 | 18 | "github.com/pusher/pusher-platform-go/client" 19 | "github.com/pusher/pusher-platform-go/instance" 20 | 21 | "github.com/pusher/chatkit-server-go/internal/common" 22 | ) 23 | 24 | // Service exposes methods to interact with the core chatkit service. 25 | // This allows interacting with the messages, rooms and users API. 26 | type Service interface { 27 | // Users 28 | GetUser(ctx context.Context, userID string) (User, error) 29 | GetUsers(ctx context.Context, options *GetUsersOptions) ([]User, error) 30 | GetUsersByID(ctx context.Context, userIDs []string) ([]User, error) 31 | CreateUser(ctx context.Context, options CreateUserOptions) error 32 | CreateUsers(ctx context.Context, users []CreateUserOptions) error 33 | UpdateUser(ctx context.Context, userID string, options UpdateUserOptions) error 34 | DeleteUser(ctx context.Context, userID string) error 35 | 36 | // Rooms 37 | GetRoom(ctx context.Context, roomID string) (Room, error) 38 | GetRooms(ctx context.Context, options GetRoomsOptions) ([]RoomWithoutMembers, error) 39 | GetUserRooms(ctx context.Context, userID string) ([]Room, error) 40 | GetUserJoinableRooms(ctx context.Context, userID string) ([]Room, error) 41 | CreateRoom(ctx context.Context, options CreateRoomOptions) (Room, error) 42 | UpdateRoom(ctx context.Context, roomID string, options UpdateRoomOptions) error 43 | DeleteRoom(ctx context.Context, roomID string) error 44 | AddUsersToRoom(ctx context.Context, roomID string, userIDs []string) error 45 | RemoveUsersFromRoom(ctx context.Context, roomID string, userIds []string) error 46 | 47 | // Messages 48 | SendMessage(ctx context.Context, options SendMessageOptions) (uint, error) 49 | SendMultipartMessage(ctx context.Context, options SendMultipartMessageOptions) (uint, error) 50 | SendSimpleMessage(ctx context.Context, options SendSimpleMessageOptions) (uint, error) 51 | GetRoomMessages( 52 | ctx context.Context, 53 | roomID string, 54 | options GetRoomMessagesOptions, 55 | ) ([]Message, error) 56 | FetchMultipartMessage( 57 | ctx context.Context, 58 | options FetchMultipartMessageOptions, 59 | ) (MultipartMessage, error) 60 | FetchMultipartMessages( 61 | ctx context.Context, 62 | roomID string, 63 | options FetchMultipartMessagesOptions, 64 | ) ([]MultipartMessage, error) 65 | DeleteMessage(ctx context.Context, options DeleteMessageOptions) error 66 | EditMessage(ctx context.Context, roomID string, messageID uint, options EditMessageOptions) error 67 | EditMultipartMessage(ctx context.Context, roomID string, messageID uint, options EditMultipartMessageOptions) error 68 | EditSimpleMessage(ctx context.Context, roomID string, messageID uint, options EditSimpleMessageOptions) error 69 | 70 | // Generic requests 71 | Request(ctx context.Context, options client.RequestOptions) (*http.Response, error) 72 | } 73 | 74 | type coreService struct { 75 | underlyingInstance instance.Instance 76 | } 77 | 78 | // Returns a new coreService instance that conforms to the Service interface. 79 | func NewService(platformInstance instance.Instance) Service { 80 | return &coreService{ 81 | underlyingInstance: platformInstance, 82 | } 83 | } 84 | 85 | // GetUser retrieves a user for the given user id. 86 | func (cs *coreService) GetUser(ctx context.Context, userID string) (User, error) { 87 | if userID == "" { 88 | return User{}, errors.New("You must provide the ID of the user you want to fetch") 89 | } 90 | 91 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 92 | Method: http.MethodGet, 93 | Path: fmt.Sprintf("/users/%s", url.PathEscape(userID)), 94 | }) 95 | if response != nil { 96 | defer response.Body.Close() 97 | } 98 | if err != nil { 99 | return User{}, err 100 | } 101 | 102 | var user User 103 | err = common.DecodeResponseBody(response.Body, &user) 104 | if err != nil { 105 | return User{}, err 106 | } 107 | 108 | return user, nil 109 | } 110 | 111 | // GetUsers retrieves a batch of users depending on the optionally passed in parameters. 112 | // If not options are passed in, the server will return the default limit of 20 users. 113 | func (cs *coreService) GetUsers(ctx context.Context, options *GetUsersOptions) ([]User, error) { 114 | queryParams := url.Values{} 115 | if options != nil { 116 | queryParams.Add("from_ts", options.FromTimestamp) 117 | queryParams.Add("limit", strconv.Itoa(int(options.Limit))) 118 | } 119 | 120 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 121 | Method: http.MethodGet, 122 | Path: "/users", 123 | QueryParams: &queryParams, 124 | }) 125 | if err != nil { 126 | return nil, err 127 | } 128 | defer response.Body.Close() 129 | 130 | var users []User 131 | err = common.DecodeResponseBody(response.Body, &users) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | return users, nil 137 | } 138 | 139 | // GetUsersByID returns a list of users whose ID's are supplied. 140 | func (cs *coreService) GetUsersByID(ctx context.Context, userIDs []string) ([]User, error) { 141 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 142 | Method: http.MethodGet, 143 | Path: "/users_by_ids", 144 | QueryParams: &url.Values{ 145 | "id": userIDs, 146 | }, 147 | }) 148 | if response != nil { 149 | defer response.Body.Close() 150 | } 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | var users []User 156 | err = common.DecodeResponseBody(response.Body, &users) 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | return users, nil 162 | } 163 | 164 | // CreateUser creates a new chatkit user based on the provided options. 165 | func (cs *coreService) CreateUser(ctx context.Context, options CreateUserOptions) error { 166 | if options.ID == "" { 167 | return errors.New("You must provide the ID of the user to create") 168 | } 169 | 170 | if options.Name == "" { 171 | return errors.New("You must provide the name of the user to create") 172 | } 173 | 174 | requestBody, err := common.CreateRequestBody(&options) 175 | if err != nil { 176 | return err 177 | } 178 | 179 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 180 | Method: http.MethodPost, 181 | Path: "/users", 182 | Body: requestBody, 183 | }) 184 | if response != nil { 185 | defer response.Body.Close() 186 | } 187 | if err != nil { 188 | return err 189 | } 190 | 191 | return nil 192 | } 193 | 194 | // CreateUsers creates users in a batch. 195 | // A maximum of 10 users can be created per batch. 196 | func (cs *coreService) CreateUsers(ctx context.Context, users []CreateUserOptions) error { 197 | if len(users) == 0 { 198 | return errors.New("You must provide a list of users to create") 199 | } 200 | 201 | requestBody, err := common.CreateRequestBody(map[string]interface{}{"users": users}) 202 | if err != nil { 203 | return err 204 | } 205 | 206 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 207 | Method: http.MethodPost, 208 | Path: "/batch_users", 209 | Body: requestBody, 210 | }) 211 | if response != nil { 212 | defer response.Body.Close() 213 | } 214 | if err != nil { 215 | return err 216 | } 217 | 218 | return nil 219 | } 220 | 221 | // UpdateUser updates an existing user depending on the options provided. 222 | func (cs *coreService) UpdateUser( 223 | ctx context.Context, 224 | userID string, 225 | options UpdateUserOptions, 226 | ) error { 227 | if userID == "" { 228 | return errors.New("You must provide the ID of the user to update") 229 | } 230 | 231 | requestBody, err := common.CreateRequestBody(&options) 232 | if err != nil { 233 | return err 234 | } 235 | 236 | response, err := common.RequestWithUserToken(cs.underlyingInstance, ctx, userID, client.RequestOptions{ 237 | Method: http.MethodPut, 238 | Path: fmt.Sprintf("/users/%s", url.PathEscape(userID)), 239 | Body: requestBody, 240 | }) 241 | if response != nil { 242 | defer response.Body.Close() 243 | } 244 | if err != nil { 245 | return err 246 | } 247 | 248 | return nil 249 | } 250 | 251 | // DeleteUser deles an existing user. 252 | // Users can only be deleted with a sudo token. 253 | func (cs *coreService) DeleteUser(ctx context.Context, userID string) error { 254 | if userID == "" { 255 | return errors.New("You must provide the ID of the user to delete") 256 | } 257 | 258 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 259 | Method: http.MethodDelete, 260 | Path: fmt.Sprintf("/users/%s", url.PathEscape(userID)), 261 | }) 262 | if response != nil { 263 | defer response.Body.Close() 264 | } 265 | if err != nil { 266 | return err 267 | } 268 | 269 | return nil 270 | } 271 | 272 | // GetRoom retrieves a room with the given id. 273 | func (cs *coreService) GetRoom(ctx context.Context, roomID string) (Room, error) { 274 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 275 | Method: http.MethodGet, 276 | Path: fmt.Sprintf("/rooms/%s", url.PathEscape(roomID)), 277 | }) 278 | if response != nil { 279 | defer response.Body.Close() 280 | } 281 | if err != nil { 282 | return Room{}, err 283 | } 284 | 285 | var room Room 286 | err = common.DecodeResponseBody(response.Body, &room) 287 | if err != nil { 288 | return Room{}, err 289 | } 290 | 291 | return room, nil 292 | } 293 | 294 | // GetRooms retrieves a list of rooms with the given parameters. 295 | func (cs *coreService) GetRooms(ctx context.Context, options GetRoomsOptions) ([]RoomWithoutMembers, error) { 296 | queryParams := url.Values{} 297 | if options.FromID != nil { 298 | queryParams.Add("from_id", *options.FromID) 299 | } 300 | 301 | strIncludePrivate := "false" 302 | if options.IncludePrivate { 303 | strIncludePrivate = "true" 304 | } 305 | queryParams.Add("include_private", strIncludePrivate) 306 | 307 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 308 | Method: http.MethodGet, 309 | Path: "/rooms", 310 | QueryParams: &queryParams, 311 | }) 312 | if response != nil { 313 | defer response.Body.Close() 314 | } 315 | if err != nil { 316 | return nil, err 317 | } 318 | 319 | var rooms []RoomWithoutMembers 320 | err = common.DecodeResponseBody(response.Body, &rooms) 321 | if err != nil { 322 | return nil, err 323 | } 324 | 325 | return rooms, nil 326 | } 327 | 328 | // GetUserRooms retrieves a list of rooms that the user is currently a member of. 329 | func (cs *coreService) GetUserRooms(ctx context.Context, userID string) ([]Room, error) { 330 | return cs.getRoomsForUser(ctx, userID, false) 331 | } 332 | 333 | // GetUserJoinable rooms returns a list of rooms that the user can join (has not joined previously). 334 | func (cs *coreService) GetUserJoinableRooms(ctx context.Context, userID string) ([]Room, error) { 335 | return cs.getRoomsForUser(ctx, userID, true) 336 | } 337 | 338 | // getRoomsForUser is used by GetUserRooms and GetUserJoinableRooms 339 | func (cs *coreService) getRoomsForUser( 340 | ctx context.Context, 341 | userID string, 342 | joinable bool, 343 | ) ([]Room, error) { 344 | if userID == "" { 345 | return nil, errors.New("You must privde the ID of the user to retrieve rooms for") 346 | } 347 | 348 | strJoinable := "false" 349 | if joinable { 350 | strJoinable = "true" 351 | } 352 | queryParams := url.Values{"joinable": []string{strJoinable}} 353 | 354 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 355 | Method: http.MethodGet, 356 | Path: fmt.Sprintf("/users/%s/rooms", url.PathEscape(userID)), 357 | QueryParams: &queryParams, 358 | }) 359 | if response != nil { 360 | defer response.Body.Close() 361 | } 362 | if err != nil { 363 | return nil, err 364 | } 365 | 366 | var rooms []Room 367 | err = common.DecodeResponseBody(response.Body, &rooms) 368 | if err != nil { 369 | return nil, err 370 | } 371 | 372 | return rooms, nil 373 | } 374 | 375 | // CreateRoom creates a room. 376 | func (cs *coreService) CreateRoom(ctx context.Context, options CreateRoomOptions) (Room, error) { 377 | if options.CreatorID == "" { 378 | return Room{}, errors.New("Yout must provide the ID of the user creating the room") 379 | } 380 | 381 | if options.Name == "" { 382 | return Room{}, errors.New("You must provide a name for the room") 383 | } 384 | 385 | requestBody, err := common.CreateRequestBody(&options) 386 | if err != nil { 387 | return Room{}, err 388 | } 389 | 390 | response, err := common.RequestWithUserToken( 391 | cs.underlyingInstance, 392 | ctx, 393 | options.CreatorID, client.RequestOptions{ 394 | Method: http.MethodPost, 395 | Path: "/rooms", 396 | Body: requestBody, 397 | }, 398 | ) 399 | if response != nil { 400 | defer response.Body.Close() 401 | } 402 | if err != nil { 403 | return Room{}, err 404 | } 405 | 406 | var room Room 407 | err = common.DecodeResponseBody(response.Body, &room) 408 | if err != nil { 409 | return Room{}, err 410 | } 411 | 412 | return room, nil 413 | } 414 | 415 | // UpdateRoom updates an existing room based on the options provided. 416 | func (cs *coreService) UpdateRoom(ctx context.Context, roomID string, options UpdateRoomOptions) error { 417 | var formattedOptions interface{} = options 418 | if options.PushNotificationTitleOverride == &ExplicitlyResetPushNotificationTitleOverride { 419 | type updateRoomOptionsWithExplicitPNTitleOverride struct { 420 | UpdateRoomOptions 421 | // overriding internal `UpdateRoomOptions.PushNotificationTitleOverride` by removing the `omitempty` tag 422 | PushNotificationTitleOverride *string `json:"push_notification_title_override"` 423 | } 424 | formattedOptions = updateRoomOptionsWithExplicitPNTitleOverride{ 425 | UpdateRoomOptions: options, 426 | PushNotificationTitleOverride: nil, 427 | } 428 | } 429 | 430 | requestBody, err := common.CreateRequestBody(&formattedOptions) 431 | if err != nil { 432 | return err 433 | } 434 | 435 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 436 | Method: http.MethodPut, 437 | Path: fmt.Sprintf("/rooms/%s", url.PathEscape(roomID)), 438 | Body: requestBody, 439 | }) 440 | if response != nil { 441 | defer response.Body.Close() 442 | } 443 | if err != nil { 444 | return err 445 | } 446 | 447 | return nil 448 | } 449 | 450 | // DeleteRoom deletes an existing room. 451 | func (cs *coreService) DeleteRoom(ctx context.Context, roomID string) error { 452 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 453 | Method: http.MethodDelete, 454 | Path: fmt.Sprintf("/rooms/%s", url.PathEscape(roomID)), 455 | }) 456 | if response != nil { 457 | defer response.Body.Close() 458 | } 459 | if err != nil { 460 | return err 461 | } 462 | 463 | return nil 464 | } 465 | 466 | // AddUsersToRoom adds users to an existing room. 467 | // The maximum number of users that can be added in a single request is 10. 468 | func (cs *coreService) AddUsersToRoom(ctx context.Context, roomID string, userIDs []string) error { 469 | if len(userIDs) == 0 { 470 | return errors.New("You must provide a list of IDs of the users you want to add to the room") 471 | } 472 | 473 | requestBody, err := common.CreateRequestBody(map[string][]string{"user_ids": userIDs}) 474 | if err != nil { 475 | return err 476 | } 477 | 478 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 479 | Method: http.MethodPut, 480 | Path: fmt.Sprintf("/rooms/%s/users/add", url.PathEscape(roomID)), 481 | Body: requestBody, 482 | }) 483 | if response != nil { 484 | defer response.Body.Close() 485 | } 486 | if err != nil { 487 | return err 488 | } 489 | 490 | return nil 491 | } 492 | 493 | // RemoveUsersFromRoom removes a list of users from the room. 494 | // The maximum number of users that can be removed in a single request is 10. 495 | func (cs *coreService) RemoveUsersFromRoom(ctx context.Context, roomID string, userIDs []string) error { 496 | if len(userIDs) == 0 { 497 | return errors.New("You must provide a list of IDs of the users you want to remove from the room") 498 | } 499 | 500 | requestBody, err := common.CreateRequestBody(map[string][]string{"user_ids": userIDs}) 501 | if err != nil { 502 | return err 503 | } 504 | 505 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 506 | Method: http.MethodPut, 507 | Path: fmt.Sprintf("/rooms/%s/users/remove", url.PathEscape(roomID)), 508 | Body: requestBody, 509 | }) 510 | if response != nil { 511 | defer response.Body.Close() 512 | } 513 | if err != nil { 514 | return err 515 | } 516 | 517 | return nil 518 | } 519 | 520 | // SendMessage publishes a message to a room. 521 | func (cs *coreService) SendMessage(ctx context.Context, options SendMessageOptions) (uint, error) { 522 | if options.Text == "" { 523 | return 0, errors.New("You must provide some text for the message") 524 | } 525 | 526 | if options.SenderID == "" { 527 | return 0, errors.New("You must provide the ID of the user sending the message") 528 | } 529 | 530 | requestBody, err := common.CreateRequestBody(map[string]string{"text": options.Text}) 531 | if err != nil { 532 | return 0, err 533 | } 534 | 535 | response, err := common.RequestWithUserToken( 536 | cs.underlyingInstance, 537 | ctx, 538 | options.SenderID, 539 | client.RequestOptions{ 540 | Method: http.MethodPost, 541 | Path: fmt.Sprintf("/rooms/%s/messages", url.PathEscape(options.RoomID)), 542 | Body: requestBody, 543 | }) 544 | if response != nil { 545 | defer response.Body.Close() 546 | } 547 | if err != nil { 548 | return 0, err 549 | } 550 | 551 | var messageResponse map[string]uint 552 | err = common.DecodeResponseBody(response.Body, &messageResponse) 553 | if err != nil { 554 | return 0, err 555 | } 556 | 557 | return messageResponse["message_id"], nil 558 | } 559 | 560 | // SendMultipartMessage publishes a multipart message to a room. 561 | func (cs *coreService) SendMultipartMessage( 562 | ctx context.Context, 563 | options SendMultipartMessageOptions, 564 | ) (uint, error) { 565 | if len(options.Parts) == 0 { 566 | return 0, errors.New("You must provide at least one message part") 567 | } 568 | 569 | if options.SenderID == "" { 570 | return 0, errors.New("You must provide the ID of the user sending the message") 571 | } 572 | 573 | requestParts := make([]interface{}, len(options.Parts)) 574 | g, gCtx := errgroup.WithContext(ctx) 575 | 576 | for i, part := range options.Parts { 577 | switch p := part.(type) { 578 | case NewAttachmentPart: 579 | i := i 580 | g.Go(func() error { 581 | uploadedPart, err := cs.uploadAttachment(gCtx, options.SenderID, options.RoomID, p) 582 | requestParts[i] = uploadedPart 583 | return err 584 | }) 585 | default: 586 | requestParts[i] = part 587 | } 588 | } 589 | 590 | if err := g.Wait(); err != nil { 591 | return 0, fmt.Errorf("Failed to upload attachment: %v", err) 592 | } 593 | 594 | requestBody, err := common.CreateRequestBody( 595 | map[string]interface{}{"parts": requestParts}, 596 | ) 597 | if err != nil { 598 | return 0, err 599 | } 600 | 601 | response, err := common.RequestWithUserToken( 602 | cs.underlyingInstance, 603 | ctx, 604 | options.SenderID, 605 | client.RequestOptions{ 606 | Method: http.MethodPost, 607 | Path: fmt.Sprintf("/rooms/%s/messages", url.PathEscape(options.RoomID)), 608 | Body: requestBody, 609 | }, 610 | ) 611 | if response != nil { 612 | defer response.Body.Close() 613 | } 614 | if err != nil { 615 | return 0, err 616 | } 617 | 618 | var messageResponse map[string]uint 619 | err = common.DecodeResponseBody(response.Body, &messageResponse) 620 | if err != nil { 621 | return 0, err 622 | } 623 | 624 | return messageResponse["message_id"], nil 625 | } 626 | 627 | func (cs *coreService) uploadAttachment( 628 | ctx context.Context, 629 | senderID string, 630 | roomID string, 631 | part NewAttachmentPart, 632 | ) (newAttachmentPartUploaded, error) { 633 | // Unfortunately since we need to provide the content length up front, we 634 | // have to read the whole file in to memory. 635 | b, err := ioutil.ReadAll(part.File) 636 | if err != nil { 637 | return newAttachmentPartUploaded{}, err 638 | } 639 | 640 | url, attachmentID, err := cs.requestPresignedURL( 641 | ctx, 642 | senderID, 643 | roomID, 644 | part.Type, 645 | len(b), 646 | part.Name, 647 | part.CustomData, 648 | ) 649 | if err != nil { 650 | return newAttachmentPartUploaded{}, err 651 | } 652 | 653 | if err := cs.uploadToURL(ctx, url, part.Type, len(b), bytes.NewReader(b)); err != nil { 654 | return newAttachmentPartUploaded{}, err 655 | } 656 | 657 | return newAttachmentPartUploaded{ 658 | Type: part.Type, 659 | Attachment: uploadedAttachment{attachmentID}, 660 | }, nil 661 | } 662 | 663 | func (cs *coreService) requestPresignedURL( 664 | ctx context.Context, 665 | senderID string, 666 | roomID string, 667 | contentType string, 668 | contentLength int, 669 | name *string, 670 | customData interface{}, 671 | ) (string, string, error) { 672 | body, err := common.CreateRequestBody(map[string]interface{}{ 673 | "content_type": contentType, 674 | "content_length": contentLength, 675 | "name": name, 676 | "custom_data": customData, 677 | }) 678 | if err != nil { 679 | return "", "", err 680 | } 681 | 682 | res, err := common.RequestWithUserToken( 683 | cs.underlyingInstance, 684 | ctx, 685 | senderID, 686 | client.RequestOptions{ 687 | Method: http.MethodPost, 688 | Path: fmt.Sprintf("/rooms/%s/attachments", url.PathEscape(roomID)), 689 | Body: body, 690 | }, 691 | ) 692 | if res != nil { 693 | defer res.Body.Close() 694 | } 695 | if err != nil { 696 | return "", "", err 697 | } 698 | 699 | var resBody map[string]string 700 | if err := common.DecodeResponseBody(res.Body, &resBody); err != nil { 701 | return "", "", err 702 | } 703 | 704 | return resBody["upload_url"], resBody["attachment_id"], nil 705 | } 706 | 707 | func (cs *coreService) uploadToURL( 708 | ctx context.Context, 709 | url string, 710 | contentType string, 711 | contentLength int, 712 | body io.Reader, 713 | ) error { 714 | client := &http.Client{} 715 | 716 | req, err := http.NewRequest("PUT", url, body) 717 | if err != nil { 718 | return err 719 | } 720 | req.Header.Add("content-type", contentType) 721 | req.Header.Add("content-length", strconv.Itoa(contentLength)) 722 | 723 | res, err := client.Do(req.WithContext(ctx)) 724 | if res != nil { 725 | defer res.Body.Close() 726 | } 727 | if err != nil { 728 | return err 729 | } 730 | if res.StatusCode < 200 || res.StatusCode >= 300 { 731 | return fmt.Errorf("unexpected status: %v", res.Status) 732 | } 733 | 734 | return nil 735 | } 736 | 737 | // SendSimpleMessage publishes a simple message to a room. 738 | func (cs *coreService) SendSimpleMessage( 739 | ctx context.Context, 740 | options SendSimpleMessageOptions, 741 | ) (uint, error) { 742 | return cs.SendMultipartMessage(ctx, SendMultipartMessageOptions{ 743 | RoomID: options.RoomID, 744 | SenderID: options.SenderID, 745 | Parts: []NewPart{NewInlinePart{Type: "text/plain", Content: options.Text}}, 746 | }) 747 | } 748 | 749 | // DeleteMessage deletes a previously sent message. 750 | func (cs *coreService) DeleteMessage(ctx context.Context, options DeleteMessageOptions) error { 751 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 752 | Method: http.MethodDelete, 753 | Path: fmt.Sprintf("/rooms/%s/messages/%d", url.PathEscape(options.RoomID), options.MessageID), 754 | }) 755 | if response != nil { 756 | defer response.Body.Close() 757 | } 758 | if err != nil { 759 | return nil 760 | } 761 | 762 | return nil 763 | } 764 | 765 | // EditMessage edits an existing message in a room. 766 | func (cs *coreService) EditMessage(ctx context.Context, roomID string, messageID uint, options EditMessageOptions) error { 767 | if options.Text == "" { 768 | return errors.New("You must provide some text for the message") 769 | } 770 | 771 | if options.SenderID == "" { 772 | return errors.New("You must provide the ID of the user editing the message") 773 | } 774 | 775 | if roomID == "" { 776 | return errors.New("You must provide the ID of the room in which the message to edit belongs") 777 | } 778 | 779 | requestBody, err := common.CreateRequestBody(map[string]string{"text": options.Text}) 780 | if err != nil { 781 | return err 782 | } 783 | 784 | response, err := common.RequestWithUserToken( 785 | cs.underlyingInstance, 786 | ctx, 787 | options.SenderID, 788 | client.RequestOptions{ 789 | Method: http.MethodPut, 790 | Path: fmt.Sprintf("/rooms/%s/messages/%d", url.PathEscape(roomID), messageID), 791 | Body: requestBody, 792 | }) 793 | if response != nil { 794 | defer response.Body.Close() 795 | } 796 | if err != nil { 797 | return err 798 | } 799 | 800 | return nil 801 | } 802 | 803 | // EditSimpleMessage edits an existing message in a room, replacing it with a simple message. 804 | func (cs *coreService) EditSimpleMessage(ctx context.Context, roomID string, messageID uint, options EditSimpleMessageOptions) error { 805 | return cs.EditMultipartMessage(ctx, roomID, messageID, EditMultipartMessageOptions{ 806 | SenderID: options.SenderID, 807 | Parts: []NewPart{NewInlinePart{Type: "text/plain", Content: options.Text}}, 808 | }) 809 | } 810 | 811 | // EditMultipartMessage edits an existing message in a room, replacing it with a multipart message. 812 | func (cs *coreService) EditMultipartMessage(ctx context.Context, roomID string, messageID uint, options EditMultipartMessageOptions) error { 813 | if len(options.Parts) == 0 { 814 | return errors.New("You must provide at least one message part") 815 | } 816 | 817 | if options.SenderID == "" { 818 | return errors.New("You must provide the ID of the user editing the message") 819 | } 820 | 821 | if roomID == "" { 822 | return errors.New("You must provide the ID of the room in which the message to edit belongs") 823 | } 824 | 825 | requestParts := make([]interface{}, len(options.Parts)) 826 | g, gCtx := errgroup.WithContext(ctx) 827 | 828 | for i, part := range options.Parts { 829 | switch p := part.(type) { 830 | case NewAttachmentPart: 831 | i := i 832 | g.Go(func() error { 833 | uploadedPart, err := cs.uploadAttachment(gCtx, options.SenderID, roomID, p) 834 | requestParts[i] = uploadedPart 835 | return err 836 | }) 837 | default: 838 | requestParts[i] = part 839 | } 840 | } 841 | 842 | if err := g.Wait(); err != nil { 843 | return fmt.Errorf("Failed to upload attachment: %v", err) 844 | } 845 | 846 | requestBody, err := common.CreateRequestBody( 847 | map[string]interface{}{"parts": requestParts}, 848 | ) 849 | if err != nil { 850 | return err 851 | } 852 | 853 | response, err := common.RequestWithUserToken( 854 | cs.underlyingInstance, 855 | ctx, 856 | options.SenderID, 857 | client.RequestOptions{ 858 | Method: http.MethodPut, 859 | Path: fmt.Sprintf("/rooms/%s/messages/%d", url.PathEscape(roomID), messageID), 860 | Body: requestBody, 861 | }, 862 | ) 863 | if response != nil { 864 | defer response.Body.Close() 865 | } 866 | if err != nil { 867 | return err 868 | } 869 | 870 | return nil 871 | } 872 | 873 | // GetRoomMessages fetches messages sent to a room based on the passed in options. 874 | func (cs *coreService) GetRoomMessages( 875 | ctx context.Context, 876 | roomID string, 877 | options GetRoomMessagesOptions, 878 | ) ([]Message, error) { 879 | messages := []Message{} 880 | err := cs.fetchMessages(ctx, roomID, options, &messages) 881 | return messages, err 882 | } 883 | 884 | // FetchMultipartMessage fetches a single message sent to a room based on the passed in options. 885 | func (cs *coreService) FetchMultipartMessage( 886 | ctx context.Context, 887 | options FetchMultipartMessageOptions, 888 | ) (MultipartMessage, error) { 889 | response, err := common.RequestWithSuToken( 890 | cs.underlyingInstance, 891 | ctx, 892 | client.RequestOptions{ 893 | Method: http.MethodGet, 894 | Path: fmt.Sprintf("/rooms/%s/messages/%d", options.RoomID, options.MessageID), 895 | }, 896 | ) 897 | if err != nil { 898 | return MultipartMessage{}, err 899 | } 900 | defer response.Body.Close() 901 | 902 | var message MultipartMessage 903 | err = common.DecodeResponseBody(response.Body, &message) 904 | if err != nil { 905 | return MultipartMessage{}, err 906 | } 907 | 908 | return message, nil 909 | } 910 | 911 | // FetchMultipartMessages fetches messages sent to a room based on the passed in options. 912 | func (cs *coreService) FetchMultipartMessages( 913 | ctx context.Context, 914 | roomID string, 915 | options FetchMultipartMessagesOptions, 916 | ) ([]MultipartMessage, error) { 917 | messages := []MultipartMessage{} 918 | err := cs.fetchMessages(ctx, roomID, options, &messages) 919 | return messages, err 920 | } 921 | 922 | func (cs *coreService) fetchMessages( 923 | ctx context.Context, 924 | roomID string, 925 | options fetchMessagesOptions, 926 | target interface{}, // poor man's generics 927 | ) error { 928 | queryParams := url.Values{} 929 | if options.Direction != nil { 930 | queryParams.Add("direction", *options.Direction) 931 | } 932 | 933 | if options.InitialID != nil { 934 | queryParams.Add("initial_id", strconv.Itoa(int(*options.InitialID))) 935 | } 936 | 937 | if options.Limit != nil { 938 | queryParams.Add("limit", strconv.Itoa(int(*options.Limit))) 939 | } 940 | 941 | response, err := common.RequestWithSuToken(cs.underlyingInstance, ctx, client.RequestOptions{ 942 | Method: http.MethodGet, 943 | Path: fmt.Sprintf("/rooms/%s/messages", url.PathEscape(roomID)), 944 | QueryParams: &queryParams, 945 | }) 946 | if response != nil { 947 | defer response.Body.Close() 948 | } 949 | if err != nil { 950 | return err 951 | } 952 | 953 | err = common.DecodeResponseBody(response.Body, target) 954 | if err != nil { 955 | return err 956 | } 957 | 958 | return nil 959 | } 960 | 961 | // Request allows performing requests to the core chatkit service and returns the raw http response. 962 | func (cs *coreService) Request( 963 | ctx context.Context, 964 | options client.RequestOptions, 965 | ) (*http.Response, error) { 966 | return cs.underlyingInstance.Request(ctx, options) 967 | } 968 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package chatkit 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "math/rand" 10 | "net/http" 11 | "os" 12 | "reflect" 13 | "sort" 14 | "strings" 15 | "testing" 16 | "time" 17 | 18 | "github.com/pusher/pusher-platform-go/auth" 19 | platformclient "github.com/pusher/pusher-platform-go/client" 20 | . "github.com/smartystreets/goconvey/convey" 21 | ) 22 | 23 | // Helpers 24 | 25 | func init() { 26 | rand.Seed(time.Now().UnixNano()) 27 | } 28 | 29 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 30 | 31 | // randomString generates a random string of length 10. 32 | func randomString() string { 33 | b := make([]rune, 10) 34 | for i := range b { 35 | b[i] = letters[rand.Intn(len(letters))] 36 | } 37 | 38 | return string(b) 39 | } 40 | 41 | // config represents test config. 42 | type config struct { 43 | instanceLocator string 44 | key string 45 | } 46 | 47 | // GetConfig retrieves instance specific config from the ENV. 48 | func getConfig() (*config, error) { 49 | instanceLocator := os.Getenv("CHATKIT_INSTANCE_LOCATOR") 50 | if instanceLocator == "" { 51 | return nil, errors.New("CHATKIT_INSTANCE_LOCATOR not set") 52 | } 53 | 54 | key := os.Getenv("CHATKIT_INSTANCE_KEY") 55 | if key == "" { 56 | return nil, errors.New("CHATKIT_INSTANCE_KEY not set") 57 | } 58 | 59 | return &config{instanceLocator, key}, nil 60 | } 61 | 62 | func createUser( 63 | client *Client, 64 | ) (string, error) { 65 | userID := randomString() 66 | err := client.CreateUser(context.Background(), CreateUserOptions{ 67 | ID: userID, 68 | Name: "integration-test-user", 69 | }) 70 | if err != nil { 71 | return "", err 72 | } 73 | 74 | return userID, nil 75 | } 76 | 77 | // createUserRoleWithGlobalPermissions generates a random user id, creates a user with that id 78 | // and assigns globally scoped permissions to them. 79 | // It returns the generated user id or an error. 80 | func createUserWithGlobalPermissions( 81 | client *Client, 82 | permissions []string, 83 | ) (string, error) { 84 | userID, err := createUser(client) 85 | if err != nil { 86 | return userID, err 87 | } 88 | 89 | _, err = assignGlobalPermissionsToUser(client, userID, permissions) 90 | if err != nil { 91 | return "", err 92 | } 93 | 94 | return userID, nil 95 | 96 | } 97 | 98 | // assignGlobalPermissionsToUser assigns permissions to a user 99 | // and returns the role name 100 | func assignGlobalPermissionsToUser( 101 | client *Client, 102 | userID string, 103 | permissions []string, 104 | ) (string, error) { 105 | roleName := randomString() 106 | 107 | err := client.CreateGlobalRole(context.Background(), CreateRoleOptions{ 108 | Name: roleName, 109 | Permissions: permissions, 110 | }) 111 | if err != nil { 112 | return "", err 113 | } 114 | 115 | err = client.AssignGlobalRoleToUser(context.Background(), userID, roleName) 116 | if err != nil { 117 | return "", err 118 | } 119 | 120 | return roleName, nil 121 | } 122 | 123 | // DeleteResources deletes all resources associated with an instance. 124 | // This allows tearing down resources after a test. 125 | func deleteAllResources(client *Client) error { 126 | tokenWithExpiry, err := client.GenerateSUToken(auth.Options{}) 127 | if err != nil { 128 | return err 129 | } 130 | 131 | _, err = client.CoreRequest(context.Background(), platformclient.RequestOptions{ 132 | Method: http.MethodDelete, 133 | Path: "/resources", 134 | Jwt: &tokenWithExpiry.Token, 135 | }) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | return nil 141 | } 142 | 143 | func interfaceToSliceOfInterfaces(s interface{}) []interface{} { 144 | t := reflect.ValueOf(s) 145 | if t.Kind() != reflect.Slice { 146 | panic("can't coerce non slice to []interface{}") 147 | } 148 | u := make([]interface{}, t.Len()) 149 | for i := 0; i < t.Len(); i++ { 150 | u[i] = t.Index(i).Interface() 151 | } 152 | return u 153 | } 154 | 155 | func containsResembling(s []interface{}, x interface{}) bool { 156 | for _, y := range s { 157 | if reflect.DeepEqual(x, y) { 158 | return true 159 | } 160 | } 161 | return false 162 | } 163 | 164 | // assumes neither actual nor expected contain repetitions 165 | func shouldResembleUpToReordering( 166 | actual interface{}, 167 | expected ...interface{}, 168 | ) string { 169 | s := interfaceToSliceOfInterfaces(actual) 170 | t := interfaceToSliceOfInterfaces(expected[0]) 171 | if len(s) != len(t) { 172 | return fmt.Sprintf("%s and %s are not the same length!", s, t) 173 | } 174 | for _, x := range s { 175 | if !containsResembling(t, x) { 176 | return fmt.Sprintf("%s contains %s, but %s does not", s, x, t) 177 | } 178 | } 179 | return "" 180 | } 181 | 182 | func TestCursors(t *testing.T) { 183 | config, err := getConfig() 184 | if err != nil { 185 | t.Fatalf("Failed to get test config: %s", err.Error()) 186 | } 187 | 188 | Convey("A chatkit user, having sent a message to a room", t, func() { 189 | client, err := NewClient(config.instanceLocator, config.key) 190 | So(err, ShouldBeNil) 191 | 192 | userID, err := createUserWithGlobalPermissions(client, []string{ 193 | "cursors:read:set", 194 | "cursors:read:get", 195 | "room:create", 196 | "message:create", 197 | }) 198 | So(err, ShouldBeNil) 199 | 200 | room, err := client.CreateRoom(context.Background(), CreateRoomOptions{ 201 | Name: randomString(), 202 | CreatorID: userID, 203 | }) 204 | So(err, ShouldBeNil) 205 | 206 | messageID, err := client.SendMessage(context.Background(), SendMessageOptions{ 207 | RoomID: room.ID, 208 | Text: "Hello!", 209 | SenderID: userID, 210 | }) 211 | So(err, ShouldBeNil) 212 | 213 | Convey("and has set the read cursor", func() { 214 | err = client.SetReadCursor(context.Background(), userID, room.ID, messageID) 215 | So(err, ShouldBeNil) 216 | 217 | Convey("it should be possible to get back cursors for the user", func() { 218 | userCursors, err := client.GetUserReadCursors(context.Background(), userID) 219 | So(err, ShouldBeNil) 220 | 221 | So(userCursors[0].CursorType, ShouldEqual, 0) 222 | So(userCursors[0].RoomID, ShouldEqual, room.ID) 223 | So(userCursors[0].UserID, ShouldEqual, userID) 224 | So(userCursors[0].Position, ShouldEqual, messageID) 225 | }) 226 | 227 | Convey("it should be possible to get back cursors for a room", func() { 228 | roomCursors, err := client.GetReadCursorsForRoom(context.Background(), room.ID) 229 | So(err, ShouldBeNil) 230 | 231 | So(roomCursors[0].CursorType, ShouldEqual, 0) 232 | So(roomCursors[0].RoomID, ShouldEqual, room.ID) 233 | So(roomCursors[0].UserID, ShouldEqual, userID) 234 | So(roomCursors[0].Position, ShouldEqual, messageID) 235 | }) 236 | }) 237 | 238 | Convey("On sending a new message and setting the read cursor", func() { 239 | latestMessageID, err := client.SendMessage(context.Background(), SendMessageOptions{ 240 | RoomID: room.ID, 241 | Text: "Hello!", 242 | SenderID: userID, 243 | }) 244 | So(err, ShouldBeNil) 245 | 246 | err = client.SetReadCursor(context.Background(), userID, room.ID, latestMessageID) 247 | So(err, ShouldBeNil) 248 | 249 | Convey("it should be possible to get the latest read cursor for a user and room", func() { 250 | cursor, err := client.GetReadCursor(context.Background(), userID, room.ID) 251 | So(err, ShouldBeNil) 252 | 253 | So(cursor.CursorType, ShouldEqual, 0) 254 | So(cursor.RoomID, ShouldEqual, room.ID) 255 | So(cursor.UserID, ShouldEqual, userID) 256 | So(cursor.Position, ShouldEqual, latestMessageID) 257 | }) 258 | 259 | Convey("it should be possible to make a raw request to cursors", func() { 260 | tokenWithExpiry, err := client.GenerateSUToken(AuthenticateOptions{}) 261 | So(err, ShouldBeNil) 262 | 263 | // Get back last set cursor 264 | response, err := client.CursorsRequest(context.Background(), RequestOptions{ 265 | Method: http.MethodGet, 266 | Path: fmt.Sprintf("/cursors/0/rooms/%s/users/%s", room.ID, userID), 267 | Jwt: &tokenWithExpiry.Token, 268 | }) 269 | So(err, ShouldBeNil) 270 | 271 | So(response.StatusCode, ShouldEqual, http.StatusOK) 272 | }) 273 | }) 274 | 275 | Reset(func() { 276 | err := deleteAllResources(client) 277 | So(err, ShouldBeNil) 278 | }) 279 | }) 280 | } 281 | 282 | func TestAuthorizer(t *testing.T) { 283 | config, err := getConfig() 284 | if err != nil { 285 | t.Fatalf("Failed to get test config: %s", err.Error()) 286 | } 287 | 288 | Convey("Given global and room roles have been created", t, func() { 289 | client, err := NewClient(config.instanceLocator, config.key) 290 | So(err, ShouldBeNil) 291 | 292 | globalRoleName := randomString() 293 | globalPermissions := []string{"message:create", "room:create"} 294 | err = client.CreateGlobalRole(context.Background(), CreateRoleOptions{ 295 | Name: globalRoleName, 296 | Permissions: globalPermissions, 297 | }) 298 | So(err, ShouldBeNil) 299 | 300 | roomRoleName := randomString() 301 | roomPermissions := []string{"message:create"} 302 | err = client.CreateRoomRole(context.Background(), CreateRoleOptions{ 303 | Name: roomRoleName, 304 | Permissions: roomPermissions, 305 | }) 306 | So(err, ShouldBeNil) 307 | 308 | Convey("it should be possible to fetch them", func() { 309 | roles, err := client.GetRoles(context.Background()) 310 | So(err, ShouldBeNil) 311 | 312 | So(roles, ShouldHaveLength, 2) 313 | So(roles, ShouldContain, Role{ 314 | Name: globalRoleName, 315 | Permissions: globalPermissions, 316 | Scope: "global", 317 | }) 318 | 319 | So(roles, ShouldContain, Role{ 320 | Name: roomRoleName, 321 | Permissions: roomPermissions, 322 | Scope: "room", 323 | }) 324 | }) 325 | 326 | Convey("it should be possible to delete a global scoped role", func() { 327 | err := client.DeleteGlobalRole(context.Background(), globalRoleName) 328 | So(err, ShouldBeNil) 329 | }) 330 | 331 | Convey("it should be possible to delete a room scoped role", func() { 332 | err := client.DeleteRoomRole(context.Background(), roomRoleName) 333 | So(err, ShouldBeNil) 334 | }) 335 | 336 | Convey("it should be possible to retrieve permissions for a global scoped role", func() { 337 | permissions, err := client.GetPermissionsForGlobalRole(context.Background(), globalRoleName) 338 | So(err, ShouldBeNil) 339 | So(permissions, shouldResembleUpToReordering, globalPermissions) 340 | }) 341 | 342 | Convey("it should be possible to retrieve permissions for a room scoped role", func() { 343 | permissions, err := client.GetPermissionsForRoomRole(context.Background(), roomRoleName) 344 | So(err, ShouldBeNil) 345 | So(permissions, shouldResembleUpToReordering, roomPermissions) 346 | }) 347 | 348 | Convey("it should be possible to update permissions for a globally scoped role", func() { 349 | err := client.UpdatePermissionsForGlobalRole( 350 | context.Background(), 351 | globalRoleName, 352 | UpdateRolePermissionsOptions{ 353 | PermissionsToAdd: []string{"cursors:read:set", "cursors:read:get"}, 354 | PermissionsToRemove: []string{"room:create"}, 355 | }, 356 | ) 357 | So(err, ShouldBeNil) 358 | 359 | permissions, err := client.GetPermissionsForGlobalRole(context.Background(), globalRoleName) 360 | So(err, ShouldBeNil) 361 | So( 362 | permissions, 363 | shouldResembleUpToReordering, 364 | []string{"message:create", "cursors:read:set", "cursors:read:get"}, 365 | ) 366 | }) 367 | 368 | Convey("it should be possible to update permissions for a room scoped role", func() { 369 | err := client.UpdatePermissionsForRoomRole( 370 | context.Background(), 371 | roomRoleName, 372 | UpdateRolePermissionsOptions{ 373 | PermissionsToAdd: []string{"cursors:read:set", "cursors:read:get"}, 374 | PermissionsToRemove: []string{"message:create"}, 375 | }, 376 | ) 377 | So(err, ShouldBeNil) 378 | 379 | permissions, err := client.GetPermissionsForRoomRole(context.Background(), roomRoleName) 380 | So(err, ShouldBeNil) 381 | So( 382 | permissions, 383 | shouldResembleUpToReordering, 384 | []string{"cursors:read:set", "cursors:read:get"}, 385 | ) 386 | }) 387 | 388 | Convey("on creating a user, a room and a global and room role", func() { 389 | userID, err := createUser(client) 390 | So(err, ShouldBeNil) 391 | 392 | globalRoleName := randomString() 393 | globalPermissions := []string{"message:create", "room:create"} 394 | err = client.CreateGlobalRole(context.Background(), CreateRoleOptions{ 395 | Name: globalRoleName, 396 | Permissions: globalPermissions, 397 | }) 398 | So(err, ShouldBeNil) 399 | 400 | room, err := client.CreateRoom(context.Background(), CreateRoomOptions{ 401 | Name: randomString(), 402 | CreatorID: userID, 403 | }) 404 | So(err, ShouldBeNil) 405 | 406 | roomRoleName := randomString() 407 | roomPermissions := []string{"message:create"} 408 | err = client.CreateRoomRole(context.Background(), CreateRoleOptions{ 409 | Name: roomRoleName, 410 | Permissions: roomPermissions, 411 | }) 412 | So(err, ShouldBeNil) 413 | 414 | Convey("it should be possible to assign a global scoped role to a user", func() { 415 | err := client.AssignGlobalRoleToUser(context.Background(), userID, globalRoleName) 416 | So(err, ShouldBeNil) 417 | 418 | Convey("and to get that role", func() { 419 | roles, err := client.GetUserRoles(context.Background(), userID) 420 | So(err, ShouldBeNil) 421 | So(roles, ShouldContain, Role{ 422 | Name: globalRoleName, 423 | Permissions: globalPermissions, 424 | Scope: "global", 425 | }) 426 | }) 427 | 428 | Convey("and remove it again", func() { 429 | err := client.RemoveGlobalRoleForUser(context.Background(), userID) 430 | So(err, ShouldBeNil) 431 | }) 432 | }) 433 | 434 | Convey("it should be possible to assign a room scoped role to a user", func() { 435 | err := client.AssignRoomRoleToUser(context.Background(), userID, room.ID, roomRoleName) 436 | So(err, ShouldBeNil) 437 | 438 | Convey("and to get that role", func() { 439 | roles, err := client.GetUserRoles(context.Background(), userID) 440 | So(err, ShouldBeNil) 441 | So(roles, ShouldContain, Role{ 442 | Name: roomRoleName, 443 | Permissions: roomPermissions, 444 | Scope: "room", 445 | }) 446 | }) 447 | 448 | Convey("and remove it again", func() { 449 | err := client.RemoveRoomRoleForUser(context.Background(), userID, room.ID) 450 | So(err, ShouldBeNil) 451 | }) 452 | }) 453 | 454 | }) 455 | 456 | Reset(func() { 457 | err := deleteAllResources(client) 458 | So(err, ShouldBeNil) 459 | }) 460 | }) 461 | } 462 | 463 | func TestUsers(t *testing.T) { 464 | ctx := context.Background() 465 | 466 | config, err := getConfig() 467 | if err != nil { 468 | t.Fatalf("Failed to get test config: %s", err.Error()) 469 | } 470 | 471 | client, err := NewClient(config.instanceLocator, config.key) 472 | if err != nil { 473 | t.Fatalf("Failed to create client: %s", err.Error()) 474 | } 475 | 476 | Convey("We can create a user", t, func() { 477 | userID := randomString() 478 | avatarURL := "https://" + randomString() 479 | err := client.CreateUser(ctx, CreateUserOptions{ 480 | ID: userID, 481 | Name: "integration-test-user", 482 | AvatarURL: &avatarURL, 483 | CustomData: json.RawMessage([]byte(`{"foo":"hello","bar":42}`)), 484 | }) 485 | So(err, ShouldBeNil) 486 | 487 | Convey("and we can can get them", func() { 488 | user, err := client.GetUser(ctx, userID) 489 | So(err, ShouldBeNil) 490 | So(user.ID, ShouldEqual, userID) 491 | So(user.Name, ShouldEqual, "integration-test-user") 492 | So(user.AvatarURL, ShouldEqual, avatarURL) 493 | So(len(user.CustomData), ShouldEqual, 2) 494 | So(user.CustomData["foo"], ShouldEqual, "hello") 495 | So(user.CustomData["bar"], ShouldEqual, 42) 496 | }) 497 | 498 | Convey("and we can update them", func() { 499 | newName := randomString() 500 | newAvatarURL := "https://" + randomString() 501 | 502 | err := client.UpdateUser(ctx, userID, UpdateUserOptions{ 503 | Name: &newName, 504 | AvatarUrl: &newAvatarURL, 505 | CustomData: json.RawMessage([]byte(`{"foo":"goodbye"}`)), 506 | }) 507 | So(err, ShouldBeNil) 508 | 509 | Convey("and get them again", func() { 510 | user, err := client.GetUser(ctx, userID) 511 | So(err, ShouldBeNil) 512 | So(user.ID, ShouldEqual, userID) 513 | So(user.Name, ShouldEqual, newName) 514 | So(user.AvatarURL, ShouldEqual, newAvatarURL) 515 | So(len(user.CustomData), ShouldEqual, 1) 516 | So(user.CustomData["foo"], ShouldEqual, "goodbye") 517 | }) 518 | }) 519 | 520 | Convey("and we can delete them", func() { 521 | err := client.DeleteUser(ctx, userID) 522 | So(err, ShouldBeNil) 523 | 524 | Convey("and can't get them any more", func() { 525 | _, err := client.GetUser(ctx, userID) 526 | So(err.(*ErrorResponse).Status, ShouldEqual, 404) 527 | So( 528 | err.(*ErrorResponse).Info.(map[string]interface{})["error"], 529 | ShouldEqual, 530 | "services/chatkit/not_found/user_not_found", 531 | ) 532 | }) 533 | }) 534 | }) 535 | 536 | Convey("We can create a batch of users", t, func() { 537 | ids := []string{randomString(), randomString(), randomString(), randomString()} 538 | sort.Strings(ids) 539 | 540 | avatarURLs := make([]string, 4) 541 | for i, id := range ids { 542 | avatarURLs[i] = "https://avatars/" + id 543 | } 544 | 545 | err := client.CreateUsers(ctx, []CreateUserOptions{ 546 | CreateUserOptions{ 547 | ID: ids[0], 548 | Name: "Alice", 549 | AvatarURL: &avatarURLs[0], 550 | CustomData: json.RawMessage(`{"a":"aaa"}`), 551 | }, 552 | CreateUserOptions{ 553 | ID: ids[1], 554 | Name: "Bob", 555 | AvatarURL: &avatarURLs[1], 556 | CustomData: json.RawMessage(`{"b":"bbb"}`), 557 | }, 558 | CreateUserOptions{ 559 | ID: ids[2], 560 | Name: "Carol", 561 | AvatarURL: &avatarURLs[2], 562 | CustomData: json.RawMessage(`{"c":"ccc"}`), 563 | }, 564 | CreateUserOptions{ 565 | ID: ids[3], 566 | Name: "Dave", 567 | AvatarURL: &avatarURLs[3], 568 | CustomData: json.RawMessage(`{"d":"ddd"}`), 569 | }, 570 | }) 571 | So(err, ShouldBeNil) 572 | 573 | Convey("and get them all by ID", func() { 574 | users, err := client.GetUsersByID(ctx, ids) 575 | So(err, ShouldBeNil) 576 | 577 | sort.Slice(users, func(i, j int) bool { 578 | return users[i].ID < users[j].ID 579 | }) 580 | 581 | So(len(users), ShouldEqual, 4) 582 | 583 | So(users[0].ID, ShouldEqual, ids[0]) 584 | So(users[0].Name, ShouldEqual, "Alice") 585 | So(users[0].AvatarURL, ShouldEqual, avatarURLs[0]) 586 | So(len(users[0].CustomData), ShouldEqual, 1) 587 | So(users[0].CustomData["a"], ShouldEqual, "aaa") 588 | 589 | So(users[1].ID, ShouldEqual, ids[1]) 590 | So(users[1].Name, ShouldEqual, "Bob") 591 | So(users[1].AvatarURL, ShouldEqual, avatarURLs[1]) 592 | So(len(users[1].CustomData), ShouldEqual, 1) 593 | So(users[1].CustomData["b"], ShouldEqual, "bbb") 594 | 595 | So(users[2].ID, ShouldEqual, ids[2]) 596 | So(users[2].Name, ShouldEqual, "Carol") 597 | So(users[2].AvatarURL, ShouldEqual, avatarURLs[2]) 598 | So(len(users[2].CustomData), ShouldEqual, 1) 599 | So(users[2].CustomData["c"], ShouldEqual, "ccc") 600 | 601 | So(users[3].ID, ShouldEqual, ids[3]) 602 | So(users[3].Name, ShouldEqual, "Dave") 603 | So(users[3].AvatarURL, ShouldEqual, avatarURLs[3]) 604 | So(len(users[3].CustomData), ShouldEqual, 1) 605 | So(users[3].CustomData["d"], ShouldEqual, "ddd") 606 | }) 607 | 608 | Convey("and get them all (paginated)", func() { 609 | users, err := client.GetUsers(ctx, nil) 610 | So(err, ShouldBeNil) 611 | 612 | sort.Slice(users, func(i, j int) bool { 613 | return users[i].ID < users[j].ID 614 | }) 615 | 616 | So(users[0].ID, ShouldEqual, ids[0]) 617 | So(users[0].Name, ShouldEqual, "Alice") 618 | So(users[0].AvatarURL, ShouldEqual, avatarURLs[0]) 619 | So(len(users[0].CustomData), ShouldEqual, 1) 620 | So(users[0].CustomData["a"], ShouldEqual, "aaa") 621 | 622 | So(users[1].ID, ShouldEqual, ids[1]) 623 | So(users[1].Name, ShouldEqual, "Bob") 624 | So(users[1].AvatarURL, ShouldEqual, avatarURLs[1]) 625 | So(len(users[1].CustomData), ShouldEqual, 1) 626 | So(users[1].CustomData["b"], ShouldEqual, "bbb") 627 | 628 | So(users[2].ID, ShouldEqual, ids[2]) 629 | So(users[2].Name, ShouldEqual, "Carol") 630 | So(users[2].AvatarURL, ShouldEqual, avatarURLs[2]) 631 | So(len(users[2].CustomData), ShouldEqual, 1) 632 | So(users[2].CustomData["c"], ShouldEqual, "ccc") 633 | 634 | So(users[3].ID, ShouldEqual, ids[3]) 635 | So(users[3].Name, ShouldEqual, "Dave") 636 | So(users[3].AvatarURL, ShouldEqual, avatarURLs[3]) 637 | So(len(users[3].CustomData), ShouldEqual, 1) 638 | So(users[3].CustomData["d"], ShouldEqual, "ddd") 639 | }) 640 | 641 | Reset(func() { 642 | err := deleteAllResources(client) 643 | So(err, ShouldBeNil) 644 | }) 645 | }) 646 | } 647 | 648 | func TestRooms(t *testing.T) { 649 | ctx := context.Background() 650 | 651 | config, err := getConfig() 652 | if err != nil { 653 | t.Fatalf("Failed to get test config: %s", err.Error()) 654 | } 655 | 656 | client, err := NewClient(config.instanceLocator, config.key) 657 | if err != nil { 658 | t.Fatalf("Failed to create client: %s", err.Error()) 659 | } 660 | 661 | Convey("Given some users", t, func() { 662 | aliceID, err := createUser(client) 663 | So(err, ShouldBeNil) 664 | 665 | bobID, err := createUser(client) 666 | So(err, ShouldBeNil) 667 | 668 | carolID, err := createUser(client) 669 | So(err, ShouldBeNil) 670 | 671 | Convey("we can create a room (without providing an ID)", func() { 672 | roomName := randomString() 673 | roomPNTitleOverride := randomString() 674 | 675 | room, err := client.CreateRoom(ctx, CreateRoomOptions{ 676 | Name: roomName, 677 | PushNotificationTitleOverride: &roomPNTitleOverride, 678 | Private: true, 679 | UserIDs: []string{aliceID, bobID}, 680 | CreatorID: aliceID, 681 | CustomData: map[string]interface{}{"foo": "bar"}, 682 | }) 683 | So(err, ShouldBeNil) 684 | So(room.Name, ShouldEqual, roomName) 685 | So(room.PushNotificationTitleOverride, ShouldResemble, &roomPNTitleOverride) 686 | So(room.Private, ShouldEqual, true) 687 | So(room.MemberUserIDs, shouldResembleUpToReordering, []string{aliceID, bobID}) 688 | 689 | Convey("and get it", func() { 690 | r, err := client.GetRoom(ctx, room.ID) 691 | So(err, ShouldBeNil) 692 | So(r.ID, ShouldEqual, room.ID) 693 | So(r.Name, ShouldEqual, roomName) 694 | So(r.PushNotificationTitleOverride, ShouldResemble, &roomPNTitleOverride) 695 | So(r.Private, ShouldEqual, true) 696 | So(r.MemberUserIDs, shouldResembleUpToReordering, []string{aliceID, bobID}) 697 | So(r.CustomData, ShouldResemble, map[string]interface{}{"foo": "bar"}) 698 | }) 699 | 700 | Convey("and update it to something else", func() { 701 | newRoomName := randomString() 702 | newRoomPNTitleOverride := randomString() 703 | 704 | err := client.UpdateRoom(ctx, room.ID, UpdateRoomOptions{ 705 | Name: &newRoomName, 706 | PushNotificationTitleOverride: &newRoomPNTitleOverride, 707 | CustomData: map[string]interface{}{"foo": "baz"}, 708 | }) 709 | So(err, ShouldBeNil) 710 | 711 | Convey("and get it again", func() { 712 | r, err := client.GetRoom(ctx, room.ID) 713 | So(err, ShouldBeNil) 714 | So(r.ID, ShouldEqual, room.ID) 715 | So(r.Name, ShouldEqual, newRoomName) 716 | So(r.PushNotificationTitleOverride, ShouldResemble, &newRoomPNTitleOverride) 717 | So(r.Private, ShouldEqual, true) 718 | So(r.MemberUserIDs, shouldResembleUpToReordering, []string{aliceID, bobID}) 719 | So(r.CustomData, ShouldResemble, map[string]interface{}{"foo": "baz"}) 720 | }) 721 | }) 722 | 723 | Convey("and explicitly remove push notifications override", func() { 724 | newRoomName := randomString() 725 | 726 | err := client.UpdateRoom(ctx, room.ID, UpdateRoomOptions{ 727 | Name: &newRoomName, 728 | PushNotificationTitleOverride: ExplicitlyResetPushNotificationTitleOverride, 729 | CustomData: map[string]interface{}{"foo": "baz"}, 730 | }) 731 | So(err, ShouldBeNil) 732 | 733 | Convey("and get it again", func() { 734 | r, err := client.GetRoom(ctx, room.ID) 735 | So(err, ShouldBeNil) 736 | So(r.ID, ShouldEqual, room.ID) 737 | So(r.Name, ShouldEqual, newRoomName) 738 | So(r.PushNotificationTitleOverride, ShouldBeNil) 739 | So(r.Private, ShouldEqual, true) 740 | So(r.MemberUserIDs, shouldResembleUpToReordering, []string{aliceID, bobID}) 741 | So(r.CustomData, ShouldResemble, map[string]interface{}{"foo": "baz"}) 742 | }) 743 | }) 744 | 745 | Convey("and delete it", func() { 746 | err := client.DeleteRoom(ctx, room.ID) 747 | So(err, ShouldBeNil) 748 | 749 | Convey("and can't get it any more (via GetRooms)", func() { 750 | rooms, err := client.GetRooms(ctx, GetRoomsOptions{}) 751 | So(err, ShouldBeNil) 752 | So(len(rooms), ShouldEqual, 0) 753 | }) 754 | }) 755 | 756 | Convey("and add users to it", func() { 757 | err := client.AddUsersToRoom(ctx, room.ID, []string{carolID}) 758 | So(err, ShouldBeNil) 759 | 760 | r, err := client.GetRoom(ctx, room.ID) 761 | So(err, ShouldBeNil) 762 | So(r.MemberUserIDs, shouldResembleUpToReordering, []string{aliceID, bobID, carolID}) 763 | }) 764 | 765 | Convey("and remove users from it", func() { 766 | err := client.RemoveUsersFromRoom(ctx, room.ID, []string{bobID}) 767 | So(err, ShouldBeNil) 768 | 769 | r, err := client.GetRoom(ctx, room.ID) 770 | So(err, ShouldBeNil) 771 | So(r.MemberUserIDs, shouldResembleUpToReordering, []string{aliceID}) 772 | }) 773 | }) 774 | 775 | Convey("we can create a room providing an ID", func() { 776 | roomID := randomString() 777 | roomName := randomString() 778 | 779 | room, err := client.CreateRoom(ctx, CreateRoomOptions{ 780 | ID: &roomID, 781 | Name: roomName, 782 | Private: true, 783 | UserIDs: []string{aliceID, bobID}, 784 | CreatorID: aliceID, 785 | CustomData: map[string]interface{}{"foo": "bar"}, 786 | }) 787 | So(err, ShouldBeNil) 788 | So(room.ID, ShouldEqual, roomID) 789 | So(room.Name, ShouldEqual, roomName) 790 | So(room.Private, ShouldEqual, true) 791 | So(room.MemberUserIDs, shouldResembleUpToReordering, []string{aliceID, bobID}) 792 | 793 | Convey("and get it", func() { 794 | r, err := client.GetRoom(ctx, room.ID) 795 | So(err, ShouldBeNil) 796 | So(r.ID, ShouldEqual, room.ID) 797 | So(r.Name, ShouldEqual, roomName) 798 | So(r.Private, ShouldEqual, true) 799 | So(r.MemberUserIDs, shouldResembleUpToReordering, []string{aliceID, bobID}) 800 | So(r.CustomData, ShouldResemble, map[string]interface{}{"foo": "bar"}) 801 | }) 802 | }) 803 | 804 | Convey("we can create a couple of rooms", func() { 805 | room1, err := client.CreateRoom(ctx, CreateRoomOptions{ 806 | Name: randomString(), 807 | UserIDs: []string{aliceID, bobID}, 808 | CreatorID: aliceID, 809 | }) 810 | So(err, ShouldBeNil) 811 | 812 | room2, err := client.CreateRoom(ctx, CreateRoomOptions{ 813 | Name: randomString(), 814 | UserIDs: []string{aliceID}, 815 | CreatorID: aliceID, 816 | }) 817 | So(err, ShouldBeNil) 818 | 819 | Convey("and get them", func() { 820 | rooms, err := client.GetRooms(ctx, GetRoomsOptions{}) 821 | So(err, ShouldBeNil) 822 | So(len(rooms), ShouldEqual, 2) 823 | So( 824 | []string{rooms[0].ID, rooms[1].ID}, 825 | shouldResembleUpToReordering, 826 | []string{room1.ID, room2.ID}, 827 | ) 828 | }) 829 | 830 | Convey("and get a user's rooms", func() { 831 | rooms, err := client.GetUserRooms(ctx, bobID) 832 | So(err, ShouldBeNil) 833 | So(len(rooms), ShouldEqual, 1) 834 | So(rooms[0].ID, ShouldEqual, room1.ID) 835 | }) 836 | 837 | Convey("and get a user's joinable rooms", func() { 838 | rooms, err := client.GetUserJoinableRooms(ctx, bobID) 839 | So(err, ShouldBeNil) 840 | So(len(rooms), ShouldEqual, 1) 841 | So(rooms[0].ID, ShouldEqual, room2.ID) 842 | }) 843 | }) 844 | 845 | Reset(func() { 846 | err := deleteAllResources(client) 847 | So(err, ShouldBeNil) 848 | }) 849 | }) 850 | } 851 | 852 | func TestMessages(t *testing.T) { 853 | ctx := context.Background() 854 | 855 | config, err := getConfig() 856 | if err != nil { 857 | t.Fatalf("Failed to get test config: %s", err.Error()) 858 | } 859 | 860 | client, err := NewClient(config.instanceLocator, config.key) 861 | if err != nil { 862 | t.Fatalf("Failed to create client: %s", err.Error()) 863 | } 864 | 865 | Convey("Given a user and a room", t, func() { 866 | userID, err := createUser(client) 867 | So(err, ShouldBeNil) 868 | 869 | room, err := client.CreateRoom(ctx, CreateRoomOptions{ 870 | Name: randomString(), 871 | CreatorID: userID, 872 | }) 873 | So(err, ShouldBeNil) 874 | 875 | Convey("we can publish messages from that user", func() { 876 | messageID1, err := client.SendMessage(ctx, SendMessageOptions{ 877 | RoomID: room.ID, 878 | Text: "one", 879 | SenderID: userID, 880 | }) 881 | So(err, ShouldBeNil) 882 | 883 | messageID2, err := client.SendMessage(ctx, SendMessageOptions{ 884 | RoomID: room.ID, 885 | Text: "two", 886 | SenderID: userID, 887 | }) 888 | So(err, ShouldBeNil) 889 | 890 | messageID3, err := client.SendMultipartMessage(ctx, SendMultipartMessageOptions{ 891 | RoomID: room.ID, 892 | SenderID: userID, 893 | Parts: []NewPart{ 894 | NewInlinePart{Type: "text/plain", Content: "three"}, 895 | }}) 896 | So(err, ShouldBeNil) 897 | 898 | messageID4, err := client.SendSimpleMessage(ctx, SendSimpleMessageOptions{ 899 | RoomID: room.ID, 900 | Text: "four", 901 | SenderID: userID, 902 | }) 903 | So(err, ShouldBeNil) 904 | 905 | Convey("and fetch many of them", func() { 906 | limit := uint(2) 907 | messagesPage1, err := client.GetRoomMessages(ctx, room.ID, GetRoomMessagesOptions{ 908 | Limit: &limit, 909 | }) 910 | So(err, ShouldBeNil) 911 | So(len(messagesPage1), ShouldEqual, 2) 912 | So(messagesPage1[0].ID, ShouldEqual, messageID4) 913 | So(messagesPage1[0].Text, ShouldEqual, "four") 914 | So(messagesPage1[1].ID, ShouldEqual, messageID3) 915 | So(messagesPage1[1].Text, ShouldEqual, "three") 916 | 917 | messagesPage2, err := client.GetRoomMessages(ctx, room.ID, GetRoomMessagesOptions{ 918 | InitialID: &messageID3, 919 | }) 920 | So(err, ShouldBeNil) 921 | So(len(messagesPage2), ShouldEqual, 2) 922 | So(messagesPage2[0].ID, ShouldEqual, messageID2) 923 | So(messagesPage2[0].Text, ShouldEqual, "two") 924 | So(messagesPage2[1].ID, ShouldEqual, messageID1) 925 | So(messagesPage2[1].Text, ShouldEqual, "one") 926 | }) 927 | 928 | Convey("and fetch one of them", func() { 929 | message, err := client.FetchMultipartMessage(ctx, FetchMultipartMessageOptions{ 930 | MessageID: messageID3, 931 | RoomID: room.ID, 932 | }) 933 | So(err, ShouldBeNil) 934 | So(message.ID, ShouldEqual, messageID3) 935 | So(message.Parts[0].Type, ShouldEqual, "text/plain") 936 | So(*message.Parts[0].Content, ShouldEqual, "three") 937 | }) 938 | 939 | Convey("but attempting to fetch a non-existent message returns a 404 error", func() { 940 | message, err := client.FetchMultipartMessage(ctx, FetchMultipartMessageOptions{ 941 | MessageID: 888, 942 | RoomID: room.ID, 943 | }) 944 | So(err, ShouldNotBeNil) 945 | So(err.(*ErrorResponse).Status, ShouldEqual, 404) 946 | So(message, ShouldResemble, MultipartMessage{}) 947 | }) 948 | 949 | Convey("and delete one of them", func() { 950 | err := client.DeleteMessage(ctx, DeleteMessageOptions{ 951 | RoomID: room.ID, 952 | MessageID: messageID3, 953 | }) 954 | So(err, ShouldBeNil) 955 | 956 | limit := uint(4) 957 | messages, err := client.GetRoomMessages(ctx, room.ID, GetRoomMessagesOptions{ 958 | Limit: &limit, 959 | }) 960 | So(err, ShouldBeNil) 961 | So(len(messages), ShouldEqual, 3) 962 | So(messages[0].ID, ShouldEqual, messageID4) 963 | So(messages[0].Text, ShouldEqual, "four") 964 | So(messages[1].ID, ShouldEqual, messageID2) 965 | So(messages[1].Text, ShouldEqual, "two") 966 | So(messages[2].ID, ShouldEqual, messageID1) 967 | So(messages[2].Text, ShouldEqual, "one") 968 | 969 | Convey("and attempting to retrieve the deleted message returns a 404 error", func() { 970 | message, err := client.FetchMultipartMessage(ctx, FetchMultipartMessageOptions{ 971 | MessageID: messageID3, 972 | RoomID: room.ID, 973 | }) 974 | So(err, ShouldNotBeNil) 975 | So(err.(*ErrorResponse).Status, ShouldEqual, 404) 976 | So(message, ShouldResemble, MultipartMessage{}) 977 | }) 978 | }) 979 | }) 980 | 981 | Convey("we can publish a multipart messages", func() { 982 | fileName := "cat.jpg" 983 | file, err := os.Open(fileName) 984 | So(err, ShouldBeNil) 985 | defer file.Close() 986 | 987 | messageID, err := client.SendMultipartMessage(ctx, SendMultipartMessageOptions{ 988 | RoomID: room.ID, 989 | SenderID: userID, 990 | Parts: []NewPart{ 991 | NewInlinePart{Type: "text/plain", Content: "see attached"}, 992 | NewURLPart{Type: "audio/ogg", URL: "https://example.com/audio.ogg"}, 993 | NewURLPart{Type: "audio/ogg", URL: "https://example.com/audio2.ogg"}, 994 | NewAttachmentPart{ 995 | Type: "application/json", 996 | File: strings.NewReader(`{"hello":"world"}`), 997 | CustomData: "anything", 998 | }, 999 | NewAttachmentPart{ 1000 | Type: "image/png", 1001 | File: file, 1002 | Name: &fileName, 1003 | }, 1004 | }, 1005 | }) 1006 | So(err, ShouldBeNil) 1007 | 1008 | Convey("and fetch it (v2)", func() { 1009 | limit := uint(1) 1010 | messages, err := client.GetRoomMessages(ctx, room.ID, GetRoomMessagesOptions{ 1011 | Limit: &limit, 1012 | }) 1013 | So(err, ShouldBeNil) 1014 | So(len(messages), ShouldEqual, 1) 1015 | So(messages[0].ID, ShouldEqual, messageID) 1016 | So( 1017 | messages[0].Text, 1018 | ShouldEqual, 1019 | "You have received a message which can't be represented in this version of the app. You will need to upgrade to read it.", 1020 | ) 1021 | }) 1022 | 1023 | Convey("and fetch it (v6)", func() { 1024 | limit := uint(1) 1025 | messages, err := client.FetchMultipartMessages( 1026 | ctx, 1027 | room.ID, 1028 | FetchMultipartMessagesOptions{Limit: &limit}, 1029 | ) 1030 | So(err, ShouldBeNil) 1031 | So(len(messages), ShouldEqual, 1) 1032 | So(messages[0].ID, ShouldEqual, messageID) 1033 | So(len(messages[0].Parts), ShouldEqual, 5) 1034 | 1035 | So(messages[0].Parts[0].Type, ShouldEqual, "text/plain") 1036 | So(messages[0].Parts[0].Content, ShouldNotBeNil) 1037 | So(*messages[0].Parts[0].Content, ShouldEqual, "see attached") 1038 | So(messages[0].Parts[0].URL, ShouldBeNil) 1039 | So(messages[0].Parts[0].Attachment, ShouldBeNil) 1040 | 1041 | So(messages[0].Parts[1].Type, ShouldEqual, "audio/ogg") 1042 | So(messages[0].Parts[1].Content, ShouldBeNil) 1043 | So(messages[0].Parts[1].URL, ShouldNotBeNil) 1044 | So(*messages[0].Parts[1].URL, ShouldEqual, "https://example.com/audio.ogg") 1045 | So(messages[0].Parts[1].Attachment, ShouldBeNil) 1046 | 1047 | So(messages[0].Parts[2].Type, ShouldEqual, "audio/ogg") 1048 | So(messages[0].Parts[2].Content, ShouldBeNil) 1049 | So(messages[0].Parts[2].URL, ShouldNotBeNil) 1050 | So(*messages[0].Parts[2].URL, ShouldEqual, "https://example.com/audio2.ogg") 1051 | So(messages[0].Parts[2].Attachment, ShouldBeNil) 1052 | 1053 | So(messages[0].Parts[3].Type, ShouldEqual, "application/json") 1054 | So(messages[0].Parts[3].Content, ShouldBeNil) 1055 | So(messages[0].Parts[3].URL, ShouldBeNil) 1056 | So(messages[0].Parts[3].Attachment, ShouldNotBeNil) 1057 | So(messages[0].Parts[3].Attachment.RefreshURL, ShouldNotEqual, "") 1058 | So(messages[0].Parts[3].Attachment.Expiration, ShouldNotEqual, time.Time{}) 1059 | So(messages[0].Parts[3].Attachment.Name, ShouldNotEqual, "") 1060 | So(messages[0].Parts[3].Attachment.Size, ShouldEqual, 17) 1061 | So(messages[0].Parts[3].Attachment.CustomData, ShouldEqual, "anything") 1062 | res, err := http.Get(messages[0].Parts[3].Attachment.DownloadURL) 1063 | So(err, ShouldBeNil) 1064 | defer res.Body.Close() 1065 | body, err := ioutil.ReadAll(res.Body) 1066 | So(err, ShouldBeNil) 1067 | So(string(body), ShouldEqual, `{"hello":"world"}`) 1068 | 1069 | So(messages[0].Parts[4].Type, ShouldEqual, "image/png") 1070 | So(messages[0].Parts[4].Content, ShouldBeNil) 1071 | So(messages[0].Parts[4].URL, ShouldBeNil) 1072 | So(messages[0].Parts[4].Attachment, ShouldNotBeNil) 1073 | So(messages[0].Parts[4].Attachment.RefreshURL, ShouldNotEqual, "") 1074 | So(messages[0].Parts[4].Attachment.Expiration, ShouldNotEqual, time.Time{}) 1075 | So(messages[0].Parts[4].Attachment.Name, ShouldEqual, fileName) 1076 | So(messages[0].Parts[4].Attachment.Size, ShouldEqual, 44043) 1077 | So(messages[0].Parts[4].Attachment.CustomData, ShouldBeNil) 1078 | So(messages[0].Parts[4].Attachment.DownloadURL, ShouldNotEqual, "") 1079 | }) 1080 | }) 1081 | 1082 | Reset(func() { 1083 | err := deleteAllResources(client) 1084 | So(err, ShouldBeNil) 1085 | }) 1086 | }) 1087 | } 1088 | 1089 | func TestEditMessages(t *testing.T) { 1090 | ctx := context.Background() 1091 | 1092 | config, err := getConfig() 1093 | if err != nil { 1094 | t.Fatalf("Failed to get test config: %s", err.Error()) 1095 | } 1096 | 1097 | client, err := NewClient(config.instanceLocator, config.key) 1098 | if err != nil { 1099 | t.Fatalf("Failed to create client: %s", err.Error()) 1100 | } 1101 | 1102 | Convey("Given a user and a room with messages", t, func() { 1103 | userID, err := createUser(client) 1104 | So(err, ShouldBeNil) 1105 | 1106 | room, err := client.CreateRoom(ctx, CreateRoomOptions{ 1107 | Name: randomString(), 1108 | CreatorID: userID, 1109 | }) 1110 | So(err, ShouldBeNil) 1111 | 1112 | messageID1, err := client.SendMessage(ctx, SendMessageOptions{ 1113 | RoomID: room.ID, 1114 | Text: "one", 1115 | SenderID: userID, 1116 | }) 1117 | So(err, ShouldBeNil) 1118 | 1119 | messageID2, err := client.SendSimpleMessage(ctx, SendSimpleMessageOptions{ 1120 | RoomID: room.ID, 1121 | Text: "two", 1122 | SenderID: userID, 1123 | }) 1124 | So(err, ShouldBeNil) 1125 | 1126 | messageID3, err := client.SendMultipartMessage(ctx, SendMultipartMessageOptions{ 1127 | RoomID: room.ID, 1128 | SenderID: userID, 1129 | Parts: []NewPart{ 1130 | NewInlinePart{Type: "text/plain", Content: "three"}, 1131 | }}) 1132 | So(err, ShouldBeNil) 1133 | 1134 | Convey("Messages can be edited", func() { 1135 | Convey("Messages can be edited using the v2 api", func() { 1136 | err := client.EditMessage(ctx, room.ID, messageID1, EditMessageOptions{ 1137 | Text: "one-edited", 1138 | SenderID: userID, 1139 | }) 1140 | So(err, ShouldBeNil) 1141 | }) 1142 | Convey("Messages can be edited using simple multipart messages", func() { 1143 | err := client.EditSimpleMessage(ctx, room.ID, messageID2, EditSimpleMessageOptions{ 1144 | Text: "two-edited", 1145 | SenderID: userID, 1146 | }) 1147 | So(err, ShouldBeNil) 1148 | }) 1149 | Convey("Messages can be edited using multipart messages", func() { 1150 | err := client.EditMultipartMessage(ctx, room.ID, messageID3, EditMultipartMessageOptions{ 1151 | SenderID: userID, 1152 | Parts: []NewPart{ 1153 | NewInlinePart{Type: "text/plain", Content: "three-edited"}, 1154 | }}) 1155 | So(err, ShouldBeNil) 1156 | }) 1157 | 1158 | }) 1159 | 1160 | Reset(func() { 1161 | err := deleteAllResources(client) 1162 | So(err, ShouldBeNil) 1163 | }) 1164 | }) 1165 | 1166 | } 1167 | --------------------------------------------------------------------------------