├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ ├── initiate_release.yml │ ├── lint.yml │ ├── release.yml │ ├── reviewdog.yml │ └── scheduled_test.yml ├── .gitignore ├── .golangci.yml ├── .versionrc.js ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── SECURITY.md ├── app.go ├── app_test.go ├── assets └── logo.svg ├── async_tasks.go ├── async_tasks_test.go ├── ban.go ├── ban_test.go ├── blocklist.go ├── blocklist_test.go ├── channel.go ├── channel_config.go ├── channel_config_test.go ├── channel_test.go ├── channel_type.go ├── channel_type_test.go ├── client.go ├── client_test.go ├── command.go ├── command_test.go ├── common.go ├── device.go ├── device_test.go ├── draft_test.go ├── event.go ├── event_test.go ├── go.mod ├── go.sum ├── http.go ├── http_test.go ├── import.go ├── import_test.go ├── json.go ├── json_test.go ├── message.go ├── message_history.go ├── message_history_test.go ├── message_test.go ├── permission_client.go ├── permission_client_test.go ├── query.go ├── query_test.go ├── rate_limits.go ├── rate_limits_test.go ├── reaction.go ├── reaction_test.go ├── scripts └── get_changelog_diff.js ├── testdata ├── helloworld.jpg └── helloworld.txt ├── thread.go ├── thread_test.go ├── unread_counts.go ├── unread_counts_test.go ├── user.go ├── user_test.go ├── utils_test.go └── version.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @totalimmersion @JimmyPettersson85 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | test-build: 14 | name: 👷 Test & Build 15 | runs-on: ubuntu-latest 16 | strategy: 17 | max-parallel: 3 18 | fail-fast: false 19 | matrix: 20 | goVer: ['1.22', '1.23', '1.24'] 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Go ${{ matrix.goVer }} 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ matrix.goVer }} 28 | 29 | - name: Test via ${{ matrix.goVer }} 30 | env: 31 | STREAM_KEY: ${{ secrets.STREAM_CHAT_API_KEY }} 32 | STREAM_SECRET: ${{ secrets.STREAM_CHAT_API_SECRET }} 33 | run: | 34 | go test -coverprofile cover.out -v -race ./... 35 | go tool cover -func=cover.out 36 | -------------------------------------------------------------------------------- /.github/workflows/initiate_release.yml: -------------------------------------------------------------------------------- 1 | name: Create release PR 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "The new version number with 'v' prefix. Example: v1.40.1" 8 | required: true 9 | 10 | jobs: 11 | init_release: 12 | name: 🚀 Create release PR 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 # gives the changelog generator access to all previous commits 18 | 19 | - name: Update CHANGELOG.md, version.go and push release branch 20 | env: 21 | VERSION: ${{ github.event.inputs.version }} 22 | run: | 23 | npx --yes standard-version@9.3.2 --release-as "$VERSION" --skip.tag --skip.commit --tag-prefix=v 24 | git config --global user.name 'github-actions' 25 | git config --global user.email 'release@getstream.io' 26 | git checkout -q -b "release-$VERSION" 27 | git commit -am "chore(release): $VERSION" 28 | git push -q -u origin "release-$VERSION" 29 | 30 | - name: Get changelog diff 31 | uses: actions/github-script@v6 32 | with: 33 | script: | 34 | const get_change_log_diff = require('./scripts/get_changelog_diff.js') 35 | core.exportVariable('CHANGELOG', get_change_log_diff()) 36 | 37 | - name: Open pull request 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | run: | 41 | gh pr create \ 42 | -t "Release ${{ github.event.inputs.version }}" \ 43 | -b "# :rocket: ${{ github.event.inputs.version }} 44 | Make sure to use squash & merge when merging! 45 | Once this is merged, another job will kick off automatically and publish the package. 46 | # :memo: Changelog 47 | ${{ env.CHANGELOG }}" 48 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [pull_request] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.head_ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | lint: 11 | name: 👮 Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Setup Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: "1.22" 23 | 24 | - name: Tidy 25 | run: go mod tidy -v && git diff --no-patch --exit-code || { git status; echo 'Unchecked diff, did you forget go mod tidy again?' ; false ; }; 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | branches: 7 | - master 8 | 9 | jobs: 10 | Release: 11 | name: 🚀 Release 12 | if: github.event.pull_request.merged && startsWith(github.head_ref, 'release-') 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/github-script@v6 18 | with: 19 | script: | 20 | const get_change_log_diff = require('./scripts/get_changelog_diff.js') 21 | core.exportVariable('CHANGELOG', get_change_log_diff()) 22 | 23 | // Getting the release version from the PR source branch 24 | // Source branch looks like this: release-1.0.0 25 | const version = context.payload.pull_request.head.ref.split('-')[1] 26 | core.exportVariable('VERSION', version) 27 | 28 | - name: Create release on GitHub 29 | uses: ncipollo/release-action@v1 30 | with: 31 | body: ${{ env.CHANGELOG }} 32 | tag: ${{ env.VERSION }} 33 | token: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: 3 | pull_request: 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.head_ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | reviewdog: 11 | name: 🐶 Reviewdog 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: reviewdog/action-setup@v1 17 | with: 18 | reviewdog_version: latest 19 | 20 | - name: Setup Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: '1.22' 24 | 25 | - name: Install golangci-lint 26 | run: make install-golangci 27 | 28 | - name: Reviewdog 29 | env: 30 | REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | run: $(go env GOPATH)/bin/golangci-lint run --out-format line-number | reviewdog -f=golangci-lint -name=golangci-lint -reporter=github-pr-review 32 | -------------------------------------------------------------------------------- /.github/workflows/scheduled_test.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled tests 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # Monday at 9:00 UTC 7 | - cron: "0 9 * * 1" 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - uses: actions/setup-go@v3 16 | with: 17 | go-version: "1.22" 18 | 19 | - name: Run tests 20 | env: 21 | STREAM_KEY: ${{ secrets.STREAM_CHAT_API_KEY }} 22 | STREAM_SECRET: ${{ secrets.STREAM_CHAT_API_SECRET }} 23 | run: | 24 | # Retry 3 times because tests can be flaky 25 | for _ in 1 2 3; 26 | do 27 | go test -v -race ./... && break 28 | done 29 | 30 | - name: Notify Slack if failed 31 | uses: voxmedia/github-action-slack-notify-build@v1 32 | if: failure() 33 | with: 34 | channel_id: C02RPDF7T63 35 | color: danger 36 | status: FAILED 37 | env: 38 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_NOTIFICATIONS_BOT_TOKEN }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | # or the one produced by the the built-in coverage tool 13 | *.out 14 | 15 | .vscode/ 16 | .idea/ 17 | .env 18 | .envrc 19 | 20 | # vendored dependencies and binaries 21 | vendor/ -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # all available settings of specific linters 2 | linters-settings: 3 | gofmt: 4 | simplify: true 5 | gofumpt: 6 | simplify: true 7 | goimports: 8 | local-prefixes: github.com/GetStream/stream-chat-go 9 | errcheck: 10 | check-type-assertions: false 11 | check-blank: false 12 | gocritic: 13 | disabled-checks: 14 | - unnamedResult 15 | - whyNoLint 16 | enabled-tags: 17 | - diagnostic 18 | - experimental 19 | - opinionated 20 | - performance 21 | - style 22 | settings: 23 | hugeParam: 24 | sizeThreshold: 512 25 | rangeValCopy: 26 | sizeThreshold: 364 27 | skipTestFuncs: true 28 | govet: 29 | enable-all: true 30 | disable: 31 | - fieldalignment 32 | - shadow 33 | run: 34 | skip-dirs: 35 | - hack 36 | tests: true 37 | 38 | linters: 39 | enable-all: true 40 | disable: 41 | - depguard 42 | - tagalign 43 | - err113 44 | - exhaustruct 45 | - gochecknoglobals 46 | - godox 47 | - gomnd 48 | - lll 49 | - mnd 50 | - nlreturn 51 | - nonamedreturns 52 | - paralleltest 53 | - tagliatelle 54 | - varnamelen 55 | - wrapcheck 56 | - wsl 57 | issues: 58 | exclude-rules: 59 | - linters: 60 | - stylecheck 61 | text: 'ST1003:' # should not use underscores in package names 62 | - linters: 63 | - revive 64 | text: "don't use an underscore in package name" 65 | - linters: 66 | - gocritic 67 | text: 'commentFormatting' 68 | - path: _test\.go 69 | linters: 70 | - gosec 71 | - testpackage # makes you use a separate _test package 72 | - gochecknoinits 73 | - depguard 74 | - testableexamples 75 | - contextcheck 76 | - funlen 77 | -------------------------------------------------------------------------------- /.versionrc.js: -------------------------------------------------------------------------------- 1 | const versionFileUpdater = { 2 | MAJOR_REGEX: /versionMajor = (\d+)/, 3 | MINOR_REGEX: /versionMinor = (\d+)/, 4 | PATCH_REGEX: /versionPatch = (\d+)/, 5 | 6 | readVersion: function (contents) { 7 | const major = this.MAJOR_REGEX.exec(contents)[1]; 8 | const minor = this.MINOR_REGEX.exec(contents)[1]; 9 | const patch = this.PATCH_REGEX.exec(contents)[1]; 10 | 11 | return `${major}.${minor}.${patch}`; 12 | }, 13 | 14 | writeVersion: function (contents, version) { 15 | const splitted = version.split('.'); 16 | const [major, minor, patch] = [splitted[0], splitted[1], splitted[2]]; 17 | 18 | return contents 19 | .replace(this.MAJOR_REGEX, `versionMajor = ${major}`) 20 | .replace(this.MINOR_REGEX, `versionMinor = ${minor}`) 21 | .replace(this.PATCH_REGEX, `versionPatch = ${patch}`); 22 | } 23 | } 24 | 25 | const moduleVersionUpdater = { 26 | GO_MOD_REGEX: /stream-chat-go\/v(\d+)/g, 27 | 28 | readVersion: function (contents) { 29 | return this.GO_MOD_REGEX.exec(contents)[1]; 30 | }, 31 | 32 | writeVersion: function (contents, version) { 33 | const major = version.split('.')[0]; 34 | 35 | return contents.replace(this.GO_MOD_REGEX, `stream-chat-go/v${major}`); 36 | } 37 | } 38 | 39 | module.exports = { 40 | bumpFiles: [ 41 | { filename: './version.go', updater: versionFileUpdater }, 42 | { filename: './go.mod', updater: moduleVersionUpdater }, 43 | { filename: './README.md', updater: moduleVersionUpdater }, 44 | ], 45 | } 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # :recycle: Contributing 2 | 3 | Contributions to this project are very much welcome, please make sure that your code changes are tested and that they follow Go best-practices. 4 | 5 | ## Getting started 6 | 7 | ### Required environmental variables 8 | 9 | The tests require at least two environment variables: `STREAM_KEY` and `STREAM_SECRET`. There are multiple ways to provide that: 10 | - simply set it in your current shell (`export STREAM_KEY=xyz`) 11 | - you could use [direnv](https://direnv.net/) 12 | - if you debug the tests in VS Code, you can set up an env file there as well: `"go.testEnvFile": "${workspaceFolder}/.env"`. 13 | 14 | ### Code formatting & linter 15 | 16 | We enforce code formatting with [`gofumpt`](https://github.com/mvdan/gofumpt) (a stricter `gofmt`). If you use VS Code, it's recommended to set this setting there for auto-formatting: 17 | 18 | ```json 19 | { 20 | "editor.formatOnSave": true, 21 | "gopls": { 22 | "formatting.gofumpt": true 23 | }, 24 | "go.lintTool": "golangci-lint", 25 | "go.lintFlags": [ 26 | "--fast" 27 | ] 28 | } 29 | ``` 30 | 31 | Gofumpt will mostly take care of your linting issues as well. 32 | 33 | ## Commit message convention 34 | 35 | Since we're autogenerating our [CHANGELOG](./CHANGELOG.md), we need to follow a specific commit message convention. 36 | You can read about conventional commits [here](https://www.conventionalcommits.org/). Here's how a usual commit message looks like for a new feature: `feat: allow provided config object to extend other configs`. A bugfix: `fix: prevent racing of requests`. 37 | 38 | ## Release (for Stream developers) 39 | 40 | Releasing this package involves two GitHub Action steps: 41 | 42 | - Kick off a job called `initiate_release` ([link](https://github.com/GetStream/stream-chat-go/actions/workflows/initiate_release.yml)). 43 | 44 | The job creates a pull request with the changelog. Check if it looks good. 45 | 46 | - Merge the pull request. 47 | 48 | Once the PR is merged, it automatically kicks off another job which will create the tag and created a GitHub release. 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2020, Stream.io Inc, and individual contributors. 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted 6 | provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this list of 9 | conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of 12 | conditions and the following disclaimer in the documentation and/or other materials 13 | provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its contributors may 16 | be used to endorse or promote products derived from this software without specific prior 17 | written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 20 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY 21 | AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 22 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 26 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOLANGCI_VERSION = 1.58.2 2 | GOLANGCI = vendor/golangci-lint/$(GOLANGCI_VERSION)/golangci-lint 3 | 4 | install-golangci: 5 | test -s $(GOLANGCI) || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(dir $(GOLANGCI)) v$(GOLANGCI_VERSION) 6 | 7 | lint: install-golangci 8 | $(GOLANGCI) run 9 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Submit a pull request 2 | 3 | ## CLA 4 | 5 | - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). 6 | - [ ] The code changes follow best practices 7 | - [ ] Code changes are tested (add some information if not applicable) 8 | 9 | ## Description of the pull request 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Official Go SDK for [Stream Chat](https://getstream.io/chat/) 2 | 3 | [![build](https://github.com/GetStream/stream-chat-go/workflows/build/badge.svg)](https://github.com/GetStream/stream-chat-go/actions) 4 | [![godoc](https://pkg.go.dev/badge/GetStream/stream-chat-go)](https://pkg.go.dev/github.com/GetStream/stream-chat-go/v7?tab=doc) 5 | 6 |

7 | 8 |

9 |

10 | Official Go API client for Stream Chat, a service for building chat applications. 11 |
12 | Explore the docs » 13 |
14 |
15 | Report Bug 16 | · 17 | Request Feature 18 |

19 | 20 | ## 📝 About Stream 21 | 22 | You can sign up for a Stream account at our [Get Started](https://getstream.io/chat/get_started/) page. 23 | 24 | You can use this library to access chat API endpoints server-side. 25 | 26 | For the client-side integrations (web and mobile) have a look at the JavaScript, iOS and Android SDK libraries ([docs](https://getstream.io/chat/)). 27 | 28 | ## ⚙️ Installation 29 | 30 | ```shell 31 | go get github.com/GetStream/stream-chat-go/v7 32 | ``` 33 | 34 | ## ✨ Getting started 35 | 36 | ```go 37 | package main 38 | 39 | import ( 40 | "os" 41 | 42 | stream "github.com/GetStream/stream-chat-go/v7" 43 | ) 44 | 45 | var APIKey = os.Getenv("STREAM_KEY") 46 | var APISecret = os.Getenv("STREAM_SECRET") 47 | var userID = "" // your server user id 48 | 49 | func main() { 50 | // Initialize client 51 | client, err := stream.NewClient(APIKey, APISecret) 52 | 53 | // Or with a specific timeout 54 | client, err := stream.NewClient(APIKey, APISecret, WithTimeout(3 * time.Second)) 55 | 56 | // Or using only environmental variables: (required) STREAM_KEY, (required) STREAM_SECRET, 57 | // (optional) STREAM_CHAT_TIMEOUT 58 | client, err := stream.NewClientFromEnvVars() 59 | 60 | // handle error 61 | 62 | // Define a context 63 | ctx := context.Background() 64 | 65 | // use client methods 66 | 67 | // create channel with users 68 | users := []string{"id1", "id2", "id3"} 69 | userID := "id1" 70 | channel, err := client.CreateChannelWithMembers(ctx, "messaging", "channel-id", userID, users...) 71 | 72 | // use channel methods 73 | msg, err := channel.SendMessage(ctx, &stream.Message{Text: "hello"}, userID) 74 | } 75 | ``` 76 | 77 | ## ✍️ Contributing 78 | 79 | We welcome code changes that improve this library or fix a problem, please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. We are very happy to merge your code in the official repository. Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. See our [license file](./LICENSE) for more details. 80 | 81 | Head over to [CONTRIBUTING.md](./CONTRIBUTING.md) for some development tips. 82 | 83 | ## 🧑‍💻 We are hiring! 84 | 85 | We've recently closed a [$38 million Series B funding round](https://techcrunch.com/2021/03/04/stream-raises-38m-as-its-chat-and-activity-feed-apis-power-communications-for-1b-users/) and we keep actively growing. 86 | Our APIs are used by more than a billion end-users, and you'll have a chance to make a huge impact on the product within a team of the strongest engineers all over the world. 87 | 88 | Check out our current openings and apply via [Stream's website](https://getstream.io/team/#jobs). 89 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting a Vulnerability 2 | At Stream we are committed to the security of our Software. We appreciate your efforts in disclosing vulnerabilities responsibly and we will make every effort to acknowledge your contributions. 3 | 4 | Report security vulnerabilities at the following email address: 5 | ``` 6 | [security@getstream.io](mailto:security@getstream.io) 7 | ``` 8 | Alternatively it is also possible to open a new issue in the affected repository, tagging it with the `security` tag. 9 | 10 | A team member will acknowledge the vulnerability and will follow-up with more detailed information. A representative of the security team will be in touch if more information is needed. 11 | 12 | # Information to include in a report 13 | While we appreciate any information that you are willing to provide, please make sure to include the following: 14 | * Which repository is affected 15 | * Which branch, if relevant 16 | * Be as descriptive as possible, the team will replicate the vulnerability before working on a fix. 17 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type AppSettings struct { 10 | Name string `json:"name"` 11 | OrganizationName string `json:"organization"` 12 | Suspended bool `json:"suspended"` 13 | SuspendedExplanation string `json:"suspended_explanation"` 14 | ConfigNameMap map[string]*ChannelConfig `json:"channel_configs"` 15 | RevokeTokensIssuedBefore *time.Time `json:"revoke_tokens_issued_before"` 16 | 17 | DisableAuth *bool `json:"disable_auth_checks,omitempty"` 18 | DisablePermissions *bool `json:"disable_permissions_checks,omitempty"` 19 | 20 | PushNotifications PushNotificationFields `json:"push_notifications"` 21 | PushConfig *PushConfigRequest `json:"push_config,omitempty"` 22 | APNConfig *APNConfig `json:"apn_config,omitempty"` 23 | FirebaseConfig *FirebaseConfigRequest `json:"firebase_config,omitempty"` 24 | XiaomiConfig *XiaomiConfigRequest `json:"xiaomi_config,omitempty"` 25 | HuaweiConfig *HuaweiConfigRequest `json:"huawei_config,omitempty"` 26 | WebhookURL *string `json:"webhook_url,omitempty"` 27 | WebhookEvents []string `json:"webhook_events,omitempty"` 28 | SqsURL *string `json:"sqs_url,omitempty"` 29 | SqsKey *string `json:"sqs_key,omitempty"` 30 | SqsSecret *string `json:"sqs_secret,omitempty"` 31 | SnsTopicArn *string `json:"sns_topic_arn,omitempty"` 32 | SnsKey *string `json:"sns_key,omitempty"` 33 | SnsSecret *string `json:"sns_secret,omitempty"` 34 | BeforeMessageSendHookURL *string `json:"before_message_send_hook_url,omitempty"` 35 | CustomActionHandlerURL *string `json:"custom_action_handler_url,omitempty"` 36 | 37 | FileUploadConfig *FileUploadConfig `json:"file_upload_config,omitempty"` 38 | ImageUploadConfig *FileUploadConfig `json:"image_upload_config,omitempty"` 39 | ImageModerationLabels []string `json:"image_moderation_labels,omitempty"` 40 | ImageModerationEnabled *bool `json:"image_moderation_enabled,omitempty"` 41 | 42 | PermissionVersion *string `json:"permission_version,omitempty"` 43 | MigratePermissionsToV2 *bool `json:"migrate_permissions_to_v2,omitempty"` 44 | Policies map[string][]Policy `json:"policies"` 45 | Grants map[string][]string `json:"grants,omitempty"` 46 | 47 | MultiTenantEnabled *bool `json:"multi_tenant_enabled,omitempty"` 48 | AsyncURLEnrichEnabled *bool `json:"async_url_enrich_enabled,omitempty"` 49 | AutoTranslationEnabled *bool `json:"auto_translation_enabled,omitempty"` 50 | RemindersInterval int `json:"reminders_interval,omitempty"` 51 | UserSearchDisallowedRoles []string `json:"user_search_disallowed_roles,omitempty"` 52 | EnforceUniqueUsernames *string `json:"enforce_unique_usernames,omitempty"` 53 | ChannelHideMembersOnly *bool `json:"channel_hide_members_only,omitempty"` 54 | AsyncModerationConfig *AsyncModerationConfiguration `json:"async_moderation_config,omitempty"` 55 | } 56 | 57 | func (a *AppSettings) SetDisableAuth(b bool) *AppSettings { 58 | a.DisableAuth = &b 59 | return a 60 | } 61 | 62 | func (a *AppSettings) SetDisablePermissions(b bool) *AppSettings { 63 | a.DisablePermissions = &b 64 | return a 65 | } 66 | 67 | func (a *AppSettings) SetAPNConfig(c APNConfig) *AppSettings { 68 | a.APNConfig = &c 69 | return a 70 | } 71 | 72 | func (a *AppSettings) SetFirebaseConfig(c FirebaseConfigRequest) *AppSettings { 73 | a.FirebaseConfig = &c 74 | return a 75 | } 76 | 77 | func (a *AppSettings) SetWebhookURL(s string) *AppSettings { 78 | a.WebhookURL = &s 79 | return a 80 | } 81 | 82 | func (a *AppSettings) SetMultiTenant(b bool) *AppSettings { 83 | a.MultiTenantEnabled = &b 84 | return a 85 | } 86 | 87 | func (a *AppSettings) SetGrants(g map[string][]string) *AppSettings { 88 | a.Grants = g 89 | return a 90 | } 91 | 92 | func (a *AppSettings) SetAsyncModerationConfig(c AsyncModerationConfiguration) *AppSettings { 93 | a.AsyncModerationConfig = &c 94 | return a 95 | } 96 | 97 | func NewAppSettings() *AppSettings { 98 | return &AppSettings{} 99 | } 100 | 101 | type AsyncModerationCallback struct { 102 | Mode string `json:"mode"` 103 | ServerURL string `json:"server_url"` 104 | } 105 | 106 | type AsyncModerationConfiguration struct { 107 | Callback *AsyncModerationCallback `json:"callback,omitempty"` 108 | Timeout int `json:"timeout_ms,omitempty"` 109 | } 110 | 111 | type FileUploadConfig struct { 112 | AllowedFileExtensions []string `json:"allowed_file_extensions,omitempty"` 113 | BlockedFileExtensions []string `json:"blocked_file_extensions,omitempty"` 114 | AllowedMimeTypes []string `json:"allowed_mime_types,omitempty"` 115 | BlockedMimeTypes []string `json:"blocked_mime_types,omitempty"` 116 | } 117 | 118 | type APNConfig struct { 119 | Enabled bool `json:"enabled"` 120 | Development bool `json:"development"` 121 | AuthType string `json:"auth_type,omitempty"` 122 | AuthKey string `json:"auth_key,omitempty"` 123 | NotificationTemplate string `json:"notification_template"` 124 | Host string `json:"host,omitempty"` 125 | BundleID string `json:"bundle_id,omitempty"` 126 | TeamID string `json:"team_id,omitempty"` 127 | KeyID string `json:"key_id,omitempty"` 128 | } 129 | 130 | type PushNotificationFields struct { 131 | Version string `json:"version"` 132 | OfflineOnly bool `json:"offline_only"` 133 | APNConfig APNConfig `json:"apn"` 134 | FirebaseConfig FirebaseConfig `json:"firebase"` 135 | HuaweiConfig HuaweiConfig `json:"huawei"` 136 | XiaomiConfig XiaomiConfig `json:"xiaomi"` 137 | Providers []PushProvider `json:"providers,omitempty"` 138 | } 139 | 140 | type FirebaseConfigRequest struct { 141 | ServerKey string `json:"server_key"` 142 | NotificationTemplate string `json:"notification_template,omitempty"` 143 | DataTemplate string `json:"data_template,omitempty"` 144 | APNTemplate *string `json:"apn_template,omitempty"` 145 | CredentialsJSON string `json:"credentials_json,omitempty"` 146 | } 147 | 148 | type FirebaseConfig struct { 149 | Enabled bool `json:"enabled"` 150 | NotificationTemplate string `json:"notification_template"` 151 | DataTemplate string `json:"data_template"` 152 | } 153 | 154 | type XiaomiConfigRequest struct { 155 | PackageName string `json:"package_name"` 156 | Secret string `json:"secret"` 157 | } 158 | 159 | type XiaomiConfig struct { 160 | Enabled bool `json:"enabled"` 161 | } 162 | 163 | type HuaweiConfigRequest struct { 164 | ID string `json:"id"` 165 | Secret string `json:"secret"` 166 | } 167 | 168 | type HuaweiConfig struct { 169 | Enabled bool `json:"enabled"` 170 | } 171 | 172 | type PushConfigRequest struct { 173 | Version string `json:"version,omitempty"` 174 | OfflineOnly bool `json:"offline_only,omitempty"` 175 | } 176 | 177 | type Policy struct { 178 | Name string `json:"name"` 179 | Resources []string `json:"resources"` 180 | Roles []string `json:"roles"` 181 | Action int `json:"action"` // allow: 1, deny: 0 182 | Owner bool `json:"owner"` 183 | Priority int `json:"priority"` 184 | CreatedAt time.Time `json:"created_at"` 185 | UpdatedAt time.Time `json:"updated_at"` 186 | } 187 | 188 | type AppResponse struct { 189 | App *AppSettings `json:"app"` 190 | Response 191 | } 192 | 193 | // GetAppSettings returns app settings. 194 | func (c *Client) GetAppSettings(ctx context.Context) (*AppResponse, error) { 195 | var resp AppResponse 196 | 197 | err := c.makeRequest(ctx, http.MethodGet, "app", nil, nil, &resp) 198 | return &resp, err 199 | } 200 | 201 | // UpdateAppSettings makes request to update app settings 202 | // Example of usage: 203 | // 204 | // settings := NewAppSettings().SetDisableAuth(true) 205 | // err := client.UpdateAppSettings(settings) 206 | func (c *Client) UpdateAppSettings(ctx context.Context, settings *AppSettings) (*Response, error) { 207 | var resp Response 208 | err := c.makeRequest(ctx, http.MethodPatch, "app", nil, settings, &resp) 209 | return &resp, err 210 | } 211 | 212 | type CheckSQSRequest struct { 213 | SqsURL string `json:"sqs_url"` 214 | SqsKey string `json:"sqs_key"` 215 | SqsSecret string `json:"sqs_secret"` 216 | } 217 | 218 | type CheckSQSResponse struct { 219 | Status string `json:"status"` 220 | Error string `json:"error"` 221 | Data map[string]interface{} `json:"data"` 222 | Response 223 | } 224 | 225 | // CheckSqs checks whether the AWS credentials are valid for the SQS queue access. 226 | func (c *Client) CheckSqs(ctx context.Context, req *CheckSQSRequest) (*CheckSQSResponse, error) { 227 | var resp CheckSQSResponse 228 | err := c.makeRequest(ctx, http.MethodPost, "check_sqs", nil, req, &resp) 229 | return &resp, err 230 | } 231 | 232 | type CheckSNSRequest struct { 233 | SnsTopicARN string `json:"sns_topic_arn"` 234 | SnsKey string `json:"sns_key"` 235 | SnsSecret string `json:"sns_secret"` 236 | } 237 | 238 | type CheckSNSResponse struct { 239 | Status string `json:"status"` 240 | Error string `json:"error"` 241 | Data map[string]interface{} `json:"data"` 242 | Response 243 | } 244 | 245 | func (c *Client) CheckSns(ctx context.Context, req *CheckSNSRequest) (*CheckSNSResponse, error) { 246 | var resp CheckSNSResponse 247 | err := c.makeRequest(ctx, http.MethodPost, "check_sns", nil, req, &resp) 248 | return &resp, err 249 | } 250 | 251 | type CheckPushRequest struct { 252 | MessageID string `json:"message_id,omitempty"` 253 | ApnTemplate string `json:"apn_template,omitempty"` 254 | FirebaseTemplate string `json:"firebase_template,omitempty"` 255 | FirebaseDataTemplate string `json:"firebase_data_template,omitempty"` 256 | SkipDevices *bool `json:"skip_devices,omitempty"` 257 | PushProviderName string `json:"push_provider_name,omitempty"` 258 | PushProviderType string `json:"push_provider_type,omitempty"` 259 | UserID string `json:"user_id,omitempty"` 260 | User *User `json:"user,omitempty"` 261 | } 262 | 263 | type DeviceError struct { 264 | Provider string `json:"provider"` 265 | ProviderName string `json:"provider_name"` 266 | ErrorMessage string `json:"error_message"` 267 | } 268 | 269 | type CheckPushResponse struct { 270 | DeviceErrors map[string]DeviceError `json:"device_errors"` 271 | GeneralErrors []string `json:"general_errors"` 272 | SkipDevices *bool `json:"skip_devices"` 273 | RenderedApnTemplate string `json:"rendered_apn_template"` 274 | RenderedFirebaseTemplate string `json:"rendered_firebase_template"` 275 | RenderedMessage map[string]string `json:"rendered_message"` 276 | Response 277 | } 278 | 279 | // CheckPush initiates a push test. 280 | func (c *Client) CheckPush(ctx context.Context, req *CheckPushRequest) (*CheckPushResponse, error) { 281 | var resp CheckPushResponse 282 | err := c.makeRequest(ctx, http.MethodPost, "check_push", nil, req, &resp) 283 | return &resp, err 284 | } 285 | 286 | // RevokeTokens revokes all tokens for an application issued before given time. 287 | func (c *Client) RevokeTokens(ctx context.Context, before *time.Time) (*Response, error) { 288 | setting := make(map[string]interface{}) 289 | if before == nil { 290 | setting["revoke_tokens_issued_before"] = nil 291 | } else { 292 | setting["revoke_tokens_issued_before"] = before.Format(time.RFC3339) 293 | } 294 | 295 | var resp Response 296 | err := c.makeRequest(ctx, http.MethodPatch, "app", nil, setting, &resp) 297 | return &resp, err 298 | } 299 | 300 | type PushProvider struct { 301 | Type PushProviderType `json:"type"` 302 | Name string `json:"name"` 303 | Description string `json:"description,omitempty"` 304 | DisabledAt *time.Time `json:"disabled_at,omitempty"` 305 | DisabledReason string `json:"disabled_reason,omitempty"` 306 | 307 | APNAuthKey string `json:"apn_auth_key,omitempty"` 308 | APNKeyID string `json:"apn_key_id,omitempty"` 309 | APNTeamID string `json:"apn_team_id,omitempty"` 310 | APNTopic string `json:"apn_topic,omitempty"` 311 | 312 | FirebaseCredentials string `json:"firebase_credentials,omitempty"` 313 | FirebaseNotificationTemplate *string `json:"firebase_notification_template,omitempty"` 314 | FirebaseAPNTemplate *string `json:"firebase_apn_template,omitempty"` 315 | 316 | HuaweiAppID string `json:"huawei_app_id,omitempty"` 317 | HuaweiAppSecret string `json:"huawei_app_secret,omitempty"` 318 | 319 | XiaomiPackageName string `json:"xiaomi_package_name,omitempty"` 320 | XiaomiAppSecret string `json:"xiaomi_app_secret,omitempty"` 321 | } 322 | 323 | // UpsertPushProvider inserts or updates a push provider. 324 | func (c *Client) UpsertPushProvider(ctx context.Context, provider *PushProvider) (*Response, error) { 325 | body := map[string]PushProvider{"push_provider": *provider} 326 | var resp Response 327 | err := c.makeRequest(ctx, http.MethodPost, "push_providers", nil, body, &resp) 328 | return &resp, err 329 | } 330 | 331 | // DeletePushProvider deletes a push provider. 332 | func (c *Client) DeletePushProvider(ctx context.Context, providerType, name string) (*Response, error) { 333 | var resp Response 334 | err := c.makeRequest(ctx, http.MethodDelete, "push_providers/"+providerType+"/"+name, nil, nil, &resp) 335 | return &resp, err 336 | } 337 | 338 | type PushProviderListResponse struct { 339 | Response 340 | PushProviders []PushProvider `json:"push_providers"` 341 | } 342 | 343 | // ListPushProviders returns the list of push providers. 344 | func (c *Client) ListPushProviders(ctx context.Context) (*PushProviderListResponse, error) { 345 | var providers PushProviderListResponse 346 | err := c.makeRequest(ctx, http.MethodGet, "push_providers", nil, nil, &providers) 347 | return &providers, err 348 | } 349 | -------------------------------------------------------------------------------- /app_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestClient_GetApp(t *testing.T) { 12 | c := initClient(t) 13 | ctx := context.Background() 14 | _, err := c.GetAppSettings(ctx) 15 | require.NoError(t, err) 16 | } 17 | 18 | func TestClient_UpdateAppSettings(t *testing.T) { 19 | c := initClient(t) 20 | ctx := context.Background() 21 | 22 | settings := NewAppSettings(). 23 | SetDisableAuth(true). 24 | SetDisablePermissions(true) 25 | 26 | _, err := c.UpdateAppSettings(ctx, settings) 27 | require.NoError(t, err) 28 | } 29 | 30 | func TestClient_CheckAsyncModeConfig(t *testing.T) { 31 | c := initClient(t) 32 | ctx := context.Background() 33 | 34 | settings := NewAppSettings(). 35 | SetAsyncModerationConfig( 36 | AsyncModerationConfiguration{ 37 | Callback: &AsyncModerationCallback{ 38 | Mode: "CALLBACK_MODE_REST", 39 | ServerURL: "https://example.com/gosdk", 40 | }, 41 | Timeout: 10000, 42 | }, 43 | ) 44 | 45 | _, err := c.UpdateAppSettings(ctx, settings) 46 | require.NoError(t, err) 47 | } 48 | 49 | func TestClient_UpdateAppSettingsClearing(t *testing.T) { 50 | c := initClient(t) 51 | ctx := context.Background() 52 | 53 | sqsURL := "https://example.com" 54 | sqsKey := "some key" 55 | sqsSecret := "some secret" 56 | 57 | settings := NewAppSettings() 58 | settings.SqsURL = &sqsURL 59 | settings.SqsKey = &sqsKey 60 | settings.SqsSecret = &sqsSecret 61 | 62 | _, err := c.UpdateAppSettings(ctx, settings) 63 | require.NoError(t, err) 64 | 65 | sqsURL = "" 66 | settings.SqsURL = &sqsURL 67 | _, err = c.UpdateAppSettings(ctx, settings) 68 | require.NoError(t, err) 69 | 70 | s, err := c.GetAppSettings(ctx) 71 | require.NoError(t, err) 72 | require.Equal(t, *settings.SqsURL, *s.App.SqsURL) 73 | } 74 | 75 | func TestClient_CheckSqs(t *testing.T) { 76 | c := initClient(t) 77 | ctx := context.Background() 78 | 79 | req := &CheckSQSRequest{SqsURL: "https://foo.com/bar", SqsKey: "key", SqsSecret: "secret"} 80 | resp, err := c.CheckSqs(ctx, req) 81 | 82 | require.NoError(t, err) 83 | require.NotEmpty(t, resp.Error) 84 | require.Equal(t, "error", resp.Status) 85 | require.NotNil(t, resp.Data) 86 | } 87 | 88 | func TestClient_CheckSns(t *testing.T) { 89 | c := initClient(t) 90 | ctx := context.Background() 91 | 92 | req := &CheckSNSRequest{SnsTopicARN: "arn:aws:sns:us-east-1:123456789012:sns-topic", SnsKey: "key", SnsSecret: "secret"} 93 | resp, err := c.CheckSns(ctx, req) 94 | 95 | require.NoError(t, err) 96 | require.NotEmpty(t, resp.Error) 97 | require.Equal(t, "error", resp.Status) 98 | require.NotNil(t, resp.Data) 99 | } 100 | 101 | func TestClient_CheckPush(t *testing.T) { 102 | c := initClient(t) 103 | ch := initChannel(t, c) 104 | ctx := context.Background() 105 | user := randomUser(t, c) 106 | msgResp, _ := ch.SendMessage(ctx, &Message{Text: "text"}, user.ID) 107 | skipDevices := true 108 | 109 | req := &CheckPushRequest{MessageID: msgResp.Message.ID, SkipDevices: &skipDevices, UserID: user.ID} 110 | resp, err := c.CheckPush(ctx, req) 111 | 112 | require.NoError(t, err) 113 | require.Equal(t, msgResp.Message.ID, resp.RenderedMessage["message_id"]) 114 | } 115 | 116 | // See https://getstream.io/chat/docs/app_settings_auth/ for 117 | // more details. 118 | func ExampleClient_UpdateAppSettings_disable_auth() { 119 | client, err := NewClient("XXXXXXXXXXXX", "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX") 120 | if err != nil { 121 | log.Fatalf("Err: %v", err) 122 | } 123 | ctx := context.Background() 124 | 125 | // disable auth checks, allows dev token usage 126 | settings := NewAppSettings().SetDisableAuth(true) 127 | _, err = client.UpdateAppSettings(ctx, settings) 128 | if err != nil { 129 | log.Fatalf("Err: %v", err) 130 | } 131 | 132 | // re-enable auth checks 133 | _, err = client.UpdateAppSettings(ctx, NewAppSettings().SetDisableAuth(false)) 134 | if err != nil { 135 | log.Fatalf("Err: %v", err) 136 | } 137 | } 138 | 139 | func ExampleClient_UpdateAppSettings_disable_permission() { 140 | client, err := NewClient("XXXX", "XXXX") 141 | if err != nil { 142 | log.Fatalf("Err: %v", err) 143 | } 144 | ctx := context.Background() 145 | 146 | // disable permission checkse 147 | settings := NewAppSettings().SetDisablePermissions(true) 148 | _, err = client.UpdateAppSettings(ctx, settings) 149 | if err != nil { 150 | log.Fatalf("Err: %v", err) 151 | } 152 | 153 | // re-enable permission checks 154 | _, err = client.UpdateAppSettings(ctx, NewAppSettings().SetDisablePermissions(false)) 155 | if err != nil { 156 | log.Fatalf("Err: %v", err) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | STREAM MARK 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /async_tasks.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "time" 11 | ) 12 | 13 | type TaskStatus string 14 | 15 | const ( 16 | TaskStatusWaiting TaskStatus = "waiting" 17 | TaskStatusPending TaskStatus = "pending" 18 | TaskStatusRunning TaskStatus = "running" 19 | TaskStatusCompleted TaskStatus = "completed" 20 | TaskStatusFailed TaskStatus = "failed" 21 | ) 22 | 23 | type TaskResponse struct { 24 | TaskID string `json:"task_id"` 25 | Status TaskStatus `json:"status"` 26 | CreatedAt time.Time `json:"created_at"` 27 | UpdatedAt time.Time `json:"updated_at"` 28 | 29 | Result map[string]interface{} `json:"result,omitempty"` 30 | Response 31 | } 32 | 33 | // GetTask returns the status of a task that has been ran asynchronously. 34 | func (c *Client) GetTask(ctx context.Context, id string) (*TaskResponse, error) { 35 | if id == "" { 36 | return nil, errors.New("id should not be empty") 37 | } 38 | 39 | p := path.Join("tasks", url.PathEscape(id)) 40 | 41 | var task TaskResponse 42 | err := c.makeRequest(ctx, http.MethodGet, p, nil, nil, &task) 43 | return &task, err 44 | } 45 | 46 | type AsyncTaskResponse struct { 47 | TaskID string `json:"task_id"` 48 | Response 49 | } 50 | 51 | // DeleteChannels deletes channels asynchronously. 52 | // Channels and messages will be hard deleted if hardDelete is true. 53 | // It returns an AsyncTaskResponse object which contains the task ID, the status of the task can be check with client.GetTask method. 54 | func (c *Client) DeleteChannels(ctx context.Context, cids []string, hardDelete bool) (*AsyncTaskResponse, error) { 55 | if len(cids) == 0 { 56 | return nil, errors.New("cids parameter should not be empty") 57 | } 58 | 59 | data := struct { 60 | CIDs []string `json:"cids"` 61 | HardDelete bool `json:"hard_delete"` 62 | }{ 63 | CIDs: cids, 64 | HardDelete: hardDelete, 65 | } 66 | 67 | var resp AsyncTaskResponse 68 | err := c.makeRequest(ctx, http.MethodPost, "channels/delete", nil, data, &resp) 69 | return &resp, err 70 | } 71 | 72 | type DeleteType string 73 | 74 | const ( 75 | HardDelete DeleteType = "hard" 76 | SoftDelete DeleteType = "soft" 77 | ) 78 | 79 | type DeleteUserOptions struct { 80 | User DeleteType `json:"user"` 81 | Messages DeleteType `json:"messages,omitempty"` 82 | Conversations DeleteType `json:"conversations,omitempty"` 83 | NewChannelOwnerID string `json:"new_channel_owner_id,omitempty"` 84 | } 85 | 86 | // DeleteUsers deletes users asynchronously. 87 | // User will be deleted either "hard" or "soft" 88 | // Conversations (1to1 channels) will be deleted if either "hard" or "soft" 89 | // Messages will be deleted if either "hard" or "soft" 90 | // NewChannelOwnerID any channels owned by the hard-deleted user will be transferred to this user ID 91 | // It returns an AsyncTaskResponse object which contains the task ID, the status of the task can be check with client.GetTask method. 92 | func (c *Client) DeleteUsers(ctx context.Context, userIDs []string, options DeleteUserOptions) (*AsyncTaskResponse, error) { 93 | if len(userIDs) == 0 { 94 | return nil, errors.New("userIDs parameter should not be empty") 95 | } 96 | 97 | data := struct { 98 | DeleteUserOptions 99 | UserIDs []string `json:"user_ids"` 100 | }{ 101 | DeleteUserOptions: options, 102 | UserIDs: userIDs, 103 | } 104 | 105 | var resp AsyncTaskResponse 106 | err := c.makeRequest(ctx, http.MethodPost, "users/delete", nil, data, &resp) 107 | return &resp, err 108 | } 109 | 110 | type ExportableChannel struct { 111 | Type string `json:"type"` 112 | ID string `json:"id"` 113 | MessagesSince *time.Time `json:"messages_since,omitempty"` 114 | MessagesUntil *time.Time `json:"messages_until,omitempty"` 115 | } 116 | 117 | type ExportChannelOptions struct { 118 | ClearDeletedMessageText *bool `json:"clear_deleted_message_text,omitempty"` 119 | IncludeTruncatedMessages *bool `json:"include_truncated_messages,omitempty"` 120 | ExportUsers *bool `json:"export_users,omitempty"` 121 | Version string `json:"version,omitempty"` 122 | } 123 | 124 | // ExportChannels requests an asynchronous export of the provided channels. 125 | // It returns an AsyncTaskResponse object which contains the task ID, the status of the task can be check with client.GetTask method. 126 | func (c *Client) ExportChannels(ctx context.Context, channels []*ExportableChannel, options *ExportChannelOptions) (*AsyncTaskResponse, error) { 127 | if len(channels) == 0 { 128 | return nil, errors.New("number of channels must be at least one") 129 | } 130 | 131 | err := verifyExportableChannels(channels) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | req := struct { 137 | Channels []*ExportableChannel `json:"channels"` 138 | ClearDeletedMessageText *bool `json:"clear_deleted_message_text,omitempty"` 139 | IncludeTruncatedMessages *bool `json:"include_truncated_messages,omitempty"` 140 | ExportUsers *bool `json:"export_users,omitempty"` 141 | Version string `json:"version,omitempty"` 142 | }{ 143 | Channels: channels, 144 | } 145 | 146 | if options != nil { 147 | req.ClearDeletedMessageText = options.ClearDeletedMessageText 148 | req.IncludeTruncatedMessages = options.IncludeTruncatedMessages 149 | req.ExportUsers = options.ExportUsers 150 | req.Version = options.Version 151 | } 152 | 153 | var resp AsyncTaskResponse 154 | err = c.makeRequest(ctx, http.MethodPost, "export_channels", nil, req, &resp) 155 | return &resp, err 156 | } 157 | 158 | func verifyExportableChannels(channels []*ExportableChannel) error { 159 | for i, ch := range channels { 160 | if ch.Type == "" || ch.ID == "" { 161 | return fmt.Errorf("channel type and id must not be empty for index: %d", i) 162 | } 163 | } 164 | return nil 165 | } 166 | 167 | // GetExportChannelsTask returns current state of the export task. 168 | func (c *Client) GetExportChannelsTask(ctx context.Context, taskID string) (*TaskResponse, error) { 169 | if taskID == "" { 170 | return nil, errors.New("task ID must be not empty") 171 | } 172 | 173 | p := path.Join("export_channels", url.PathEscape(taskID)) 174 | 175 | var task TaskResponse 176 | err := c.makeRequest(ctx, http.MethodGet, p, nil, nil, &task) 177 | return &task, err 178 | } 179 | 180 | // ExportUsers requests an asynchronous export of the provided user IDs. 181 | // It returns an AsyncTaskResponse object which contains the task ID, the status of the task can be check with client.GetTask method. 182 | func (c *Client) ExportUsers(ctx context.Context, userIDs []string) (*AsyncTaskResponse, error) { 183 | if len(userIDs) == 0 { 184 | return nil, errors.New("number of user IDs must be at least one") 185 | } 186 | 187 | req := struct { 188 | UserIDs []string `json:"user_ids"` 189 | }{ 190 | UserIDs: userIDs, 191 | } 192 | 193 | var resp AsyncTaskResponse 194 | err := c.makeRequest(ctx, http.MethodPost, "export/users", nil, req, &resp) 195 | return &resp, err 196 | } 197 | -------------------------------------------------------------------------------- /async_tasks_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestClient_DeleteChannels(t *testing.T) { 12 | c := initClient(t) 13 | ch := initChannel(t, c) 14 | ctx := context.Background() 15 | user := randomUser(t, c) 16 | msg := &Message{Text: "test message"} 17 | 18 | _, err := ch.SendMessage(ctx, msg, user.ID, MessageSkipPush) 19 | require.NoError(t, err, "send message") 20 | 21 | // should fail without CIDs in parameter 22 | _, err = c.DeleteChannels(ctx, []string{}, true) 23 | require.Error(t, err) 24 | 25 | resp1, err := c.DeleteChannels(ctx, []string{ch.CID}, true) 26 | require.NoError(t, err) 27 | require.NotEmpty(t, resp1.TaskID) 28 | 29 | for i := 0; i < 10; i++ { 30 | resp2, err := c.GetTask(ctx, resp1.TaskID) 31 | require.NoError(t, err) 32 | require.Equal(t, resp1.TaskID, resp2.TaskID) 33 | 34 | if resp2.Status == TaskStatusCompleted { 35 | require.Equal(t, map[string]interface{}{"status": "ok"}, resp2.Result[ch.CID]) 36 | return 37 | } 38 | 39 | time.Sleep(time.Second) 40 | } 41 | } 42 | 43 | func TestClient_DeleteUsers(t *testing.T) { 44 | c := initClient(t) 45 | ch := initChannel(t, c) 46 | ctx := context.Background() 47 | user := randomUser(t, c) 48 | 49 | msg := &Message{Text: "test message"} 50 | 51 | _, err := ch.SendMessage(ctx, msg, user.ID, MessageSkipPush) 52 | require.NoError(t, err, "send message") 53 | 54 | // should fail without userIDs in parameter 55 | _, err = c.DeleteUsers(ctx, []string{}, DeleteUserOptions{ 56 | User: SoftDelete, 57 | Messages: HardDelete, 58 | }) 59 | require.Error(t, err) 60 | 61 | resp1, err := c.DeleteUsers(ctx, []string{user.ID}, DeleteUserOptions{ 62 | User: SoftDelete, 63 | Messages: HardDelete, 64 | }) 65 | require.NoError(t, err) 66 | require.NotEmpty(t, resp1.TaskID) 67 | 68 | for i := 0; i < 10; i++ { 69 | resp2, err := c.GetTask(ctx, resp1.TaskID) 70 | require.NoError(t, err) 71 | require.Equal(t, resp1.TaskID, resp2.TaskID) 72 | 73 | if resp2.Status == TaskStatusCompleted { 74 | require.Equal(t, map[string]interface{}{"status": "ok"}, resp2.Result[user.ID]) 75 | return 76 | } 77 | 78 | time.Sleep(time.Second) 79 | } 80 | 81 | require.True(t, false, "task did not succeed") 82 | } 83 | 84 | func TestClient_ExportChannels(t *testing.T) { 85 | c := initClient(t) 86 | ch1 := initChannel(t, c) 87 | ch2 := initChannel(t, c) 88 | ctx := context.Background() 89 | 90 | t.Run("Return error if there are 0 channels", func(t *testing.T) { 91 | _, err := c.ExportChannels(ctx, nil, nil) 92 | require.Error(t, err) 93 | }) 94 | 95 | t.Run("Return error if exportable channel structs are incorrect", func(t *testing.T) { 96 | expChannels := []*ExportableChannel{ 97 | {Type: "", ID: ch1.ID}, 98 | } 99 | _, err := c.ExportChannels(ctx, expChannels, nil) 100 | require.Error(t, err) 101 | }) 102 | 103 | t.Run("Export channels with no error", func(t *testing.T) { 104 | expChannels := []*ExportableChannel{ 105 | {Type: ch1.Type, ID: ch1.ID}, 106 | {Type: ch2.Type, ID: ch2.ID}, 107 | } 108 | 109 | resp1, err := c.ExportChannels(ctx, expChannels, nil) 110 | require.NoError(t, err) 111 | require.NotEmpty(t, resp1.TaskID) 112 | 113 | for i := 0; i < 10; i++ { 114 | task, err := c.GetExportChannelsTask(ctx, resp1.TaskID) 115 | require.NoError(t, err) 116 | require.Equal(t, resp1.TaskID, task.TaskID) 117 | require.NotEmpty(t, task.Status) 118 | 119 | if task.Status == TaskStatusCompleted { 120 | break 121 | } 122 | 123 | time.Sleep(time.Second) 124 | } 125 | }) 126 | } 127 | 128 | func TestClient_ExportUsers(t *testing.T) { 129 | c := initClient(t) 130 | ch1 := initChannel(t, c) 131 | ctx := context.Background() 132 | 133 | t.Run("Return error if there are 0 user IDs", func(t *testing.T) { 134 | _, err := c.ExportUsers(ctx, nil) 135 | require.Error(t, err) 136 | }) 137 | 138 | t.Run("Export users with no error", func(t *testing.T) { 139 | resp, err := c.ExportUsers(ctx, []string{ch1.CreatedBy.ID}) 140 | require.NoError(t, err) 141 | require.NotEmpty(t, resp.TaskID) 142 | 143 | for i := 0; i < 10; i++ { 144 | task, err := c.GetTask(ctx, resp.TaskID) 145 | require.NoError(t, err) 146 | require.Equal(t, resp.TaskID, task.TaskID) 147 | require.NotEmpty(t, task.Status) 148 | 149 | if task.Status == TaskStatusCompleted { 150 | require.Contains(t, task.Result["url"], "/exports/users/") 151 | break 152 | } 153 | 154 | time.Sleep(time.Second) 155 | } 156 | }) 157 | } 158 | -------------------------------------------------------------------------------- /ban.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | // BanUser bans targetID. 13 | func (c *Client) BanUser(ctx context.Context, targetID, bannedBy string, options ...BanOption) (*Response, error) { 14 | switch { 15 | case targetID == "": 16 | return nil, errors.New("targetID should not be empty") 17 | case bannedBy == "": 18 | return nil, errors.New("bannedBy should not be empty") 19 | } 20 | 21 | opts := &banOptions{ 22 | TargetUserID: targetID, 23 | BannedBy: bannedBy, 24 | } 25 | 26 | for _, fn := range options { 27 | fn(opts) 28 | } 29 | 30 | var resp Response 31 | err := c.makeRequest(ctx, http.MethodPost, "moderation/ban", nil, opts, &resp) 32 | return &resp, err 33 | } 34 | 35 | // UnBanUser removes the ban for targetID. 36 | func (c *Client) UnBanUser(ctx context.Context, targetID string) (*Response, error) { 37 | if targetID == "" { 38 | return nil, errors.New("targetID should not be empty") 39 | } 40 | 41 | params := url.Values{} 42 | params.Set("target_user_id", targetID) 43 | 44 | var resp Response 45 | err := c.makeRequest(ctx, http.MethodDelete, "moderation/ban", params, nil, &resp) 46 | return &resp, err 47 | } 48 | 49 | // ShadowBan shadow bans targetID. 50 | func (c *Client) ShadowBan(ctx context.Context, targetID, bannedByID string, options ...BanOption) (*Response, error) { 51 | options = append(options, banWithShadow()) 52 | return c.BanUser(ctx, targetID, bannedByID, options...) 53 | } 54 | 55 | // BanUser bans targetID on the channel ch. 56 | func (ch *Channel) BanUser(ctx context.Context, targetID, bannedBy string, options ...BanOption) (*Response, error) { 57 | options = append(options, banFromChannel(ch.Type, ch.ID)) 58 | return ch.client.BanUser(ctx, targetID, bannedBy, options...) 59 | } 60 | 61 | // UnBanUser removes the ban for targetID from the channel ch. 62 | func (ch *Channel) UnBanUser(ctx context.Context, targetID string) (*Response, error) { 63 | if targetID == "" { 64 | return nil, errors.New("targetID should not be empty") 65 | } 66 | 67 | params := url.Values{} 68 | params.Set("target_user_id", targetID) 69 | params.Set("id", ch.ID) 70 | params.Set("type", ch.Type) 71 | 72 | var resp Response 73 | err := ch.client.makeRequest(ctx, http.MethodDelete, "moderation/ban", params, nil, &resp) 74 | return &resp, err 75 | } 76 | 77 | // ShadowBan shadow bans targetID on the channel ch. 78 | func (ch *Channel) ShadowBan(ctx context.Context, targetID, bannedByID string, options ...BanOption) (*Response, error) { 79 | options = append(options, banWithShadow(), banFromChannel(ch.Type, ch.ID)) 80 | return ch.client.ShadowBan(ctx, targetID, bannedByID, options...) 81 | } 82 | 83 | type QueryBannedUsersOptions struct { 84 | *QueryOption 85 | } 86 | 87 | type QueryBannedUsersResponse struct { 88 | Bans []*Ban `json:"bans"` 89 | Response 90 | } 91 | 92 | type Ban struct { 93 | Channel *Channel `json:"channel,omitempty"` 94 | User *User `json:"user"` 95 | Expires *time.Time `json:"expires,omitempty"` 96 | Reason string `json:"reason,omitempty"` 97 | Shadow bool `json:"shadow,omitempty"` 98 | BannedBy *User `json:"banned_by,omitempty"` 99 | CreatedAt time.Time `json:"created_at"` 100 | } 101 | 102 | // QueryBannedUsers filters and returns a list of banned users. 103 | // Banned users can be retrieved in different ways: 104 | // 1) Using the dedicated query bans endpoint 105 | // 2) User Search: you can add the banned:true condition to your search. Please note that 106 | // this will only return users that were banned at the app-level and not the ones 107 | // that were banned only on channels. 108 | func (c *Client) QueryBannedUsers(ctx context.Context, q *QueryBannedUsersOptions, sorters ...*SortOption) (*QueryBannedUsersResponse, error) { 109 | qp := queryRequest{ 110 | FilterConditions: q.Filter, 111 | Limit: q.Limit, 112 | Offset: q.Offset, 113 | Sort: sorters, 114 | } 115 | 116 | data, err := json.Marshal(&qp) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | values := url.Values{} 122 | values.Set("payload", string(data)) 123 | 124 | var resp QueryBannedUsersResponse 125 | err = c.makeRequest(ctx, http.MethodGet, "query_banned_users", values, nil, &resp) 126 | return &resp, err 127 | } 128 | 129 | type banOptions struct { 130 | Reason string `json:"reason,omitempty"` 131 | Expiration int `json:"timeout,omitempty"` 132 | 133 | TargetUserID string `json:"target_user_id"` 134 | BannedBy string `json:"user_id"` 135 | Shadow bool `json:"shadow"` 136 | 137 | // ID and Type of the channel when acting on a channel member. 138 | ID string `json:"id"` 139 | Type string `json:"type"` 140 | } 141 | 142 | type BanOption func(*banOptions) 143 | 144 | func BanWithReason(reason string) func(*banOptions) { 145 | return func(opt *banOptions) { 146 | opt.Reason = reason 147 | } 148 | } 149 | 150 | // BanWithExpiration set when the ban will expire. Should be in minutes. 151 | // eg. to ban during one hour: BanWithExpiration(60). 152 | func BanWithExpiration(expiration int) func(*banOptions) { 153 | return func(opt *banOptions) { 154 | opt.Expiration = expiration 155 | } 156 | } 157 | 158 | func banWithShadow() func(*banOptions) { 159 | return func(opt *banOptions) { 160 | opt.Shadow = true 161 | } 162 | } 163 | 164 | func banFromChannel(_type, id string) func(*banOptions) { 165 | return func(opt *banOptions) { 166 | opt.Type = _type 167 | opt.ID = id 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /ban_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestShadowBanUser(t *testing.T) { 11 | c := initClient(t) 12 | userA := randomUser(t, c) 13 | userB := randomUser(t, c) 14 | userC := randomUser(t, c) 15 | ctx := context.Background() 16 | 17 | ch := initChannel(t, c, userA.ID, userB.ID, userC.ID) 18 | resp, err := c.CreateChannel(ctx, ch.Type, ch.ID, userA.ID, nil) 19 | require.NoError(t, err) 20 | 21 | ch = resp.Channel 22 | 23 | // shadow ban userB globally 24 | _, err = c.ShadowBan(ctx, userB.ID, userA.ID) 25 | require.NoError(t, err) 26 | 27 | // shadow ban userC on channel 28 | _, err = ch.ShadowBan(ctx, userC.ID, userA.ID) 29 | require.NoError(t, err) 30 | 31 | msg := &Message{Text: "test message"} 32 | messageResp, err := ch.SendMessage(ctx, msg, userB.ID) 33 | require.NoError(t, err) 34 | 35 | msg = messageResp.Message 36 | require.False(t, msg.Shadowed) 37 | 38 | messageResp, err = c.GetMessage(ctx, msg.ID) 39 | require.NoError(t, err) 40 | require.True(t, messageResp.Message.Shadowed) 41 | 42 | msg = &Message{Text: "test message"} 43 | messageResp, err = ch.SendMessage(ctx, msg, userC.ID) 44 | require.NoError(t, err) 45 | 46 | msg = messageResp.Message 47 | require.False(t, msg.Shadowed) 48 | 49 | messageResp, err = c.GetMessage(ctx, msg.ID) 50 | require.NoError(t, err) 51 | require.True(t, messageResp.Message.Shadowed) 52 | 53 | _, err = c.UnBanUser(ctx, userB.ID) 54 | require.NoError(t, err) 55 | 56 | msg = &Message{Text: "test message"} 57 | messageResp, err = ch.SendMessage(ctx, msg, userB.ID) 58 | require.NoError(t, err) 59 | 60 | msg = messageResp.Message 61 | require.False(t, msg.Shadowed) 62 | 63 | messageResp, err = c.GetMessage(ctx, msg.ID) 64 | require.NoError(t, err) 65 | require.False(t, messageResp.Message.Shadowed) 66 | 67 | _, err = ch.UnBanUser(ctx, userC.ID) 68 | require.NoError(t, err) 69 | 70 | msg = &Message{Text: "test message"} 71 | messageResp, err = ch.SendMessage(ctx, msg, userC.ID) 72 | require.NoError(t, err) 73 | 74 | msg = messageResp.Message 75 | require.False(t, msg.Shadowed) 76 | 77 | messageResp, err = c.GetMessage(ctx, msg.ID) 78 | require.NoError(t, err) 79 | require.False(t, messageResp.Message.Shadowed) 80 | } 81 | 82 | func TestBanUnbanUser(t *testing.T) { 83 | c := initClient(t) 84 | target := randomUser(t, c) 85 | user := randomUser(t, c) 86 | ctx := context.Background() 87 | 88 | _, err := c.BanUser(ctx, target.ID, user.ID, BanWithReason("spammer"), BanWithExpiration(60)) 89 | require.NoError(t, err) 90 | 91 | resp, err := c.QueryBannedUsers(ctx, &QueryBannedUsersOptions{ 92 | QueryOption: &QueryOption{Filter: map[string]interface{}{ 93 | "user_id": map[string]string{"$eq": target.ID}, 94 | }}, 95 | }) 96 | require.NoError(t, err) 97 | require.Equal(t, "spammer", resp.Bans[0].Reason) 98 | require.NotZero(t, resp.Bans[0].Expires) 99 | 100 | _, err = c.UnBanUser(ctx, target.ID) 101 | require.NoError(t, err) 102 | 103 | resp, err = c.QueryBannedUsers(ctx, &QueryBannedUsersOptions{ 104 | QueryOption: &QueryOption{Filter: map[string]interface{}{ 105 | "user_id": map[string]string{"$eq": target.ID}, 106 | }}, 107 | }) 108 | require.NoError(t, err) 109 | require.Empty(t, resp.Bans) 110 | } 111 | 112 | func TestChannelBanUnban(t *testing.T) { 113 | c := initClient(t) 114 | target := randomUser(t, c) 115 | user := randomUser(t, c) 116 | ch := initChannel(t, c, user.ID, target.ID) 117 | ctx := context.Background() 118 | 119 | _, err := ch.BanUser(ctx, target.ID, user.ID, BanWithReason("spammer"), BanWithExpiration(60)) 120 | require.NoError(t, err) 121 | 122 | _, err = ch.UnBanUser(ctx, target.ID) 123 | require.NoError(t, err) 124 | 125 | resp, err := c.QueryBannedUsers(ctx, &QueryBannedUsersOptions{ 126 | QueryOption: &QueryOption{Filter: map[string]interface{}{ 127 | "channel_cid": map[string]string{"$eq": ch.CID}, 128 | }}, 129 | }) 130 | require.NoError(t, err) 131 | require.Empty(t, resp.Bans) 132 | } 133 | 134 | func ExampleClient_BanUser() { 135 | client, _ := NewClient("XXXX", "XXXX") 136 | ctx := context.Background() 137 | 138 | // ban a user for 60 minutes from all channel 139 | _, _ = client.BanUser(ctx, "eviluser", "modUser", BanWithExpiration(60), BanWithReason("Banned for one hour")) 140 | 141 | // ban a user from the livestream:fortnite channel 142 | channel := client.Channel("livestream", "fortnite") 143 | _, _ = channel.BanUser(ctx, "eviluser", "modUser", BanWithReason("Profanity is not allowed here")) 144 | 145 | // remove ban from channel 146 | channel = client.Channel("livestream", "fortnite") 147 | _, _ = channel.UnBanUser(ctx, "eviluser") 148 | 149 | // remove global ban 150 | _, _ = client.UnBanUser(ctx, "eviluser") 151 | } 152 | -------------------------------------------------------------------------------- /blocklist.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type BlocklistBase struct { 10 | Name string `json:"name"` 11 | Words []string `json:"words"` 12 | } 13 | 14 | type Blocklist struct { 15 | CreatedAt time.Time `json:"created_at"` 16 | UpdatedAt time.Time `json:"updated_at"` 17 | BlocklistBase 18 | } 19 | 20 | type BlocklistCreateRequest struct { 21 | BlocklistBase 22 | } 23 | 24 | type GetBlocklistResponse struct { 25 | Blocklist *Blocklist `json:"blocklist"` 26 | Response 27 | } 28 | 29 | type ListBlocklistsResponse struct { 30 | Blocklists []*Blocklist `json:"blocklists"` 31 | Response 32 | } 33 | 34 | // CreateBlocklist creates a blocklist. 35 | func (c *Client) CreateBlocklist(ctx context.Context, blocklist *BlocklistCreateRequest) (*Response, error) { 36 | var resp Response 37 | err := c.makeRequest(ctx, http.MethodPost, "blocklists", nil, blocklist, &resp) 38 | return &resp, err 39 | } 40 | 41 | // GetBlocklist gets a blocklist. 42 | func (c *Client) GetBlocklist(ctx context.Context, name string) (*GetBlocklistResponse, error) { 43 | var resp GetBlocklistResponse 44 | err := c.makeRequest(ctx, http.MethodGet, "blocklists/"+name, nil, nil, &resp) 45 | return &resp, err 46 | } 47 | 48 | // UpdateBlocklist updates a blocklist. 49 | func (c *Client) UpdateBlocklist(ctx context.Context, name string, words []string) (*Response, error) { 50 | var resp Response 51 | err := c.makeRequest(ctx, http.MethodPut, "blocklists/"+name, nil, map[string][]string{"words": words}, &resp) 52 | return &resp, err 53 | } 54 | 55 | // ListBlocklists lists all blocklists. 56 | func (c *Client) ListBlocklists(ctx context.Context) (*ListBlocklistsResponse, error) { 57 | var resp ListBlocklistsResponse 58 | err := c.makeRequest(ctx, http.MethodGet, "blocklists", nil, nil, &resp) 59 | return &resp, err 60 | } 61 | 62 | // DeleteBlocklist deletes a blocklist. 63 | func (c *Client) DeleteBlocklist(ctx context.Context, name string) (*Response, error) { 64 | var resp Response 65 | err := c.makeRequest(ctx, http.MethodDelete, "blocklists/"+name, nil, nil, &resp) 66 | return &resp, err 67 | } 68 | -------------------------------------------------------------------------------- /blocklist_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestClient_TestBlocklistsEndToEnd(t *testing.T) { 11 | t.Skip() 12 | c := initClient(t) 13 | ctx := context.Background() 14 | blocklistName := randomString(10) 15 | blocklistReq := &BlocklistCreateRequest{BlocklistBase{Name: blocklistName, Words: []string{"test"}}} 16 | 17 | _, err := c.CreateBlocklist(ctx, blocklistReq) 18 | require.NoError(t, err) 19 | 20 | getResp, err := c.GetBlocklist(ctx, blocklistName) 21 | require.NoError(t, err) 22 | require.Equal(t, blocklistName, getResp.Blocklist.Name) 23 | require.Equal(t, blocklistReq.Words, getResp.Blocklist.Words) 24 | 25 | listResp, err := c.ListBlocklists(ctx) 26 | require.NoError(t, err) 27 | require.NotEmpty(t, listResp.Blocklists) 28 | 29 | _, err = c.UpdateBlocklist(ctx, blocklistName, []string{"test2"}) 30 | require.NoError(t, err) 31 | 32 | _, err = c.DeleteBlocklist(ctx, blocklistName) 33 | require.NoError(t, err) 34 | } 35 | -------------------------------------------------------------------------------- /channel_config.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | // ChannelConfig is the configuration for a channel. 9 | type ChannelConfig struct { 10 | Name string `json:"name"` 11 | 12 | // features 13 | // show typing indicators or not (probably auto disable if more than X users in a channel) 14 | TypingEvents bool `json:"typing_events"` 15 | // store who has read the message, or at least when they last viewed the chat 16 | ReadEvents bool `json:"read_events"` 17 | // connect events can get very noisy for larger chat groups 18 | ConnectEvents bool `json:"connect_events"` 19 | // make messages searchable 20 | Search bool `json:"search"` 21 | Reactions bool `json:"reactions"` 22 | Reminders bool `json:"reminders"` 23 | Replies bool `json:"replies"` 24 | Mutes bool `json:"mutes"` 25 | // enable/disable push notifications 26 | PushNotifications bool `json:"push_notifications"` 27 | Uploads bool `json:"uploads"` 28 | URLEnrichment bool `json:"url_enrichment"` 29 | CustomEvents bool `json:"custom_events"` 30 | 31 | // number of days to keep messages, must be MessageRetentionForever or numeric string 32 | MessageRetention string `json:"message_retention"` 33 | MaxMessageLength int `json:"max_message_length"` 34 | MarkMessagesPending bool `json:"mark_messages_pending"` 35 | 36 | Automod modType `json:"automod"` // disabled, simple or AI 37 | ModBehavior modBehaviour `json:"automod_behavior"` 38 | 39 | BlockList string `json:"blocklist"` 40 | BlockListBehavior modBehaviour `json:"blocklist_behavior"` 41 | AutomodThresholds *Thresholds `json:"automod_thresholds"` 42 | 43 | // Dynamic Partitioning 44 | PartitionSize int `json:"partition_size,omitempty"` 45 | PartitionTTL *DurationString `json:"partition_ttl,omitempty"` 46 | 47 | SkipLastMsgUpdateForSystemMsgs bool `json:"skip_last_msg_update_for_system_msgs,omitempty"` 48 | } 49 | 50 | // DurationString is a duration that's encoded to as a string in JSON. 51 | type DurationString time.Duration 52 | 53 | // NewDurationString creates a pointer to a DurationString. 54 | func NewDurationString(d time.Duration) *DurationString { 55 | duration := DurationString(d) 56 | return &duration 57 | } 58 | 59 | // MarshalJSON encodes the duration as a string such as "2h30m". 60 | func (d DurationString) MarshalJSON() ([]byte, error) { 61 | if d == 0 { 62 | return []byte("null"), nil 63 | } 64 | return []byte(`"` + time.Duration(d).String() + `"`), nil 65 | } 66 | 67 | // String returns the duration as a string such as "2h30m". 68 | func (d DurationString) String() string { 69 | return time.Duration(d).String() 70 | } 71 | 72 | // UnmarshalJSON decodes a duration from a string formatted as 73 | // [time.Duration.String()](https://golang.org/pkg/time/#Duration.String) 74 | func (d *DurationString) UnmarshalJSON(b []byte) error { 75 | s, err := strconv.Unquote(string(b)) 76 | if err != nil { 77 | return err 78 | } 79 | dur, err := time.ParseDuration(s) 80 | if err != nil { 81 | return err 82 | } 83 | *d = DurationString(dur) 84 | return nil 85 | } 86 | 87 | type LabelThresholds struct { 88 | Flag float32 `json:"flag"` 89 | Block float32 `json:"block"` 90 | } 91 | 92 | type Thresholds struct { 93 | Explicit *LabelThresholds `json:"explicit"` 94 | Spam *LabelThresholds `json:"spam"` 95 | Toxic *LabelThresholds `json:"toxic"` 96 | } 97 | 98 | // DefaultChannelConfig is the default channel configuration. 99 | var DefaultChannelConfig = ChannelConfig{ 100 | Automod: AutoModDisabled, 101 | ModBehavior: ModBehaviourFlag, 102 | MaxMessageLength: defaultMessageLength, 103 | MessageRetention: MessageRetentionForever, 104 | PushNotifications: true, 105 | } 106 | -------------------------------------------------------------------------------- /channel_config_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestDuration_MarshalJSON(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input DurationString 13 | want string 14 | }{ 15 | { 16 | name: "Zero", 17 | input: DurationString(0), 18 | want: `null`, 19 | }, 20 | { 21 | name: "Hours", 22 | input: DurationString(24 * time.Hour), 23 | want: `"24h0m0s"`, 24 | }, 25 | { 26 | name: "Mixed", 27 | input: DurationString(24*time.Hour + 30*time.Minute + 15*time.Second), 28 | want: `"24h30m15s"`, 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | got, err := tt.input.MarshalJSON() 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | if string(got) != tt.want { 39 | t.Errorf("Duration.MarshalJSON() = %q, want %q", string(got), tt.want) 40 | } 41 | }) 42 | } 43 | } 44 | 45 | func TestDuration_UnmarshalJSON(t *testing.T) { 46 | tests := []struct { 47 | name string 48 | input string 49 | want DurationString 50 | wantErr bool 51 | }{ 52 | { 53 | name: "Hours", 54 | input: `"4h"`, 55 | want: DurationString(4 * time.Hour), 56 | }, 57 | { 58 | name: "Mixed", 59 | input: `"2h30m"`, 60 | want: DurationString(2*time.Hour + 30*time.Minute), 61 | }, 62 | { 63 | name: "Full", 64 | input: `"6h0m0s"`, 65 | want: DurationString(6 * time.Hour), 66 | }, 67 | { 68 | name: "Invalid", 69 | input: "daily", 70 | wantErr: true, 71 | }, 72 | } 73 | 74 | for _, tt := range tests { 75 | t.Run(tt.name, func(t *testing.T) { 76 | var got DurationString 77 | err := json.Unmarshal([]byte(tt.input), &got) 78 | if (err != nil) != tt.wantErr { 79 | t.Fatalf("Error = %q, want error: %t", err, tt.wantErr) 80 | } 81 | if got.String() != tt.want.String() { 82 | t.Errorf("Duration.UnmarshalJSON() = %q, want %q", got, tt.want) 83 | } 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /channel_type.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | "time" 10 | ) 11 | 12 | const ( 13 | AutoModDisabled modType = "disabled" 14 | AutoModSimple modType = "simple" 15 | AutoModAI modType = "AI" 16 | 17 | ModBehaviourFlag modBehaviour = "flag" 18 | ModBehaviourBlock modBehaviour = "block" 19 | 20 | defaultMessageLength = 5000 21 | 22 | MessageRetentionForever = "infinite" 23 | ) 24 | 25 | var defaultChannelTypes = []string{ 26 | "messaging", 27 | "team", 28 | "livestream", 29 | "commerce", 30 | "gaming", 31 | } 32 | 33 | type ( 34 | modType string 35 | modBehaviour string 36 | ) 37 | 38 | type ChannelTypePermission struct { 39 | Name string `json:"name"` // required 40 | Action string `json:"action"` // one of: Deny Allow 41 | 42 | Resources []string `json:"resources"` // required 43 | Roles []string `json:"roles"` 44 | Owner bool `json:"owner"` 45 | Priority int `json:"priority"` // required 46 | } 47 | 48 | type ChannelType struct { 49 | ChannelConfig 50 | 51 | Commands []*Command `json:"commands"` 52 | // Deprecated: Use Permissions V2 API instead, 53 | // that can be found in permission_client.go. 54 | // See https://getstream.io/chat/docs/go-golang/migrating_from_legacy/?language=go 55 | Permissions []*ChannelTypePermission `json:"permissions"` 56 | Grants map[string][]string `json:"grants"` 57 | 58 | CreatedAt time.Time `json:"created_at"` 59 | UpdatedAt time.Time `json:"updated_at"` 60 | } 61 | 62 | func (ct *ChannelType) toRequest() channelTypeRequest { 63 | req := channelTypeRequest{ChannelType: ct} 64 | 65 | for _, cmd := range ct.Commands { 66 | req.Commands = append(req.Commands, cmd.Name) 67 | } 68 | 69 | if len(req.Commands) == 0 { 70 | req.Commands = []string{"all"} 71 | } 72 | 73 | return req 74 | } 75 | 76 | // NewChannelType returns initialized ChannelType with default values. 77 | func NewChannelType(name string) *ChannelType { 78 | ct := &ChannelType{ChannelConfig: DefaultChannelConfig} 79 | ct.Name = name 80 | 81 | return ct 82 | } 83 | 84 | type channelTypeRequest struct { 85 | *ChannelType 86 | 87 | Commands []string `json:"commands"` 88 | 89 | CreatedAt time.Time `json:"-"` 90 | UpdatedAt time.Time `json:"-"` 91 | } 92 | 93 | type ChannelTypeResponse struct { 94 | *ChannelType 95 | 96 | Commands []string `json:"commands"` 97 | 98 | CreatedAt time.Time `json:"-"` 99 | UpdatedAt time.Time `json:"-"` 100 | 101 | Response 102 | } 103 | 104 | // CreateChannelType adds new channel type. 105 | func (c *Client) CreateChannelType(ctx context.Context, chType *ChannelType) (*ChannelTypeResponse, error) { 106 | if chType == nil { 107 | return nil, errors.New("channel type is nil") 108 | } 109 | 110 | var resp ChannelTypeResponse 111 | 112 | err := c.makeRequest(ctx, http.MethodPost, "channeltypes", nil, chType.toRequest(), &resp) 113 | if err != nil { 114 | return nil, err 115 | } 116 | if resp.ChannelType == nil { 117 | return nil, errors.New("unexpected error: channel type response is nil") 118 | } 119 | 120 | for _, cmd := range resp.Commands { 121 | resp.ChannelType.Commands = append(resp.ChannelType.Commands, &Command{Name: cmd}) 122 | } 123 | 124 | return &resp, nil 125 | } 126 | 127 | type GetChannelTypeResponse struct { 128 | *ChannelType 129 | Response 130 | } 131 | 132 | // GetChannelType returns information about channel type. 133 | func (c *Client) GetChannelType(ctx context.Context, chanType string) (*GetChannelTypeResponse, error) { 134 | if chanType == "" { 135 | return nil, errors.New("channel type is empty") 136 | } 137 | 138 | p := path.Join("channeltypes", url.PathEscape(chanType)) 139 | 140 | var resp GetChannelTypeResponse 141 | err := c.makeRequest(ctx, http.MethodGet, p, nil, nil, &resp) 142 | return &resp, err 143 | } 144 | 145 | type ChannelTypesResponse struct { 146 | ChannelTypes map[string]*ChannelType `json:"channel_types"` 147 | Response 148 | } 149 | 150 | // ListChannelTypes returns all channel types. 151 | func (c *Client) ListChannelTypes(ctx context.Context) (*ChannelTypesResponse, error) { 152 | var resp ChannelTypesResponse 153 | err := c.makeRequest(ctx, http.MethodGet, "channeltypes", nil, nil, &resp) 154 | return &resp, err 155 | } 156 | 157 | // UpdateChannelType updates channel type. 158 | func (c *Client) UpdateChannelType(ctx context.Context, name string, options map[string]interface{}) (*Response, error) { 159 | switch { 160 | case name == "": 161 | return nil, errors.New("channel type name is empty") 162 | case len(options) == 0: 163 | return nil, errors.New("options are empty") 164 | } 165 | 166 | p := path.Join("channeltypes", url.PathEscape(name)) 167 | var resp Response 168 | err := c.makeRequest(ctx, http.MethodPut, p, nil, options, &resp) 169 | return &resp, err 170 | } 171 | 172 | // DeleteChannelType deletes channel type. 173 | func (c *Client) DeleteChannelType(ctx context.Context, name string) (*Response, error) { 174 | if name == "" { 175 | return nil, errors.New("channel type name is empty") 176 | } 177 | 178 | p := path.Join("channeltypes", url.PathEscape(name)) 179 | var resp Response 180 | err := c.makeRequest(ctx, http.MethodDelete, p, nil, nil, &resp) 181 | return &resp, err 182 | } 183 | -------------------------------------------------------------------------------- /channel_type_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func prepareChannelType(t *testing.T, c *Client) *ChannelType { 13 | t.Helper() 14 | 15 | ct := NewChannelType(randomString(10)) 16 | ctx := context.Background() 17 | 18 | resp, err := c.CreateChannelType(ctx, ct) 19 | require.NoError(t, err, "create channel type") 20 | time.Sleep(6 * time.Second) 21 | 22 | t.Cleanup(func() { 23 | for i := 0; i < 5; i++ { 24 | _, err = c.DeleteChannelType(ctx, ct.Name) 25 | if err == nil { 26 | break 27 | } 28 | time.Sleep(time.Second) 29 | } 30 | }) 31 | 32 | return resp.ChannelType 33 | } 34 | 35 | func TestClient_GetChannelType(t *testing.T) { 36 | c := initClient(t) 37 | ct := prepareChannelType(t, c) 38 | ctx := context.Background() 39 | 40 | resp, err := c.GetChannelType(ctx, ct.Name) 41 | require.NoError(t, err, "get channel type") 42 | 43 | assert.Equal(t, ct.Name, resp.ChannelType.Name) 44 | assert.Equal(t, len(ct.Commands), len(resp.ChannelType.Commands)) 45 | assert.Equal(t, ct.Permissions, resp.ChannelType.Permissions) 46 | assert.NotEmpty(t, resp.Grants) 47 | } 48 | 49 | func TestClient_ListChannelTypes(t *testing.T) { 50 | c := initClient(t) 51 | ct := prepareChannelType(t, c) 52 | ctx := context.Background() 53 | 54 | resp, err := c.ListChannelTypes(ctx) 55 | require.NoError(t, err, "list channel types") 56 | 57 | assert.Contains(t, resp.ChannelTypes, ct.Name) 58 | } 59 | 60 | func TestClient_UpdateChannelTypeMarkMessagesPending(t *testing.T) { 61 | c := initClient(t) 62 | ct := prepareChannelType(t, c) 63 | ctx := context.Background() 64 | 65 | // default is off 66 | require.False(t, ct.MarkMessagesPending) 67 | 68 | _, err := c.UpdateChannelType(ctx, ct.Name, map[string]interface{}{"mark_messages_pending": true}) 69 | require.NoError(t, err) 70 | 71 | resp, err := c.GetChannelType(ctx, ct.Name) 72 | require.NoError(t, err) 73 | require.True(t, resp.ChannelType.MarkMessagesPending) 74 | } 75 | 76 | func TestClient_UpdateChannelTypePushNotifications(t *testing.T) { 77 | c := initClient(t) 78 | ct := prepareChannelType(t, c) 79 | ctx := context.Background() 80 | 81 | // default is on 82 | require.True(t, ct.PushNotifications) 83 | 84 | _, err := c.UpdateChannelType(ctx, ct.Name, map[string]interface{}{"push_notifications": false}) 85 | require.NoError(t, err) 86 | 87 | resp, err := c.GetChannelType(ctx, ct.Name) 88 | require.NoError(t, err) 89 | require.False(t, resp.ChannelType.PushNotifications) 90 | } 91 | 92 | // See https://getstream.io/chat/docs/channel_features/ for more details. 93 | func ExampleClient_CreateChannelType() { 94 | client := &Client{} 95 | ctx := context.Background() 96 | 97 | newChannelType := &ChannelType{ 98 | // Copy the default settings. 99 | ChannelConfig: DefaultChannelConfig, 100 | } 101 | 102 | newChannelType.Name = "public" 103 | newChannelType.Mutes = false 104 | newChannelType.Reactions = false 105 | newChannelType.Permissions = append(newChannelType.Permissions, 106 | &ChannelTypePermission{ 107 | Name: "Allow reads for all", 108 | Priority: 999, 109 | Resources: []string{"ReadChannel", "CreateMessage"}, 110 | Action: "Allow", 111 | }, 112 | &ChannelTypePermission{ 113 | Name: "Deny all", 114 | Priority: 1, 115 | Resources: []string{"*"}, 116 | Action: "Deny", 117 | }, 118 | ) 119 | 120 | _, _ = client.CreateChannelType(ctx, newChannelType) 121 | } 122 | 123 | func ExampleClient_ListChannelTypes() { 124 | client := &Client{} 125 | ctx := context.Background() 126 | _, _ = client.ListChannelTypes(ctx) 127 | } 128 | 129 | func ExampleClient_GetChannelType() { 130 | client := &Client{} 131 | ctx := context.Background() 132 | _, _ = client.GetChannelType(ctx, "public") 133 | } 134 | 135 | func ExampleClient_UpdateChannelType() { 136 | client := &Client{} 137 | ctx := context.Background() 138 | 139 | _, _ = client.UpdateChannelType(ctx, "public", map[string]interface{}{ 140 | "permissions": []map[string]interface{}{ 141 | { 142 | "name": "Allow reads for all", 143 | "priority": 999, 144 | "resources": []string{"ReadChannel", "CreateMessage"}, 145 | "role": "*", 146 | "action": "Allow", 147 | }, 148 | { 149 | "name": "Deny all", 150 | "priority": 1, 151 | "resources": []string{"*"}, 152 | "role": "*", 153 | "action": "Deny", 154 | }, 155 | }, 156 | "replies": false, 157 | "commands": []string{"all"}, 158 | }) 159 | } 160 | 161 | func ExampleClient_UpdateChannelType_bool() { 162 | client := &Client{} 163 | ctx := context.Background() 164 | 165 | _, _ = client.UpdateChannelType(ctx, "public", map[string]interface{}{ 166 | "typing_events": false, 167 | "read_events": true, 168 | "connect_events": true, 169 | "search": false, 170 | "reactions": true, 171 | "replies": false, 172 | "mutes": true, 173 | }) 174 | } 175 | 176 | func ExampleClient_UpdateChannelType_other() { 177 | client := &Client{} 178 | ctx := context.Background() 179 | 180 | _, _ = client.UpdateChannelType(ctx, 181 | "public", 182 | map[string]interface{}{ 183 | "automod": "disabled", 184 | "message_retention": "7", 185 | "max_message_length": 140, 186 | "commands": []interface{}{"ban", "unban"}, 187 | }, 188 | ) 189 | } 190 | 191 | func ExampleClient_UpdateChannelType_permissions() { 192 | client := &Client{} 193 | ctx := context.Background() 194 | 195 | _, _ = client.UpdateChannelType(ctx, 196 | "public", 197 | map[string]interface{}{ 198 | "permissions": []map[string]interface{}{ 199 | { 200 | "name": "Allow reads for all", 201 | "priority": 999, 202 | "resources": []string{"ReadChannel", "CreateMessage"}, 203 | "role": "*", 204 | "action": "Allow", 205 | }, 206 | { 207 | "name": "Deny all", 208 | "priority": 1, 209 | "resources": []string{"*"}, 210 | "action": "Deny", 211 | }, 212 | }, 213 | }, 214 | ) 215 | } 216 | 217 | func ExampleClient_DeleteChannelType() { 218 | client := &Client{} 219 | ctx := context.Background() 220 | 221 | _, _ = client.DeleteChannelType(ctx, "public") 222 | } 223 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto" 7 | "crypto/hmac" 8 | "encoding/hex" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "io/ioutil" 14 | "mime/multipart" 15 | "net/http" 16 | "net/textproto" 17 | "os" 18 | "strconv" 19 | "strings" 20 | "time" 21 | 22 | "github.com/golang-jwt/jwt/v4" 23 | ) 24 | 25 | const ( 26 | // DefaultBaseURL is the default base URL for the stream chat api. 27 | // It works like CDN style and connects you to the closest production server. 28 | // By default, there is no real reason to change it. Use it only if you know what you are doing. 29 | DefaultBaseURL = "https://chat.stream-io-api.com" 30 | defaultTimeout = 6 * time.Second 31 | ) 32 | 33 | type Client struct { 34 | BaseURL string 35 | HTTP *http.Client `json:"-"` 36 | 37 | apiKey string 38 | apiSecret []byte 39 | authToken string 40 | } 41 | 42 | type ClientOption func(c *Client) 43 | 44 | func WithTimeout(t time.Duration) func(c *Client) { 45 | return func(c *Client) { 46 | c.HTTP.Timeout = t 47 | } 48 | } 49 | 50 | // NewClientFromEnvVars creates a new Client where the API key 51 | // is retrieved from STREAM_KEY and the secret from STREAM_SECRET 52 | // environmental variables. 53 | func NewClientFromEnvVars() (*Client, error) { 54 | return NewClient(os.Getenv("STREAM_KEY"), os.Getenv("STREAM_SECRET")) 55 | } 56 | 57 | // NewClient creates new stream chat api client. 58 | func NewClient(apiKey, apiSecret string, options ...ClientOption) (*Client, error) { 59 | switch { 60 | case apiKey == "": 61 | return nil, errors.New("API key is empty") 62 | case apiSecret == "": 63 | return nil, errors.New("API secret is empty") 64 | } 65 | 66 | baseURL := DefaultBaseURL 67 | if baseURLEnv := os.Getenv("STREAM_CHAT_URL"); strings.HasPrefix(baseURLEnv, "http") { 68 | baseURL = baseURLEnv 69 | } 70 | 71 | timeout := defaultTimeout 72 | if timeoutEnv := os.Getenv("STREAM_CHAT_TIMEOUT"); timeoutEnv != "" { 73 | i, err := strconv.Atoi(timeoutEnv) 74 | if err != nil { 75 | return nil, err 76 | } 77 | timeout = time.Duration(i) * time.Second 78 | } 79 | 80 | tr := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert 81 | tr.MaxIdleConnsPerHost = 5 82 | tr.IdleConnTimeout = 59 * time.Second // load balancer's idle timeout is 60 sec 83 | tr.ExpectContinueTimeout = 2 * time.Second 84 | 85 | client := &Client{ 86 | apiKey: apiKey, 87 | apiSecret: []byte(apiSecret), 88 | BaseURL: baseURL, 89 | HTTP: &http.Client{ 90 | Timeout: timeout, 91 | Transport: tr, 92 | }, 93 | } 94 | 95 | for _, fn := range options { 96 | fn(client) 97 | } 98 | 99 | token, err := client.createToken(jwt.MapClaims{"server": true}) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | client.authToken = token 105 | 106 | return client, nil 107 | } 108 | 109 | // SetClient sets a new underlying HTTP client. 110 | func (c *Client) SetClient(client *http.Client) { 111 | c.HTTP = client 112 | } 113 | 114 | // Channel returns a Channel object for future API calls. 115 | func (c *Client) Channel(channelType, channelID string) *Channel { 116 | return &Channel{ 117 | client: c, 118 | 119 | ID: channelID, 120 | Type: channelType, 121 | } 122 | } 123 | 124 | // Permissions returns a client for handling app permissions. 125 | func (c *Client) Permissions() *PermissionClient { 126 | return &PermissionClient{client: c} 127 | } 128 | 129 | // CreateToken creates a new token for user with optional expire time. 130 | // Zero time is assumed to be no expire. 131 | func (c *Client) CreateToken(userID string, expire time.Time, issuedAt ...time.Time) (string, error) { 132 | if userID == "" { 133 | return "", errors.New("user ID is empty") 134 | } 135 | 136 | claims := jwt.MapClaims{ 137 | "user_id": userID, 138 | } 139 | if !expire.IsZero() { 140 | claims["exp"] = expire.Unix() 141 | } 142 | if len(issuedAt) > 0 && !issuedAt[0].IsZero() { 143 | claims["iat"] = issuedAt[0].Unix() 144 | } 145 | 146 | return c.createToken(claims) 147 | } 148 | 149 | func (c *Client) createToken(claims jwt.Claims) (string, error) { 150 | return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(c.apiSecret) 151 | } 152 | 153 | // VerifyWebhook validates if hmac signature is correct for message body. 154 | func (c *Client) VerifyWebhook(body, signature []byte) (valid bool) { 155 | mac := hmac.New(crypto.SHA256.New, c.apiSecret) 156 | _, _ = mac.Write(body) 157 | 158 | expectedMAC := hex.EncodeToString(mac.Sum(nil)) 159 | return bytes.Equal(signature, []byte(expectedMAC)) 160 | } 161 | 162 | // this makes possible to set content type. 163 | type multipartForm struct { 164 | *multipart.Writer 165 | } 166 | 167 | // CreateFormFile is a convenience wrapper around CreatePart. It creates 168 | // a new form-data header with the provided field name, file name and content type. 169 | func (form *multipartForm) CreateFormFile(fieldName, filename string) (io.Writer, error) { 170 | h := make(textproto.MIMEHeader) 171 | 172 | h.Set("Content-Disposition", 173 | fmt.Sprintf(`form-data; name=%q; filename=%q`, fieldName, filename)) 174 | 175 | return form.Writer.CreatePart(h) 176 | } 177 | 178 | func (form *multipartForm) setData(fieldName string, data interface{}) error { 179 | field, err := form.CreateFormField(fieldName) 180 | if err != nil { 181 | return err 182 | } 183 | return json.NewEncoder(field).Encode(data) 184 | } 185 | 186 | func (form *multipartForm) setFile(fieldName string, r io.Reader, fileName string) error { 187 | file, err := form.CreateFormFile(fieldName, fileName) 188 | if err != nil { 189 | return err 190 | } 191 | _, err = io.Copy(file, r) 192 | 193 | return err 194 | } 195 | 196 | type SendFileResponse struct { 197 | File string `json:"file"` 198 | Response 199 | } 200 | 201 | func (c *Client) sendFile(ctx context.Context, link string, opts SendFileRequest) (*SendFileResponse, error) { 202 | if opts.User == nil { 203 | return nil, errors.New("user is nil") 204 | } 205 | 206 | tmpfile, err := ioutil.TempFile("", opts.FileName) 207 | if err != nil { 208 | return nil, err 209 | } 210 | 211 | defer func() { 212 | _ = tmpfile.Close() 213 | _ = os.Remove(tmpfile.Name()) 214 | }() 215 | 216 | form := multipartForm{multipart.NewWriter(tmpfile)} 217 | 218 | if err := form.setData("user", opts.User); err != nil { 219 | return nil, err 220 | } 221 | 222 | err = form.setFile("file", opts.Reader, opts.FileName) 223 | if err != nil { 224 | return nil, err 225 | } 226 | 227 | err = form.Close() 228 | if err != nil { 229 | return nil, err 230 | } 231 | 232 | if _, err = tmpfile.Seek(0, 0); err != nil { 233 | return nil, err 234 | } 235 | 236 | r, err := c.newRequest(ctx, http.MethodPost, link, nil, tmpfile) 237 | if err != nil { 238 | return nil, err 239 | } 240 | 241 | r.Header.Set("Content-Type", form.FormDataContentType()) 242 | 243 | res, err := c.HTTP.Do(r) 244 | if err != nil { 245 | return nil, err 246 | } 247 | 248 | var resp SendFileResponse 249 | err = c.parseResponse(res, &resp) 250 | if err != nil { 251 | return nil, err 252 | } 253 | 254 | return &resp, err 255 | } 256 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func initClient(t *testing.T) *Client { 14 | t.Helper() 15 | 16 | c, err := NewClientFromEnvVars() 17 | require.NoError(t, err, "new client") 18 | 19 | return c 20 | } 21 | 22 | func initChannel(t *testing.T, c *Client, membersID ...string) *Channel { 23 | t.Helper() 24 | 25 | owner := randomUser(t, c) 26 | ctx := context.Background() 27 | 28 | resp, err := c.CreateChannelWithMembers(ctx, "team", randomString(12), owner.ID, membersID...) 29 | require.NoError(t, err, "create channel") 30 | 31 | t.Cleanup(func() { 32 | _, _ = c.DeleteChannels(ctx, []string{resp.Channel.CID}, true) 33 | }) 34 | 35 | return resp.Channel 36 | } 37 | 38 | func TestClient_SwapHttpClient(t *testing.T) { 39 | c := initClient(t) 40 | ctx := context.Background() 41 | 42 | tr := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert 43 | proxyURL, _ := url.Parse("http://getstream.io") 44 | tr.Proxy = http.ProxyURL(proxyURL) 45 | cl := &http.Client{Transport: tr} 46 | c.SetClient(cl) 47 | _, err := c.GetAppSettings(ctx) 48 | require.Error(t, err) 49 | 50 | cl = &http.Client{} 51 | c.SetClient(cl) 52 | _, err = c.GetAppSettings(ctx) 53 | require.NoError(t, err) 54 | } 55 | 56 | func TestClient_CreateToken(t *testing.T) { 57 | type args struct { 58 | userID string 59 | expire time.Time 60 | iat time.Time 61 | } 62 | tests := []struct { 63 | name string 64 | args args 65 | want string 66 | wantErr bool 67 | }{ 68 | { 69 | "simple without expiration and iat", 70 | args{"tommaso", time.Time{}, time.Time{}}, 71 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidG9tbWFzbyJ9.v-x-jt3ZnBXXbQ0GoWloIZtVnat2IE74U1a4Yuxd63M", 72 | false, 73 | }, 74 | { 75 | "simple with expiration and iat", 76 | args{"tommaso", time.Unix(1566941272, 123121), time.Unix(1566941272, 123121)}, 77 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjY5NDEyNzIsImlhdCI6MTU2Njk0MTI3MiwidXNlcl9pZCI6InRvbW1hc28ifQ.3HY2O_7o5ZjZ-6KCXLzyPpHZOlNEDy6_m3iNb5DKAMY", 78 | false, 79 | }, 80 | } 81 | for _, tt := range tests { 82 | tt := tt 83 | t.Run(tt.name, func(t *testing.T) { 84 | c, err := NewClient("key", "secret") 85 | require.NoError(t, err) 86 | 87 | got, err := c.CreateToken(tt.args.userID, tt.args.expire, tt.args.iat) 88 | if (err != nil) != tt.wantErr { 89 | t.Errorf("createToken() error = %v, wantErr %v", err, tt.wantErr) 90 | return 91 | } 92 | if got != tt.want { 93 | t.Errorf("createToken() got = %v, want %v", got, tt.want) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | ) 10 | 11 | // Command represents a custom command. 12 | type Command struct { 13 | Name string `json:"name"` 14 | Description string `json:"description"` 15 | Args string `json:"args"` 16 | Set string `json:"set"` 17 | } 18 | 19 | // CommandResponse represents an API response containing one Command. 20 | type CommandResponse struct { 21 | Command *Command `json:"command"` 22 | Response 23 | } 24 | 25 | // CreateCommand registers a new custom command. 26 | func (c *Client) CreateCommand(ctx context.Context, cmd *Command) (*CommandResponse, error) { 27 | if cmd == nil { 28 | return nil, errors.New("command is nil") 29 | } 30 | 31 | var resp CommandResponse 32 | 33 | err := c.makeRequest(ctx, http.MethodPost, "commands", nil, cmd, &resp) 34 | if err != nil { 35 | return nil, err 36 | } 37 | if resp.Command == nil { 38 | return nil, errors.New("unexpected error: command response is nil") 39 | } 40 | 41 | return &resp, nil 42 | } 43 | 44 | type GetCommandResponse struct { 45 | *Command 46 | Response 47 | } 48 | 49 | // GetCommand retrieves a custom command referenced by cmdName. 50 | func (c *Client) GetCommand(ctx context.Context, cmdName string) (*GetCommandResponse, error) { 51 | if cmdName == "" { 52 | return nil, errors.New("command name is empty") 53 | } 54 | 55 | p := path.Join("commands", url.PathEscape(cmdName)) 56 | 57 | var resp GetCommandResponse 58 | err := c.makeRequest(ctx, http.MethodGet, p, nil, nil, &resp) 59 | return &resp, err 60 | } 61 | 62 | // DeleteCommand deletes a custom command referenced by cmdName. 63 | func (c *Client) DeleteCommand(ctx context.Context, cmdName string) (*Response, error) { 64 | if cmdName == "" { 65 | return nil, errors.New("command name is empty") 66 | } 67 | 68 | p := path.Join("commands", url.PathEscape(cmdName)) 69 | 70 | var resp Response 71 | err := c.makeRequest(ctx, http.MethodDelete, p, nil, nil, &resp) 72 | return &resp, err 73 | } 74 | 75 | // CommandsResponse represents an API response containing a list of Command. 76 | type CommandsResponse struct { 77 | Commands []*Command 78 | } 79 | 80 | // ListCommands returns a list of custom commands. 81 | func (c *Client) ListCommands(ctx context.Context) (*CommandsResponse, error) { 82 | var resp CommandsResponse 83 | err := c.makeRequest(ctx, http.MethodGet, "commands", nil, nil, &resp) 84 | return &resp, err 85 | } 86 | 87 | // UpdateCommand updates a custom command referenced by cmdName. 88 | func (c *Client) UpdateCommand(ctx context.Context, cmdName string, update *Command) (*CommandResponse, error) { 89 | switch { 90 | case cmdName == "": 91 | return nil, errors.New("command name is empty") 92 | case update == nil: 93 | return nil, errors.New("update should not be nil") 94 | } 95 | 96 | p := path.Join("commands", url.PathEscape(cmdName)) 97 | 98 | var resp CommandResponse 99 | err := c.makeRequest(ctx, http.MethodPut, p, nil, update, &resp) 100 | return &resp, err 101 | } 102 | -------------------------------------------------------------------------------- /command_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func prepareCommand(t *testing.T, c *Client) *Command { 12 | t.Helper() 13 | 14 | cmd := &Command{ 15 | Name: randomString(10), 16 | Description: "test command", 17 | } 18 | ctx := context.Background() 19 | 20 | resp, err := c.CreateCommand(ctx, cmd) 21 | require.NoError(t, err, "create command") 22 | 23 | t.Cleanup(func() { 24 | _, _ = c.DeleteCommand(ctx, cmd.Name) 25 | }) 26 | 27 | return resp.Command 28 | } 29 | 30 | func TestClient_GetCommand(t *testing.T) { 31 | c := initClient(t) 32 | cmd := prepareCommand(t, c) 33 | ctx := context.Background() 34 | 35 | resp, err := c.GetCommand(ctx, cmd.Name) 36 | require.NoError(t, err, "get command") 37 | 38 | assert.Equal(t, cmd.Name, resp.Command.Name) 39 | assert.Equal(t, cmd.Description, resp.Command.Description) 40 | } 41 | 42 | func TestClient_ListCommands(t *testing.T) { 43 | c := initClient(t) 44 | cmd := prepareCommand(t, c) 45 | ctx := context.Background() 46 | 47 | resp, err := c.ListCommands(ctx) 48 | require.NoError(t, err, "list commands") 49 | 50 | assert.Contains(t, resp.Commands, cmd) 51 | } 52 | 53 | func TestClient_UpdateCommand(t *testing.T) { 54 | c := initClient(t) 55 | cmd := prepareCommand(t, c) 56 | ctx := context.Background() 57 | 58 | update := Command{Description: "new description"} 59 | resp, err := c.UpdateCommand(ctx, cmd.Name, &update) 60 | require.NoError(t, err, "update command") 61 | 62 | assert.Equal(t, cmd.Name, resp.Command.Name) 63 | assert.Equal(t, "new description", resp.Command.Description) 64 | } 65 | 66 | // See https://getstream.io/chat/docs/custom_commands/ for more details. 67 | func ExampleClient_CreateCommand() { 68 | client := &Client{} 69 | ctx := context.Background() 70 | 71 | newCommand := &Command{ 72 | Name: "my-command", 73 | Description: "my command", 74 | Args: "[@username]", 75 | Set: "custom_cmd_set", 76 | } 77 | 78 | _, _ = client.CreateCommand(ctx, newCommand) 79 | } 80 | 81 | func ExampleClient_ListCommands() { 82 | client := &Client{} 83 | ctx := context.Background() 84 | _, _ = client.ListCommands(ctx) 85 | } 86 | 87 | func ExampleClient_GetCommand() { 88 | client := &Client{} 89 | ctx := context.Background() 90 | _, _ = client.GetCommand(ctx, "my-command") 91 | } 92 | 93 | func ExampleClient_UpdateCommand() { 94 | client := &Client{} 95 | ctx := context.Background() 96 | 97 | update := Command{Description: "updated description"} 98 | _, _ = client.UpdateCommand(ctx, "my-command", &update) 99 | } 100 | 101 | func ExampleClient_DeleteCommand() { 102 | client := &Client{} 103 | ctx := context.Background() 104 | 105 | _, _ = client.DeleteCommand(ctx, "my-command") 106 | } 107 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | type PagerRequest struct { 4 | Limit *int `json:"limit" validate:"omitempty,gte=0,lte=100"` 5 | Next *string `json:"next"` 6 | Prev *string `json:"prev"` 7 | } 8 | type SortParamRequestList []*SortParamRequest 9 | 10 | type SortParamRequest struct { 11 | // Name of field to sort by 12 | Field string `json:"field"` 13 | 14 | // Direction is the sorting direction, 1 for Ascending, -1 for Descending, default is 1 15 | Direction int `json:"direction"` 16 | } 17 | 18 | type PagerResponse struct { 19 | Next *string `json:"next,omitempty"` 20 | Prev *string `json:"prev,omitempty"` 21 | } 22 | -------------------------------------------------------------------------------- /device.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | const ( 11 | PushProviderAPNS = PushProviderType("apn") 12 | PushProviderFirebase = PushProviderType("firebase") 13 | PushProviderXiaomi = PushProviderType("xiaomi") 14 | PushProviderHuawei = PushProviderType("huawei") 15 | ) 16 | 17 | type PushProviderType = string 18 | 19 | type Device struct { 20 | ID string `json:"id"` // The device ID. 21 | UserID string `json:"user_id"` // The user ID for this device. 22 | PushProvider PushProviderType `json:"push_provider"` // The push provider for this device. One of constants PushProvider* 23 | PushProviderName string `json:"push_provider_name"` // The push provider name for this device. 24 | } 25 | 26 | type DevicesResponse struct { 27 | Devices []*Device `json:"devices"` 28 | Response 29 | } 30 | 31 | // GetDevices retrieves the list of devices for user. 32 | func (c *Client) GetDevices(ctx context.Context, userID string) (*DevicesResponse, error) { 33 | if userID == "" { 34 | return nil, errors.New("user ID is empty") 35 | } 36 | 37 | params := url.Values{} 38 | params.Set("user_id", userID) 39 | 40 | var resp DevicesResponse 41 | 42 | err := c.makeRequest(ctx, http.MethodGet, "devices", params, nil, &resp) 43 | return &resp, err 44 | } 45 | 46 | // AddDevice adds new device. 47 | func (c *Client) AddDevice(ctx context.Context, device *Device) (*Response, error) { 48 | switch { 49 | case device == nil: 50 | return nil, errors.New("device is nil") 51 | case device.ID == "": 52 | return nil, errors.New("device ID is empty") 53 | case device.UserID == "": 54 | return nil, errors.New("device user ID is empty") 55 | case device.PushProvider == "": 56 | return nil, errors.New("device push provider is empty") 57 | } 58 | 59 | var resp Response 60 | err := c.makeRequest(ctx, http.MethodPost, "devices", nil, device, &resp) 61 | return &resp, err 62 | } 63 | 64 | // DeleteDevice deletes a device from the user. 65 | func (c *Client) DeleteDevice(ctx context.Context, userID, deviceID string) (*Response, error) { 66 | switch { 67 | case userID == "": 68 | return nil, errors.New("user ID is empty") 69 | case deviceID == "": 70 | return nil, errors.New("device ID is empty") 71 | } 72 | 73 | params := url.Values{} 74 | params.Set("id", deviceID) 75 | params.Set("user_id", userID) 76 | 77 | var resp Response 78 | err := c.makeRequest(ctx, http.MethodDelete, "devices", params, nil, &resp) 79 | return &resp, err 80 | } 81 | -------------------------------------------------------------------------------- /device_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestClient_Devices(t *testing.T) { 12 | c := initClient(t) 13 | ctx := context.Background() 14 | user := randomUser(t, c) 15 | 16 | devices := []*Device{ 17 | {UserID: user.ID, ID: randomString(12), PushProvider: PushProviderFirebase}, 18 | {UserID: user.ID, ID: randomString(12), PushProvider: PushProviderAPNS}, 19 | } 20 | 21 | for _, dev := range devices { 22 | _, err := c.AddDevice(ctx, dev) 23 | require.NoError(t, err, "add device") 24 | 25 | resp, err := c.GetDevices(ctx, user.ID) 26 | require.NoError(t, err, "get devices") 27 | 28 | assert.True(t, deviceIDExists(resp.Devices, dev.ID), "device with ID %s was created", dev.ID) 29 | _, err = c.DeleteDevice(ctx, user.ID, dev.ID) 30 | require.NoError(t, err, "delete device") 31 | } 32 | } 33 | 34 | func deviceIDExists(dev []*Device, id string) bool { 35 | for _, d := range dev { 36 | if d.ID == id { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | 43 | func ExampleClient_AddDevice() { 44 | client, _ := NewClient("XXXX", "XXXX") 45 | ctx := context.Background() 46 | 47 | _, _ = client.AddDevice(ctx, &Device{ 48 | ID: "2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207", 49 | UserID: "elon", 50 | PushProvider: PushProviderAPNS, 51 | }) 52 | } 53 | 54 | func ExampleClient_DeleteDevice() { 55 | client, _ := NewClient("XXXX", "XXXX") 56 | ctx := context.Background() 57 | 58 | deviceID := "2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207" 59 | userID := "elon" 60 | _, _ = client.DeleteDevice(ctx, userID, deviceID) 61 | } 62 | -------------------------------------------------------------------------------- /draft_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestChannel_CreateDraft(t *testing.T) { 12 | c := initClient(t) 13 | ctx := context.Background() 14 | 15 | // Create a channel 16 | channel := initChannel(t, c) 17 | user := randomUser(t, c) 18 | 19 | // Create a draft message 20 | message := &messageRequestMessage{ 21 | Text: "This is a draft message", 22 | } 23 | 24 | resp, err := channel.CreateDraft(ctx, user.ID, message) 25 | require.NoError(t, err) 26 | assert.Equal(t, "This is a draft message", resp.Draft.Message.Text) 27 | assert.Equal(t, channel.CID, resp.Draft.ChannelCID) 28 | assert.NotNil(t, resp.Draft.Channel) 29 | assert.Equal(t, channel.CID, resp.Draft.Channel.CID) 30 | } 31 | 32 | func TestChannel_GetDraft(t *testing.T) { 33 | c := initClient(t) 34 | ctx := context.Background() 35 | 36 | // Create a channel 37 | channel := initChannel(t, c) 38 | user := randomUser(t, c) 39 | 40 | // Create a draft message 41 | message := &messageRequestMessage{ 42 | Text: "This is a draft message", 43 | } 44 | 45 | createResp, err := channel.CreateDraft(ctx, user.ID, message) 46 | require.NoError(t, err) 47 | 48 | // Get the draft 49 | resp, err := channel.GetDraft(ctx, nil, user.ID) 50 | require.NoError(t, err) 51 | assert.Equal(t, createResp.Draft.Message.ID, resp.Draft.Message.ID) 52 | assert.Equal(t, "This is a draft message", resp.Draft.Message.Text) 53 | } 54 | 55 | func TestChannel_DeleteDraft(t *testing.T) { 56 | c := initClient(t) 57 | ctx := context.Background() 58 | 59 | // Create a channel 60 | channel := initChannel(t, c) 61 | user := randomUser(t, c) 62 | 63 | // Create a draft message 64 | message := &messageRequestMessage{ 65 | Text: "This is a draft message", 66 | UserID: user.ID, 67 | } 68 | 69 | _, err := channel.CreateDraft(ctx, user.ID, message) 70 | require.NoError(t, err) 71 | 72 | // Delete the draft 73 | resp, err := channel.DeleteDraft(ctx, user.ID, nil) 74 | require.NoError(t, err) 75 | // Just verify the response is received, not specific fields 76 | assert.NotNil(t, resp) 77 | 78 | // Verify the draft is deleted 79 | _, err = channel.GetDraft(ctx, nil, user.ID) 80 | require.Error(t, err) 81 | } 82 | 83 | func TestChannel_CreateDraftInThread(t *testing.T) { 84 | c := initClient(t) 85 | ctx := context.Background() 86 | 87 | // Create a channel 88 | channel := initChannel(t, c) 89 | 90 | // Create a parent message 91 | userID := randomUser(t, c).ID 92 | parentMsg, err := channel.SendMessage(ctx, &Message{ 93 | Text: "Parent message", 94 | User: &User{ID: userID}, 95 | }, userID) 96 | require.NoError(t, err) 97 | 98 | // Create a draft message in thread 99 | parentID := parentMsg.Message.ID 100 | message := &messageRequestMessage{ 101 | Text: "This is a draft reply", 102 | ParentID: parentID, 103 | } 104 | 105 | resp, err := channel.CreateDraft(ctx, userID, message) 106 | require.NoError(t, err) 107 | assert.Equal(t, "This is a draft reply", resp.Draft.Message.Text) 108 | assert.Equal(t, parentID, *resp.Draft.Message.ParentID) 109 | 110 | // Get the draft in thread 111 | getResp, err := channel.GetDraft(ctx, &parentID, userID) 112 | require.NoError(t, err) 113 | assert.Equal(t, resp.Draft.Message.ID, getResp.Draft.Message.ID) 114 | assert.Equal(t, parentID, *getResp.Draft.Message.ParentID) 115 | } 116 | 117 | func TestClient_QueryDrafts(t *testing.T) { 118 | c := initClient(t) 119 | ctx := context.Background() 120 | 121 | // Create a channel 122 | channel := initChannel(t, c) 123 | user := randomUser(t, c) 124 | 125 | // Create a draft message 126 | message1 := &messageRequestMessage{ 127 | Text: "Draft 1", 128 | } 129 | _, err := channel.CreateDraft(ctx, user.ID, message1) 130 | require.NoError(t, err) 131 | 132 | // Create a second channel 133 | channel2 := initChannel(t, c) 134 | 135 | // Create a draft in the second channel 136 | message2 := &messageRequestMessage{ 137 | Text: "Draft 2", 138 | } 139 | _, err = channel2.CreateDraft(ctx, user.ID, message2) 140 | require.NoError(t, err) 141 | 142 | // Query all drafts 143 | resp, err := c.QueryDrafts(ctx, &QueryDraftsOptions{UserID: user.ID, Limit: 10}) 144 | require.NoError(t, err) 145 | 146 | // Verify we have at least 2 drafts 147 | assert.GreaterOrEqual(t, len(resp.Drafts), 2) 148 | 149 | // Check if we can find our drafts 150 | foundDraft1 := false 151 | foundDraft2 := false 152 | for _, draft := range resp.Drafts { 153 | if draft.Message.Text == "Draft 1" { 154 | foundDraft1 = true 155 | } else if draft.Message.Text == "Draft 2" { 156 | foundDraft2 = true 157 | } 158 | } 159 | assert.True(t, foundDraft1, "First draft not found") 160 | assert.True(t, foundDraft2, "Second draft not found") 161 | } 162 | 163 | func TestClient_QueryDraftsWithFilters(t *testing.T) { 164 | c := initClient(t) 165 | ctx := context.Background() 166 | 167 | // Create a channel 168 | user := randomUser(t, c) 169 | channel1 := initChannel(t, c, user.ID) 170 | 171 | // Create a draft message 172 | draft1 := &messageRequestMessage{ 173 | Text: "Draft in channel 1", 174 | } 175 | _, err := channel1.CreateDraft(ctx, user.ID, draft1) 176 | require.NoError(t, err) 177 | 178 | // Create a second channel 179 | channel2 := initChannel(t, c, user.ID) 180 | 181 | // Create a draft in the second channel 182 | draft2 := &messageRequestMessage{ 183 | Text: "Draft in channel 2", 184 | } 185 | _, err = channel2.CreateDraft(ctx, user.ID, draft2) 186 | require.NoError(t, err) 187 | 188 | // Query all drafts for the user 189 | resp, err := c.QueryDrafts(ctx, &QueryDraftsOptions{UserID: user.ID}) 190 | require.NoError(t, err) 191 | assert.Equal(t, 2, len(resp.Drafts)) 192 | 193 | // Query drafts for a specific channel 194 | resp, err = c.QueryDrafts(ctx, &QueryDraftsOptions{ 195 | UserID: user.ID, 196 | Filter: map[string]interface{}{ 197 | "channel_cid": channel2.CID, 198 | }, 199 | }) 200 | require.NoError(t, err) 201 | assert.Equal(t, 1, len(resp.Drafts)) 202 | assert.Equal(t, channel2.CID, resp.Drafts[0].ChannelCID) 203 | assert.Equal(t, "Draft in channel 2", resp.Drafts[0].Message.Text) 204 | 205 | // Query drafts with sort 206 | resp, err = c.QueryDrafts(ctx, &QueryDraftsOptions{ 207 | UserID: user.ID, 208 | Sort: []*SortOption{ 209 | {Field: "created_at", Direction: 1}, 210 | }, 211 | }) 212 | require.NoError(t, err) 213 | assert.Equal(t, 2, len(resp.Drafts)) 214 | assert.Equal(t, channel1.CID, resp.Drafts[0].ChannelCID) 215 | assert.Equal(t, channel2.CID, resp.Drafts[1].ChannelCID) 216 | 217 | // Query drafts with pagination 218 | resp, err = c.QueryDrafts(ctx, &QueryDraftsOptions{ 219 | UserID: user.ID, 220 | Limit: 1, 221 | }) 222 | require.NoError(t, err) 223 | assert.Equal(t, 1, len(resp.Drafts)) 224 | assert.Equal(t, channel2.CID, resp.Drafts[0].ChannelCID) 225 | assert.NotNil(t, resp.Next) 226 | 227 | // Query drafts with pagination using next token 228 | resp, err = c.QueryDrafts(ctx, &QueryDraftsOptions{ 229 | UserID: user.ID, 230 | Limit: 1, 231 | Next: *resp.Next, 232 | }) 233 | require.NoError(t, err) 234 | assert.Equal(t, 1, len(resp.Drafts)) 235 | assert.Equal(t, channel1.CID, resp.Drafts[0].ChannelCID) 236 | } 237 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "time" 11 | ) 12 | 13 | // EventType marks which of the various sub-types of a webhook event you are 14 | // receiving or sending. 15 | type EventType string 16 | 17 | const ( 18 | // EventMessageNew is fired when a new message is added. 19 | EventMessageNew EventType = "message.new" 20 | // EventMessageUpdated is fired when a message is updated. 21 | EventMessageUpdated EventType = "message.updated" 22 | // EventMessageDeleted is fired when a message is deleted. 23 | EventMessageDeleted EventType = "message.deleted" 24 | // EventMessageRead is fired when a user calls mark as read. 25 | EventMessageRead EventType = "message.read" 26 | 27 | // EventReactionNew is fired when a message reaction is added. 28 | EventReactionNew EventType = "reaction.new" 29 | // EventReactionDeleted is fired when a message reaction deleted. 30 | EventReactionDeleted EventType = "reaction.deleted" 31 | 32 | // EventMemberAdded is fired when a member is added to a channel. 33 | EventMemberAdded EventType = "member.added" 34 | // EventMemberUpdated is fired when a member is updated. 35 | EventMemberUpdated EventType = "member.updated" 36 | // EventMemberRemoved is fired when a member is removed from a channel. 37 | EventMemberRemoved EventType = "member.removed" 38 | 39 | // EventChannelCreated is fired when a channel is created. 40 | EventChannelCreated EventType = "channel.created" 41 | // EventChannelUpdated is fired when a channel is updated. 42 | EventChannelUpdated EventType = "channel.updated" 43 | // EventChannelDeleted is fired when a channel is deleted. 44 | EventChannelDeleted EventType = "channel.deleted" 45 | // EventChannelTruncated is fired when a channel is truncated. 46 | EventChannelTruncated EventType = "channel.truncated" 47 | 48 | // EventHealthCheck is fired when a user is updated. 49 | EventHealthCheck EventType = "health.check" 50 | 51 | // EventNotificationNewMessage and family are fired when a notification is 52 | // created, marked read, invited to a channel, and so on. 53 | EventNotificationNewMessage EventType = "notification.message_new" 54 | EventNotificationMarkRead EventType = "notification.mark_read" 55 | EventNotificationInvited EventType = "notification.invited" 56 | EventNotificationInviteAccepted EventType = "notification.invite_accepted" 57 | EventNotificationAddedToChannel EventType = "notification.added_to_channel" 58 | EventNotificationRemovedFromChannel EventType = "notification.removed_from_channel" 59 | EventNotificationMutesUpdated EventType = "notification.mutes_updated" 60 | 61 | // EventTypingStart and EventTypingStop are fired when a user starts or stops typing. 62 | EventTypingStart EventType = "typing.start" 63 | EventTypingStop EventType = "typing.stop" 64 | 65 | // EventUserMuted is fired when a user is muted. 66 | EventUserMuted EventType = "user.muted" 67 | // EventUserUnmuted is fired when a user is unmuted. 68 | EventUserUnmuted EventType = "user.unmuted" 69 | EventUserPresenceChanged EventType = "user.presence.changed" 70 | EventUserWatchingStart EventType = "user.watching.start" 71 | EventUserWatchingStop EventType = "user.watching.stop" 72 | EventUserUpdated EventType = "user.updated" 73 | 74 | EventUserUnreadMessageReminder EventType = "user.unread_message_reminder" 75 | ) 76 | 77 | // Event is received from a webhook, or sent with the SendEvent function. 78 | type Event struct { 79 | CID string `json:"cid,omitempty"` // Channel ID 80 | Type EventType `json:"type"` // Event type, one of Event* constants 81 | Message *Message `json:"message,omitempty"` 82 | Reaction *Reaction `json:"reaction,omitempty"` 83 | Channel *Channel `json:"channel,omitempty"` 84 | Member *ChannelMember `json:"member,omitempty"` 85 | Members []*ChannelMember `json:"members,omitempty"` 86 | User *User `json:"user,omitempty"` 87 | UserID string `json:"user_id,omitempty"` 88 | OwnUser *User `json:"me,omitempty"` 89 | WatcherCount int `json:"watcher_count,omitempty"` 90 | 91 | ExtraData map[string]interface{} `json:"-"` 92 | 93 | CreatedAt time.Time `json:"created_at,omitempty"` 94 | } 95 | 96 | type eventForJSON Event 97 | 98 | func (e *Event) UnmarshalJSON(data []byte) error { 99 | var e2 eventForJSON 100 | if err := json.Unmarshal(data, &e2); err != nil { 101 | return err 102 | } 103 | *e = Event(e2) 104 | 105 | if err := json.Unmarshal(data, &e.ExtraData); err != nil { 106 | return err 107 | } 108 | 109 | removeFromMap(e.ExtraData, *e) 110 | return nil 111 | } 112 | 113 | func (e Event) MarshalJSON() ([]byte, error) { 114 | return addToMapAndMarshal(e.ExtraData, eventForJSON(e)) 115 | } 116 | 117 | // SendEvent sends an event on this channel. 118 | func (ch *Channel) SendEvent(ctx context.Context, event *Event, userID string) (*Response, error) { 119 | if event == nil { 120 | return nil, errors.New("event is nil") 121 | } 122 | 123 | event.User = &User{ID: userID} 124 | 125 | req := struct { 126 | Event *Event `json:"event"` 127 | }{ 128 | Event: event, 129 | } 130 | 131 | p := path.Join("channels", url.PathEscape(ch.Type), url.PathEscape(ch.ID), "event") 132 | 133 | var resp Response 134 | err := ch.client.makeRequest(ctx, http.MethodPost, p, nil, req, &resp) 135 | return &resp, err 136 | } 137 | 138 | // UserCustomEvent is a custom event sent to a particular user. 139 | type UserCustomEvent struct { 140 | // Type should be a custom type. Using a built-in event is not supported here. 141 | Type string `json:"type"` 142 | 143 | ExtraData map[string]interface{} `json:"-"` 144 | 145 | CreatedAt time.Time `json:"created_at,omitempty"` 146 | } 147 | 148 | type userCustomEventForJSON UserCustomEvent 149 | 150 | func (e *UserCustomEvent) UnmarshalJSON(data []byte) error { 151 | // TODO: merge this method with Event.UnmarshalJSON 152 | var e2 userCustomEventForJSON 153 | if err := json.Unmarshal(data, &e2); err != nil { 154 | return err 155 | } 156 | *e = UserCustomEvent(e2) 157 | 158 | if err := json.Unmarshal(data, &e.ExtraData); err != nil { 159 | return err 160 | } 161 | 162 | removeFromMap(e.ExtraData, *e) 163 | return nil 164 | } 165 | 166 | func (e UserCustomEvent) MarshalJSON() ([]byte, error) { 167 | return addToMapAndMarshal(e.ExtraData, userCustomEventForJSON(e)) 168 | } 169 | 170 | // SendUserCustomEvent sends a custom event to all connected clients for the target user id. 171 | func (c *Client) SendUserCustomEvent(ctx context.Context, targetUserID string, event *UserCustomEvent) (*Response, error) { 172 | if event == nil { 173 | return nil, errors.New("event is nil") 174 | } 175 | if targetUserID == "" { 176 | return nil, errors.New("targetUserID should not be empty") 177 | } 178 | 179 | req := struct { 180 | Event *UserCustomEvent `json:"event"` 181 | }{ 182 | Event: event, 183 | } 184 | 185 | p := path.Join("users", url.PathEscape(targetUserID), "event") 186 | 187 | var resp Response 188 | err := c.makeRequest(ctx, http.MethodPost, p, nil, req, &resp) 189 | return &resp, err 190 | } 191 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // TestEventSupportsAllFields that we can decode all of the keys in the 14 | // examples. We do this via the DisallowUnknownFields flag. 15 | func TestEventSupportsAllFields(t *testing.T) { 16 | // Tests are taken from https://getstream.io/chat/docs/webhook_events/ and 17 | // compressed with `jq -c .` 18 | events := map[EventType]string{ 19 | "message.new": `{"cid":"messaging:fun","type":"message.new","message":{"id":"fff0d7c0-60bd-4835-833b-3843007817bf","text":"8b780762-4830-4e2a-aa43-18aabaf1732d","html":"

8b780762-4830-4e2a-aa43-18aabaf1732d

\n","type":"regular","user":{"id":"97b49906-0b98-463b-aa47-0aa945677eb2","role":"user","created_at":"2019-04-24T08:48:38.440123Z","updated_at":"2019-04-24T08:48:38.440708Z","online":false},"attachments":[],"latest_reactions":[],"own_reactions":[],"reaction_counts":null,"reply_count":0,"created_at":"2019-04-24T08:48:39.918761Z","updated_at":"2019-04-24T08:48:39.918761Z","mentioned_users":[]},"user":{"id":"97b49906-0b98-463b-aa47-0aa945677eb2","role":"user","created_at":"2019-04-24T08:48:38.440123Z","updated_at":"2019-04-24T08:48:38.440708Z","online":false,"channel_unread_count":1,"channel_last_read_at":"2019-04-24T08:48:39.900585Z","total_unread_count":1,"unread_channels":1,"unread_count":1},"created_at":"2019-04-24T08:48:38.949986Z","members":[{"user_id":"97b49906-0b98-463b-aa47-0aa945677eb2","user":{"id":"97b49906-0b98-463b-aa47-0aa945677eb2","role":"user","created_at":"2019-04-24T08:48:38.440123Z","updated_at":"2019-04-24T08:48:38.440708Z","online":false,"channel_unread_count":1,"channel_last_read_at":"2019-04-24T08:48:39.900585Z","total_unread_count":1,"unread_channels":1,"unread_count":1},"created_at":"2019-04-24T08:48:39.652296Z","updated_at":"2019-04-24T08:48:39.652296Z"}]}`, 20 | "message.read": `{"cid":"messaging:fun","type":"message.read","user":{"id":"a6e21b36-798b-408a-9cd1-0cf6c372fc7f","role":"user","created_at":"2019-04-24T08:49:58.170034Z","updated_at":"2019-04-24T08:49:59.345304Z","last_active":"2019-04-24T08:49:59.344201Z","online":true,"total_unread_count":0,"unread_channels":0,"unread_count":0,"channel_unread_count":0,"channel_last_read_at":"2019-04-24T08:49:59.365498Z"},"created_at":"2019-04-24T08:49:59.365489Z"}`, 21 | "message.updated": `{"cid":"messaging:fun","type":"message.updated","message":{"id":"93163f53-4174-4be8-90cd-e59bef78da00","text":"new stuff","html":"

new stuff

\n","type":"regular","user":{"id":"75af03a7-fe83-4a2a-a447-9ed4fac2ea36","role":"user","created_at":"2019-04-24T08:51:26.846395Z","updated_at":"2019-04-24T08:51:27.973941Z","last_active":"2019-04-24T08:51:27.972713Z","online":false},"attachments":[],"latest_reactions":[],"own_reactions":[],"reaction_counts":null,"reply_count":0,"created_at":"2019-04-24T08:51:28.005691Z","updated_at":"2019-04-24T08:51:28.138422Z","mentioned_users":[]},"user":{"id":"75af03a7-fe83-4a2a-a447-9ed4fac2ea36","role":"user","created_at":"2019-04-24T08:51:26.846395Z","updated_at":"2019-04-24T08:51:27.973941Z","last_active":"2019-04-24T08:51:27.972713Z","online":true,"channel_unread_count":1,"channel_last_read_at":"2019-04-24T08:51:27.994245Z","total_unread_count":2,"unread_channels":2,"unread_count":2},"created_at":"2019-04-24T10:51:28.142291+02:00"}`, 22 | "message.deleted": `{"cid":"messaging:fun","type":"message.deleted","message":{"id":"268d121f-82e0-4de1-8c8b-ef1201efd7a3","text":"new stuff","html":"

new stuff

\n","type":"regular","user":{"id":"76cd8430-2f91-4059-90e5-02dffb910297","role":"user","created_at":"2019-04-24T09:44:21.390868Z","updated_at":"2019-04-24T09:44:22.537305Z","last_active":"2019-04-24T09:44:22.535872Z","online":false},"attachments":[],"latest_reactions":[],"own_reactions":[],"reaction_counts":{},"reply_count":0,"created_at":"2019-04-24T09:44:22.57073Z","updated_at":"2019-04-24T09:44:22.717078Z","deleted_at":"2019-04-24T09:44:22.730524Z","mentioned_users":[]},"created_at":"2019-04-24T09:44:22.733305Z"}`, 23 | "reaction.new": `{"cid":"messaging:fun","type":"reaction.new","message":{"id":"4b3c7b6c-a39d-4069-9450-2a3716cf4ca6","text":"new stuff","html":"

new stuff

\n","type":"regular","user":{"id":"57fabaed-446a-40b4-a6ec-e0ac8cad57e3","role":"user","created_at":"2019-04-24T09:49:47.158005Z","updated_at":"2019-04-24T09:49:48.301933Z","last_active":"2019-04-24T09:49:48.300566Z","online":false},"attachments":[],"latest_reactions":[{"message_id":"4b3c7b6c-a39d-4069-9450-2a3716cf4ca6","user":{"id":"57fabaed-446a-40b4-a6ec-e0ac8cad57e3","role":"user","created_at":"2019-04-24T09:49:47.158005Z","updated_at":"2019-04-24T09:49:48.301933Z","last_active":"2019-04-24T09:49:48.300566Z","online":true},"type":"lol","created_at":"2019-04-24T09:49:48.481994Z"}],"own_reactions":[],"reaction_counts":{"lol":1},"reply_count":0,"created_at":"2019-04-24T09:49:48.334808Z","updated_at":"2019-04-24T09:49:48.483028Z","mentioned_users":[]},"reaction":{"message_id":"4b3c7b6c-a39d-4069-9450-2a3716cf4ca6","user":{"id":"57fabaed-446a-40b4-a6ec-e0ac8cad57e3","role":"user","created_at":"2019-04-24T09:49:47.158005Z","updated_at":"2019-04-24T09:49:48.301933Z","last_active":"2019-04-24T09:49:48.300566Z","online":true},"type":"lol","created_at":"2019-04-24T09:49:48.481994Z"},"user":{"id":"57fabaed-446a-40b4-a6ec-e0ac8cad57e3","role":"user","created_at":"2019-04-24T09:49:47.158005Z","updated_at":"2019-04-24T09:49:48.301933Z","last_active":"2019-04-24T09:49:48.300566Z","online":true,"unread_channels":2,"unread_count":2,"channel_unread_count":1,"channel_last_read_at":"2019-04-24T09:49:48.321138Z","total_unread_count":2},"created_at":"2019-04-24T09:49:48.488497Z"}`, 24 | "reaction.deleted": `{"cid":"messaging:fun","type":"reaction.deleted","message":{"id":"4b3c7b6c-a39d-4069-9450-2a3716cf4ca6","text":"new stuff","html":"

new stuff

\n","type":"regular","user":{"id":"57fabaed-446a-40b4-a6ec-e0ac8cad57e3","role":"user","created_at":"2019-04-24T09:49:47.158005Z","updated_at":"2019-04-24T09:49:48.301933Z","last_active":"2019-04-24T09:49:48.300566Z","online":false},"attachments":[],"latest_reactions":[],"own_reactions":[],"reaction_counts":{},"reply_count":0,"created_at":"2019-04-24T09:49:48.334808Z","updated_at":"2019-04-24T09:49:48.511631Z","mentioned_users":[]},"reaction":{"message_id":"4b3c7b6c-a39d-4069-9450-2a3716cf4ca6","user":{"id":"57fabaed-446a-40b4-a6ec-e0ac8cad57e3","role":"user","created_at":"2019-04-24T09:49:47.158005Z","updated_at":"2019-04-24T09:49:48.301933Z","last_active":"2019-04-24T11:49:48.497656+02:00","online":true},"type":"lol","created_at":"2019-04-24T09:49:48.481994Z"},"user":{"id":"57fabaed-446a-40b4-a6ec-e0ac8cad57e3","role":"user","created_at":"2019-04-24T09:49:47.158005Z","updated_at":"2019-04-24T09:49:48.301933Z","last_active":"2019-04-24T11:49:48.497656+02:00","online":true,"total_unread_count":2,"unread_channels":2,"unread_count":2,"channel_unread_count":1,"channel_last_read_at":"2019-04-24T09:49:48.321138Z"},"created_at":"2019-04-24T09:49:48.511082Z"}`, 25 | "member.added": `{"cid":"messaging:fun","type":"member.added","member":{"user_id":"d4d7b21a-78d4-4148-9830-eb2d3b99c1ec","user":{"id":"d4d7b21a-78d4-4148-9830-eb2d3b99c1ec","role":"user","created_at":"2019-04-24T09:49:47.149933Z","updated_at":"2019-04-24T09:49:47.151159Z","online":false},"created_at":"2019-04-24T09:49:48.534412Z","updated_at":"2019-04-24T09:49:48.534412Z"},"user":{"id":"d4d7b21a-78d4-4148-9830-eb2d3b99c1ec","role":"user","created_at":"2019-04-24T09:49:47.149933Z","updated_at":"2019-04-24T09:49:47.151159Z","online":false,"channel_last_read_at":"2019-04-24T09:49:48.537084Z","total_unread_count":0,"unread_channels":0,"unread_count":0,"channel_unread_count":0},"created_at":"2019-04-24T09:49:48.537082Z"}`, 26 | "member.updated": `{"cid":"messaging:fun","type":"member.updated","member":{"user_id":"d4d7b21a-78d4-4148-9830-eb2d3b99c1ec","user":{"id":"d4d7b21a-78d4-4148-9830-eb2d3b99c1ec","role":"user","created_at":"2019-04-24T09:49:47.149933Z","updated_at":"2019-04-24T09:49:47.151159Z","online":false},"is_moderator":true,"created_at":"2019-04-24T09:49:48.534412Z","updated_at":"2019-04-24T09:49:48.547034Z"},"user":{"id":"d4d7b21a-78d4-4148-9830-eb2d3b99c1ec","role":"user","created_at":"2019-04-24T09:49:47.149933Z","updated_at":"2019-04-24T09:49:47.151159Z","online":false,"total_unread_count":0,"unread_channels":0,"unread_count":0,"channel_unread_count":0,"channel_last_read_at":"2019-04-24T09:49:48.549211Z"},"created_at":"2019-04-24T09:49:48.54921Z"}`, 27 | "member.removed": `{"cid":"messaging:fun","type":"member.removed","user":{"id":"6585dbbb-3d46-4943-9b14-a645aca11df4","role":"user","created_at":"2019-03-22T14:22:04.581208Z","online":false},"created_at":"2019-03-22T14:22:07.040496Z"}`, 28 | "channel.updated": `{"cid":"messaging:fun","type":"channel.updated","channel":{"cid":"messaging:fun","id":"fun","type":"messaging","last_message_at":"2019-04-24T09:49:48.576202Z","created_by":{"id":"57fabaed-446a-40b4-a6ec-e0ac8cad57e3","role":"user","created_at":"2019-04-24T09:49:47.158005Z","updated_at":"2019-04-24T09:49:48.301933Z","last_active":"2019-04-24T09:49:48.497656Z","online":true},"created_at":"2019-04-24T09:49:48.180908Z","updated_at":"2019-04-24T09:49:48.180908Z","frozen":false,"config":{"created_at":"2016-08-18T16:42:30.586808Z","updated_at":"2016-08-18T16:42:30.586808Z","name":"messaging","typing_events":true,"read_events":true,"connect_events":true,"search":true,"reactions":true,"replies":true,"mutes":true,"message_retention":"infinite","max_message_length":5000,"automod":"disabled","commands":["giphy","flag","ban","unban","mute","unmute"]},"awesome":"yes"},"created_at":"2019-04-24T09:49:48.594316Z"}`, 29 | "channel.deleted": `{"cid":"messaging:fun","type":"channel.deleted","channel":{"cid":"messaging:fun","id":"fun","type":"messaging","created_at":"2019-04-24T09:49:48.180908Z","updated_at":"2019-04-24T09:49:48.180908Z","deleted_at":"2019-04-24T09:49:48.626704Z","frozen":false,"config":{"created_at":"2016-08-18T18:42:30.586808+02:00","updated_at":"2016-08-18T18:42:30.586808+02:00","name":"messaging","typing_events":true,"read_events":true,"connect_events":true,"search":true,"reactions":true,"replies":true,"mutes":true,"message_retention":"infinite","max_message_length":5000,"automod":"disabled","commands":["giphy","flag","ban","unban","mute","unmute"]}},"created_at":"2019-04-24T09:49:48.630913Z"}`, 30 | "user.updated": `{"type":"user.updated","user":{"id":"thierry-7b690297-98fa-42dd-b999-a75dd4c7c993","role":"user","online":false,"awesome":true},"created_at":"2019-04-24T12:54:58.956621Z","members":[]}`, 31 | } 32 | 33 | for name, blob := range events { 34 | dec := json.NewDecoder(bytes.NewBufferString(blob)) 35 | dec.DisallowUnknownFields() 36 | 37 | result := Event{} 38 | if err := dec.Decode(&result); err != nil { 39 | t.Errorf("Error unmarshaling %q: %v", name, err) 40 | } 41 | 42 | assert.Equal(t, name, result.Type) 43 | } 44 | } 45 | 46 | func TestSendUserCustomEvent(t *testing.T) { 47 | c := initClient(t) 48 | ctx := context.Background() 49 | 50 | tests := []struct { 51 | name string 52 | event *UserCustomEvent 53 | targetUserID string 54 | expectedErr string 55 | }{ 56 | { 57 | name: "ok", 58 | event: &UserCustomEvent{ 59 | Type: "custom_event", 60 | }, 61 | targetUserID: "user1", 62 | }, 63 | { 64 | name: "error: event is nil", 65 | event: nil, 66 | targetUserID: "user1", 67 | expectedErr: "event is nil", 68 | }, 69 | { 70 | name: "error: empty targetUserID", 71 | event: &UserCustomEvent{}, 72 | targetUserID: "", 73 | expectedErr: "targetUserID should not be empty", 74 | }, 75 | } 76 | 77 | for _, test := range tests { 78 | t.Run(test.name, func(t *testing.T) { 79 | if test.expectedErr == "" { 80 | _, err := c.UpsertUser(ctx, &User{ID: test.targetUserID}) 81 | require.NoError(t, err) 82 | } 83 | 84 | _, err := c.SendUserCustomEvent(ctx, test.targetUserID, test.event) 85 | 86 | if test.expectedErr == "" { 87 | require.NoError(t, err) 88 | return 89 | } 90 | require.EqualError(t, err, test.expectedErr) 91 | }) 92 | } 93 | } 94 | 95 | func TestMarshalUnmarshalUserCustomEvent(t *testing.T) { 96 | ev1 := UserCustomEvent{ 97 | Type: "custom_event", 98 | ExtraData: map[string]interface{}{ 99 | "name": "John Doe", 100 | "age": 99.0, 101 | "hungry": true, 102 | "fruits": []interface{}{}, 103 | }, 104 | } 105 | 106 | b, err := json.Marshal(ev1) 107 | require.NoError(t, err) 108 | 109 | ev2 := UserCustomEvent{} 110 | err = json.Unmarshal(b, &ev2) 111 | require.NoError(t, err) 112 | 113 | require.Equal(t, ev1, ev2) 114 | } 115 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/GetStream/stream-chat-go/v7 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/golang-jwt/jwt/v4 v4.5.1 7 | github.com/stretchr/testify v1.7.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= 5 | github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 9 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 10 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 14 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | ) 13 | 14 | type Error struct { 15 | Code int `json:"code"` 16 | Message string `json:"message"` 17 | ExceptionFields map[string]string `json:"exception_fields,omitempty"` 18 | StatusCode int `json:"StatusCode"` 19 | Duration string `json:"duration"` 20 | MoreInfo string `json:"more_info"` 21 | 22 | RateLimit *RateLimitInfo `json:"-"` 23 | } 24 | 25 | func (e Error) Error() string { 26 | return e.Message 27 | } 28 | 29 | // Response is the base response returned to client. It contains rate limit information. 30 | // All specific response returned to the client should embed this type. 31 | type Response struct { 32 | RateLimitInfo *RateLimitInfo `json:"ratelimit"` 33 | } 34 | 35 | func (c *Client) parseResponse(resp *http.Response, result interface{}) error { 36 | if resp.Body == nil { 37 | return errors.New("http body is nil") 38 | } 39 | defer resp.Body.Close() 40 | 41 | b, err := io.ReadAll(resp.Body) 42 | if err != nil { 43 | return fmt.Errorf("failed to read HTTP response: %w", err) 44 | } 45 | 46 | if resp.StatusCode >= 399 { 47 | var apiErr Error 48 | err := json.Unmarshal(b, &apiErr) 49 | if err != nil { 50 | // IP rate limit errors sent by our Edge infrastructure are not JSON encoded. 51 | // If decode fails here, we need to handle this manually. 52 | apiErr.Message = string(b) 53 | apiErr.StatusCode = resp.StatusCode 54 | return apiErr 55 | } 56 | 57 | // Include rate limit information. 58 | apiErr.RateLimit = NewRateLimitFromHeaders(resp.Header) 59 | return apiErr 60 | } 61 | 62 | if _, ok := result.(*Response); !ok { 63 | // Unmarshal the body only when it is expected. 64 | err = json.Unmarshal(b, result) 65 | if err != nil { 66 | return fmt.Errorf("cannot unmarshal body: %w", err) 67 | } 68 | } 69 | 70 | return c.addRateLimitInfo(resp.Header, result) 71 | } 72 | 73 | func (c *Client) requestURL(path string, values url.Values) (string, error) { 74 | u, err := url.Parse(c.BaseURL + "/" + path) 75 | if err != nil { 76 | return "", errors.New("url.Parse: " + err.Error()) 77 | } 78 | 79 | if values == nil { 80 | values = make(url.Values) 81 | } 82 | 83 | values.Add("api_key", c.apiKey) 84 | 85 | u.RawQuery = values.Encode() 86 | 87 | return u.String(), nil 88 | } 89 | 90 | func (c *Client) newRequest(ctx context.Context, method, path string, params url.Values, data interface{}) (*http.Request, error) { 91 | u, err := c.requestURL(path, params) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | r, err := http.NewRequestWithContext(ctx, method, u, http.NoBody) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | c.setHeaders(r) 102 | switch t := data.(type) { 103 | case nil: 104 | r.Body = nil 105 | 106 | case io.ReadCloser: 107 | r.Body = t 108 | 109 | case io.Reader: 110 | r.Body = io.NopCloser(t) 111 | 112 | default: 113 | b, err := json.Marshal(data) 114 | if err != nil { 115 | return nil, err 116 | } 117 | r.Body = io.NopCloser(bytes.NewReader(b)) 118 | } 119 | 120 | return r, nil 121 | } 122 | 123 | func (c *Client) setHeaders(r *http.Request) { 124 | r.Header.Set("Content-Type", "application/json") 125 | r.Header.Set("X-Stream-Client", versionHeader()) 126 | r.Header.Set("Authorization", c.authToken) 127 | r.Header.Set("Stream-Auth-Type", "jwt") 128 | } 129 | 130 | func (c *Client) makeRequest(ctx context.Context, method, path string, params url.Values, data, result interface{}) error { 131 | r, err := c.newRequest(ctx, method, path, params, data) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | resp, err := c.HTTP.Do(r) 137 | if err != nil { 138 | select { 139 | case <-ctx.Done(): 140 | // If we got an error, and the context has been canceled, 141 | // return context's error which is more useful. 142 | return ctx.Err() 143 | default: 144 | } 145 | return err 146 | } 147 | 148 | return c.parseResponse(resp, result) 149 | } 150 | 151 | func (c *Client) addRateLimitInfo(headers http.Header, result interface{}) error { 152 | rl := map[string]interface{}{ 153 | "ratelimit": NewRateLimitFromHeaders(headers), 154 | } 155 | 156 | b, err := json.Marshal(rl) 157 | if err != nil { 158 | return fmt.Errorf("cannot marshal rate limit info: %w", err) 159 | } 160 | 161 | err = json.Unmarshal(b, result) 162 | if err != nil { 163 | return fmt.Errorf("cannot unmarshal rate limit info: %w", err) 164 | } 165 | return nil 166 | } 167 | -------------------------------------------------------------------------------- /http_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // TestRateLimit asserts that rate limit headers are correctly decoded into the error object. 14 | // We use DeleteUsers endpoint, it requires a very low number of requests (6/min). 15 | func TestRateLimit(t *testing.T) { 16 | c := initClient(t) 17 | ctx := context.Background() 18 | 19 | users := make([]*User, 0, 8) 20 | for i := 0; i < 8; i++ { 21 | users = append(users, randomUser(t, c)) 22 | } 23 | 24 | for _, u := range users { 25 | _, err := c.DeleteUsers(ctx, []string{u.ID}, DeleteUserOptions{ 26 | User: SoftDelete, 27 | Messages: HardDelete, 28 | }) 29 | if err != nil { 30 | var apiErr Error 31 | ok := errors.As(err, &apiErr) 32 | require.True(t, ok) 33 | require.Equal(t, http.StatusTooManyRequests, apiErr.StatusCode) 34 | require.NotZero(t, apiErr.RateLimit.Limit) 35 | require.NotZero(t, apiErr.RateLimit.Reset) 36 | require.EqualValues(t, 0, apiErr.RateLimit.Remaining) 37 | return 38 | } 39 | } 40 | } 41 | 42 | // TestContextExceeded asserts that the context error is correctly returned. 43 | func TestContextExceeded(t *testing.T) { 44 | c := initClient(t) 45 | user := randomUser(t, c) 46 | ctx := context.Background() 47 | 48 | ctx, cancel := context.WithTimeout(ctx, time.Millisecond) 49 | defer cancel() 50 | 51 | _, err := c.DeleteUsers(ctx, []string{user.ID}, DeleteUserOptions{ 52 | User: SoftDelete, 53 | Messages: HardDelete, 54 | }) 55 | require.Error(t, err) 56 | require.ErrorIs(t, err, context.DeadlineExceeded) 57 | } 58 | -------------------------------------------------------------------------------- /import.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | type ImportTaskHistory struct { 12 | CreatedAt time.Time `json:"created_at"` 13 | NextState string `json:"next_state"` 14 | PrevState string `json:"prev_state"` 15 | } 16 | 17 | type ImportMode string 18 | 19 | const ( 20 | InsertMode ImportMode = "insert" 21 | UpsertMode ImportMode = "upsert" 22 | ) 23 | 24 | type ImportTask struct { 25 | CreatedAt time.Time `json:"created_at"` 26 | Path string `json:"path"` 27 | Mode ImportMode `json:"mode"` 28 | History []*ImportTaskHistory `json:"history"` 29 | ID string `json:"id"` 30 | State string `json:"state"` 31 | UpdatedAt time.Time `json:"updated_at"` 32 | Result interface{} `json:"result"` 33 | Size *int `json:"size"` 34 | } 35 | 36 | type ListImportsOptions struct { 37 | Limit int 38 | Offset int 39 | } 40 | 41 | type CreateImportResponse struct { 42 | ImportTask *ImportTask `json:"import_task"` 43 | Response 44 | } 45 | 46 | type CreateImportURLResponse struct { 47 | Path string `json:"path"` 48 | UploadURL string `json:"upload_url"` 49 | Response 50 | } 51 | 52 | type GetImportResponse struct { 53 | ImportTask *ImportTask `json:"import_task"` 54 | Response 55 | } 56 | 57 | type ListImportsResponse struct { 58 | ImportTasks []*ImportTask `json:"import_tasks"` 59 | Response 60 | } 61 | 62 | // CreateImportURL creates a new import URL. 63 | // Note: Do not use this. 64 | // It is present for internal usage only. 65 | // This function can, and will, break and/or be removed at any point in time. 66 | func (c *Client) CreateImportURL(ctx context.Context, filename string) (*CreateImportURLResponse, error) { 67 | var resp CreateImportURLResponse 68 | err := c.makeRequest(ctx, http.MethodPost, "import_urls", nil, map[string]string{"filename": filename}, &resp) 69 | 70 | return &resp, err 71 | } 72 | 73 | // CreateImport creates a new import task. 74 | // Note: Do not use this. 75 | // It is present for internal usage only. 76 | // This function can, and will, break and/or be removed at any point in time. 77 | func (c *Client) CreateImport(ctx context.Context, filePath string, mode ImportMode) (*CreateImportResponse, error) { 78 | var resp CreateImportResponse 79 | err := c.makeRequest(ctx, http.MethodPost, "imports", nil, map[string]string{"path": filePath, "mode": string(mode)}, &resp) 80 | 81 | return &resp, err 82 | } 83 | 84 | // GetImport returns an import task. 85 | // Note: Do not use this. 86 | // It is present for internal usage only. 87 | // This function can, and will, break and/or be removed at any point in time. 88 | func (c *Client) GetImport(ctx context.Context, id string) (*GetImportResponse, error) { 89 | var resp GetImportResponse 90 | err := c.makeRequest(ctx, http.MethodGet, "imports/"+id, nil, nil, &resp) 91 | 92 | return &resp, err 93 | } 94 | 95 | // ListImports returns all import tasks. 96 | // Note: Do not use this. 97 | // It is present for internal usage only. 98 | // This function can, and will, break and/or be removed at any point in time. 99 | func (c *Client) ListImports(ctx context.Context, opts *ListImportsOptions) (*ListImportsResponse, error) { 100 | params := url.Values{} 101 | if opts != nil { 102 | params.Set("limit", strconv.Itoa(opts.Limit)) 103 | params.Set("offset", strconv.Itoa(opts.Offset)) 104 | } 105 | 106 | var resp ListImportsResponse 107 | err := c.makeRequest(ctx, http.MethodGet, "imports", params, nil, &resp) 108 | 109 | return &resp, err 110 | } 111 | -------------------------------------------------------------------------------- /import_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestImportsEndToEnd(t *testing.T) { 13 | t.Skip("The backend isn't deployed yet.") 14 | filename := randomString(10) + ".json" 15 | content := "[]" 16 | c := initClient(t) 17 | ctx := context.Background() 18 | 19 | createURLResp, err := c.CreateImportURL(ctx, filename) 20 | require.NoError(t, err) 21 | require.NotEmpty(t, createURLResp.Path) 22 | require.NotEmpty(t, createURLResp.UploadURL) 23 | 24 | _, err = c.CreateImport(ctx, createURLResp.Path, "upsert") 25 | require.Error(t, err) 26 | 27 | data := strings.NewReader(content) 28 | r, err := http.NewRequestWithContext(ctx, http.MethodPut, createURLResp.UploadURL, data) 29 | require.NoError(t, err) 30 | 31 | r.Header.Set("Content-Type", "application/json") 32 | r.ContentLength = data.Size() 33 | uploadResp, err := c.HTTP.Do(r) 34 | require.NoError(t, err) 35 | uploadResp.Body.Close() 36 | 37 | createResp, err := c.CreateImport(ctx, createURLResp.Path, "upsert") 38 | require.NoError(t, err) 39 | require.NotNil(t, createResp.ImportTask.ID) 40 | require.True(t, strings.HasSuffix(createResp.ImportTask.Path, filename)) 41 | 42 | getResp, err := c.GetImport(ctx, createResp.ImportTask.ID) 43 | require.NoError(t, err) 44 | require.Equal(t, createResp.ImportTask.ID, getResp.ImportTask.ID) 45 | 46 | listResp, err := c.ListImports(ctx, &ListImportsOptions{Limit: 1, Offset: 0}) 47 | require.NoError(t, err) 48 | require.NotEmpty(t, listResp.ImportTasks) 49 | } 50 | -------------------------------------------------------------------------------- /json.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | func copyMap(m map[string]interface{}) map[string]interface{} { 10 | m2 := make(map[string]interface{}, len(m)) 11 | for k, v := range m { 12 | m2[k] = v 13 | } 14 | return m2 15 | } 16 | 17 | func removeFromMap(m map[string]interface{}, obj interface{}) { 18 | t := reflect.TypeOf(obj) 19 | for i := 0; i < t.NumField(); i++ { 20 | f := t.Field(i) 21 | if tag := f.Tag.Get("json"); tag != "" { 22 | tag = strings.Split(tag, ",")[0] 23 | delete(m, tag) 24 | } else { 25 | delete(m, f.Name) 26 | } 27 | } 28 | } 29 | 30 | func addToMapAndMarshal(m map[string]interface{}, obj interface{}) ([]byte, error) { 31 | m2 := copyMap(m) 32 | 33 | data, err := json.Marshal(obj) 34 | if err != nil { 35 | return nil, err 36 | } 37 | if err := json.Unmarshal(data, &m2); err != nil { 38 | return nil, err 39 | } 40 | return json.Marshal(m2) 41 | } 42 | -------------------------------------------------------------------------------- /json_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "encoding/json" 5 | "math/rand" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func randomExtraData(in interface{}) { 13 | v := reflect.ValueOf(in).Elem() 14 | if v.Kind() != reflect.Struct { 15 | return 16 | } 17 | f := v.FieldByName("ExtraData") 18 | f.Set(reflect.ValueOf(map[string]interface{}{ 19 | "extra_data": map[string]interface{}{ 20 | "mystring": randomString(10), 21 | "mybool": rand.Float64() < 0.5, 22 | }, 23 | "data": "custom", 24 | "custom_data": "really_custom", 25 | "extra": map[string]interface{}{ 26 | randomString(10): randomString(10), 27 | }, 28 | "stream": randomString(10), 29 | "my_score": float64(rand.Intn(100)), 30 | })) 31 | } 32 | 33 | func testInvariantJSON(t *testing.T, in, in2 interface{}) { 34 | t.Helper() 35 | 36 | // put random 37 | randomExtraData(in) 38 | 39 | // marshal given 40 | data, err := json.Marshal(in) 41 | require.NoError(t, err) 42 | 43 | // unmarshal again 44 | require.NoError(t, json.Unmarshal(data, in2)) 45 | 46 | // ensure they are same 47 | require.Equal(t, in, in2) 48 | } 49 | 50 | func TestJSON(t *testing.T) { 51 | var c, c2 Channel 52 | testInvariantJSON(t, &c, &c2) 53 | 54 | var u, u2 User 55 | testInvariantJSON(t, &u, &u2) 56 | 57 | var e, e2 Event 58 | testInvariantJSON(t, &e, &e2) 59 | 60 | var m, m2 Message 61 | testInvariantJSON(t, &m, &m2) 62 | 63 | var mr, mr2 messageRequestMessage 64 | testInvariantJSON(t, &mr, &mr2) 65 | 66 | var a, a2 Attachment 67 | testInvariantJSON(t, &a, &a2) 68 | 69 | var r, r2 Reaction 70 | testInvariantJSON(t, &r, &r2) 71 | } 72 | -------------------------------------------------------------------------------- /message_history.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | type QueryMessageHistoryRequest struct { 12 | Filter map[string]any `json:"filter"` 13 | Sort []*SortOption `json:"sort,omitempty"` 14 | 15 | Limit int `json:"limit,omitempty"` 16 | Next string `json:"next,omitempty"` 17 | Prev string `json:"prev,omitempty"` 18 | } 19 | 20 | type MessageHistoryEntry struct { 21 | MessageID string `json:"message_id"` 22 | MessageUpdatedByID string `json:"message_updated_by_id"` 23 | MessageUpdatedAt time.Time `json:"message_updated_at"` 24 | 25 | Text string `json:"text"` 26 | Attachments []*Attachment `json:"attachments"` 27 | ExtraData map[string]interface{} `json:"-"` 28 | } 29 | 30 | var ( 31 | _ json.Unmarshaler = (*MessageHistoryEntry)(nil) 32 | _ json.Marshaler = (*MessageHistoryEntry)(nil) 33 | ) 34 | 35 | type messageHistoryJson MessageHistoryEntry 36 | 37 | func (m *MessageHistoryEntry) UnmarshalJSON(data []byte) error { 38 | var m2 messageHistoryJson 39 | if err := json.Unmarshal(data, &m2); err != nil { 40 | return err 41 | } 42 | *m = MessageHistoryEntry(m2) 43 | 44 | if err := json.Unmarshal(data, &m.ExtraData); err != nil { 45 | return err 46 | } 47 | removeFromMap(m.ExtraData, *m) 48 | return nil 49 | } 50 | 51 | func (m MessageHistoryEntry) MarshalJSON() ([]byte, error) { 52 | return addToMapAndMarshal(m.ExtraData, messageHistoryJson(m)) 53 | } 54 | 55 | type QueryMessageHistoryResponse struct { 56 | MessageHistory []*MessageHistoryEntry `json:"message_history"` 57 | 58 | Next *string `json:"next,omitempty"` 59 | Prev *string `json:"prev,omitempty"` 60 | Response 61 | } 62 | 63 | func (c *Client) QueryMessageHistory(ctx context.Context, request QueryMessageHistoryRequest) (*QueryMessageHistoryResponse, error) { 64 | if len(request.Filter) == 0 { 65 | return nil, errors.New("you need specify one filter at least") 66 | } 67 | var resp QueryMessageHistoryResponse 68 | err := c.makeRequest(ctx, http.MethodPost, "messages/history", nil, request, &resp) 69 | return &resp, err 70 | } 71 | -------------------------------------------------------------------------------- /message_history_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestMessageHistory(t *testing.T) { 12 | client := initClient(t) 13 | users := randomUsers(t, client, 2) 14 | user1 := users[0] 15 | user2 := users[1] 16 | 17 | ch := initChannel(t, client, user1.ID) 18 | 19 | ctx := context.Background() 20 | initialText := "initial text" 21 | customField := "custom_field" 22 | initialCustomFieldValue := "custom value" 23 | // send a message with initial text (user1) 24 | response, err := ch.SendMessage(ctx, &Message{Text: initialText, ExtraData: map[string]interface{}{customField: initialCustomFieldValue}}, user1.ID) 25 | require.NoError(t, err) 26 | message := response.Message 27 | 28 | updatedText1 := "updated text" 29 | updatedCustomFieldValue := "updated custom value" 30 | // update the message by user1 31 | _, err = client.UpdateMessage(ctx, &Message{Text: updatedText1, ExtraData: map[string]interface{}{customField: updatedCustomFieldValue}, UserID: user1.ID}, message.ID) 32 | assert.NoError(t, err) 33 | 34 | updatedText2 := "updated text 2" 35 | // update the message by user2 36 | _, err = client.UpdateMessage(ctx, &Message{Text: updatedText2, UserID: user2.ID}, message.ID) 37 | assert.NoError(t, err) 38 | 39 | t.Run("test query", func(t *testing.T) { 40 | req := QueryMessageHistoryRequest{ 41 | Filter: map[string]interface{}{ 42 | "message_id": message.ID, 43 | }, 44 | } 45 | messageHistoryResponse, err := client.QueryMessageHistory(ctx, req) 46 | assert.NoError(t, err) 47 | assert.NotNil(t, messageHistoryResponse) 48 | 49 | history := messageHistoryResponse.MessageHistory 50 | assert.Equal(t, 2, len(history)) 51 | 52 | firstUpdate := history[1] 53 | assert.Equal(t, initialText, firstUpdate.Text) 54 | assert.Equal(t, user1.ID, firstUpdate.MessageUpdatedByID) 55 | assert.Equal(t, initialCustomFieldValue, firstUpdate.ExtraData[customField].(string)) 56 | 57 | secondUpdate := history[0] 58 | assert.Equal(t, updatedText1, secondUpdate.Text) 59 | assert.Equal(t, user1.ID, secondUpdate.MessageUpdatedByID) 60 | assert.Equal(t, updatedCustomFieldValue, secondUpdate.ExtraData[customField].(string)) 61 | }) 62 | 63 | t.Run("test sorting", func(t *testing.T) { 64 | sortedHistoryQueryRequest := QueryMessageHistoryRequest{ 65 | Filter: map[string]interface{}{ 66 | "message_id": message.ID, 67 | }, 68 | Sort: []*SortOption{ 69 | { 70 | Field: "message_updated_at", 71 | Direction: 1, 72 | }, 73 | }, 74 | } 75 | sortedHistoryResponse, err := client.QueryMessageHistory(ctx, sortedHistoryQueryRequest) 76 | assert.NoError(t, err) 77 | assert.NotNil(t, sortedHistoryResponse) 78 | 79 | sortedHistory := sortedHistoryResponse.MessageHistory 80 | assert.Equal(t, 2, len(sortedHistory)) 81 | 82 | firstUpdate := sortedHistory[0] 83 | assert.Equal(t, initialText, firstUpdate.Text) 84 | assert.Equal(t, user1.ID, firstUpdate.MessageUpdatedByID) 85 | 86 | secondUpdate := sortedHistory[1] 87 | assert.Equal(t, updatedText1, secondUpdate.Text) 88 | assert.Equal(t, user1.ID, secondUpdate.MessageUpdatedByID) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /message_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestClient_TranslateMessage(t *testing.T) { 13 | c := initClient(t) 14 | u := randomUser(t, c) 15 | ch := initChannel(t, c, u.ID) 16 | ctx := context.Background() 17 | 18 | msg := &Message{Text: "test message"} 19 | messageResp, err := ch.SendMessage(ctx, msg, u.ID) 20 | require.NoError(t, err) 21 | 22 | translated, err := c.TranslateMessage(ctx, messageResp.Message.ID, "es") 23 | require.NoError(t, err) 24 | require.Equal(t, "mensaje de prueba", translated.Message.I18n["es_text"]) 25 | } 26 | 27 | func TestClient_SendMessage(t *testing.T) { 28 | c := initClient(t) 29 | user := randomUser(t, c) 30 | 31 | ctx := context.Background() 32 | 33 | ch := initChannel(t, c, user.ID) 34 | resp1, err := c.CreateChannel(ctx, ch.Type, ch.ID, user.ID, nil) 35 | require.NoError(t, err) 36 | 37 | msg := &Message{ID: randomString(10), Text: "test message", MML: "test mml", HTML: "test HTML"} 38 | messageResp, err := resp1.Channel.SendMessage(ctx, msg, user.ID) 39 | require.NoError(t, err) 40 | require.Equal(t, ch.CID, messageResp.Message.CID) 41 | require.Equal(t, user.ID, messageResp.Message.User.ID) 42 | require.Equal(t, msg.ID, messageResp.Message.ID) 43 | require.Equal(t, msg.Text, messageResp.Message.Text) 44 | require.Equal(t, msg.MML, messageResp.Message.MML) 45 | require.Equal(t, msg.HTML, messageResp.Message.HTML) 46 | } 47 | 48 | func TestClient_SendMessage_Pending(t *testing.T) { 49 | c := initClient(t) 50 | user := randomUser(t, c) 51 | 52 | ctx := context.Background() 53 | 54 | ch := initChannel(t, c, user.ID) 55 | resp1, err := c.CreateChannel(ctx, ch.Type, ch.ID, user.ID, nil) 56 | require.NoError(t, err) 57 | 58 | msg := &Message{Text: "test pending message"} 59 | metadata := map[string]string{"my": "metadata"} 60 | messageResp, err := resp1.Channel.SendMessage(ctx, msg, user.ID, MessagePending, MessagePendingMessageMetadata(metadata)) 61 | require.NoError(t, err) 62 | require.Equal(t, metadata, messageResp.PendingMessageMetadata) 63 | 64 | gotMsg, err := c.GetMessage(ctx, messageResp.Message.ID) 65 | require.NoError(t, err) 66 | require.Equal(t, metadata, gotMsg.PendingMessageMetadata) 67 | 68 | _, err = c.CommitMessage(ctx, messageResp.Message.ID) 69 | require.NoError(t, err) 70 | } 71 | 72 | func TestClient_SendMessage_WithPendingFalse(t *testing.T) { 73 | c := initClient(t) 74 | user := randomUser(t, c) 75 | 76 | ctx := context.Background() 77 | 78 | ch := initChannel(t, c, user.ID) 79 | resp1, err := c.CreateChannel(ctx, ch.Type, ch.ID, user.ID, nil) 80 | require.NoError(t, err) 81 | 82 | msg := &Message{Text: "message with WithPending(false) - non-pending message"} 83 | messageResp, err := resp1.Channel.SendMessage(ctx, msg, user.ID, WithPending(false)) 84 | require.NoError(t, err) 85 | 86 | // Get the message to verify it's not in pending state 87 | gotMsg, err := c.GetMessage(ctx, messageResp.Message.ID) 88 | require.NoError(t, err) 89 | 90 | // No need to commit the message as it's already in non-pending state 91 | // The message should be immediately available without requiring a commit 92 | require.NotNil(t, gotMsg.Message) 93 | require.Equal(t, msg.Text, gotMsg.Message.Text) 94 | } 95 | 96 | func TestClient_SendMessage_SkipEnrichURL(t *testing.T) { 97 | c := initClient(t) 98 | user := randomUser(t, c) 99 | 100 | ctx := context.Background() 101 | 102 | ch := initChannel(t, c, user.ID) 103 | resp1, err := c.CreateChannel(ctx, ch.Type, ch.ID, user.ID, nil) 104 | require.NoError(t, err) 105 | 106 | msg := &Message{Text: "test message with link to https://getstream.io"} 107 | messageResp, err := resp1.Channel.SendMessage(ctx, msg, user.ID, MessageSkipEnrichURL) 108 | require.NoError(t, err) 109 | require.Empty(t, messageResp.Message.Attachments) 110 | 111 | time.Sleep(3 * time.Second) 112 | gotMsg, err := c.GetMessage(ctx, messageResp.Message.ID) 113 | require.NoError(t, err) 114 | require.Empty(t, gotMsg.Message.Attachments) 115 | } 116 | 117 | func TestClient_PinMessage(t *testing.T) { 118 | c := initClient(t) 119 | userA := randomUser(t, c) 120 | userB := randomUser(t, c) 121 | ctx := context.Background() 122 | 123 | ch := initChannel(t, c, userA.ID, userB.ID) 124 | resp1, err := c.CreateChannel(ctx, ch.Type, ch.ID, userA.ID, nil) 125 | require.NoError(t, err) 126 | 127 | msg := &Message{Text: "test message"} 128 | messageResp, err := resp1.Channel.SendMessage(ctx, msg, userB.ID) 129 | require.NoError(t, err) 130 | 131 | msgWithOptions := &Message{Text: "test message"} 132 | quotedMsgResp, err := resp1.Channel.SendMessage(ctx, msgWithOptions, userB.ID, func(msg *messageRequest) { 133 | msg.Message.QuotedMessageID = messageResp.Message.ID 134 | }) 135 | require.NoError(t, err) 136 | require.Equal(t, messageResp.Message.ID, quotedMsgResp.Message.QuotedMessageID) 137 | 138 | messageResp, err = c.PinMessage(ctx, messageResp.Message.ID, userA.ID, nil) 139 | require.NoError(t, err) 140 | 141 | msg = messageResp.Message 142 | require.NotZero(t, msg.PinnedAt) 143 | require.NotZero(t, msg.PinnedBy) 144 | require.Equal(t, userA.ID, msg.PinnedBy.ID) 145 | 146 | messageResp, err = c.UnPinMessage(ctx, msg.ID, userA.ID) 147 | require.NoError(t, err) 148 | 149 | msg = messageResp.Message 150 | require.Zero(t, msg.PinnedAt) 151 | require.Zero(t, msg.PinnedBy) 152 | 153 | expireAt := time.Now().Add(3 * time.Second) 154 | messageResp, err = c.PinMessage(ctx, msg.ID, userA.ID, &expireAt) 155 | require.NoError(t, err) 156 | 157 | msg = messageResp.Message 158 | require.NotZero(t, msg.PinnedAt) 159 | require.NotZero(t, msg.PinnedBy) 160 | require.Equal(t, userA.ID, msg.PinnedBy.ID) 161 | 162 | time.Sleep(3 * time.Second) 163 | messageResp, err = c.GetMessage(ctx, msg.ID) 164 | require.NoError(t, err) 165 | 166 | msg = messageResp.Message 167 | require.Zero(t, msg.PinnedAt) 168 | require.Zero(t, msg.PinnedBy) 169 | } 170 | 171 | func TestClient_SendMessage_KeepChannelHidden(t *testing.T) { 172 | c := initClient(t) 173 | user := randomUser(t, c) 174 | 175 | ctx := context.Background() 176 | 177 | ch := initChannel(t, c, user.ID) 178 | resp, err := c.CreateChannel(ctx, ch.Type, ch.ID, user.ID, nil) 179 | require.NoError(t, err) 180 | 181 | _, err = resp.Channel.Hide(ctx, user.ID) 182 | require.NoError(t, err) 183 | 184 | msg := &Message{Text: "test message"} 185 | _, err = resp.Channel.SendMessage(ctx, msg, user.ID, KeepChannelHidden) 186 | require.NoError(t, err) 187 | 188 | result, err := c.QueryChannels(ctx, &QueryOption{ 189 | Filter: map[string]interface{}{"cid": resp.Channel.CID}, 190 | UserID: user.ID, 191 | }) 192 | require.NoError(t, err) 193 | require.Empty(t, result.Channels) 194 | } 195 | 196 | func TestClient_UpdateRestrictedVisibilityMessage(t *testing.T) { 197 | c := initClient(t) 198 | ch := initChannel(t, c) 199 | ctx := context.Background() 200 | adminUser := randomUserWithRole(t, c, "admin") 201 | user1 := randomUser(t, c) 202 | user2 := randomUser(t, c) 203 | msg := &Message{ 204 | Text: "test message", 205 | RestrictedVisibility: []string{ 206 | user1.ID, 207 | }, 208 | } 209 | 210 | resp, err := ch.SendMessage(ctx, msg, adminUser.ID) 211 | require.NoError(t, err, "send message") 212 | 213 | msg = resp.Message 214 | msg.RestrictedVisibility = []string{user2.ID} 215 | msg.UserID = adminUser.ID 216 | resp, err = c.UpdateMessage(ctx, msg, msg.ID) 217 | require.NoError(t, err, "send message") 218 | assert.Equal(t, []string{user2.ID}, resp.Message.RestrictedVisibility) 219 | } 220 | 221 | func TestClient_PartialUpdateRestrictedVisibilityMessage(t *testing.T) { 222 | c := initClient(t) 223 | ch := initChannel(t, c) 224 | ctx := context.Background() 225 | adminUser := randomUserWithRole(t, c, "admin") 226 | user1 := randomUser(t, c) 227 | user2 := randomUser(t, c) 228 | msg := &Message{ 229 | Text: "test message", 230 | RestrictedVisibility: []string{ 231 | user1.ID, 232 | }, 233 | } 234 | 235 | messageResponse, err := ch.SendMessage(ctx, msg, adminUser.ID) 236 | require.NoError(t, err, "send message") 237 | 238 | resp, err := c.PartialUpdateMessage(ctx, messageResponse.Message.ID, &MessagePartialUpdateRequest{ 239 | UserID: adminUser.ID, 240 | PartialUpdate: PartialUpdate{ 241 | Set: map[string]interface{}{ 242 | "restricted_visibility": []string{user2.ID}, 243 | }, 244 | }, 245 | }) 246 | require.NoError(t, err, "send message") 247 | assert.Equal(t, []string{user2.ID}, resp.Message.RestrictedVisibility) 248 | } 249 | -------------------------------------------------------------------------------- /permission_client.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "path" 8 | "time" 9 | ) 10 | 11 | type Permission struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | Description string `json:"description"` 15 | Action string `json:"action"` 16 | Owner bool `json:"owner"` 17 | SameTeam bool `json:"same_team"` 18 | Condition map[string]interface{} `json:"condition"` 19 | Custom bool `json:"custom"` 20 | Level string `json:"level"` 21 | } 22 | 23 | type Role struct { 24 | Name string `json:"name"` 25 | Custom bool `json:"custom"` 26 | Scopes []string `json:"scoped"` 27 | CreatedAt time.Time `json:"created_at"` 28 | UpdatedAt time.Time `json:"updated_at"` 29 | } 30 | 31 | type PermissionClient struct { 32 | client *Client 33 | } 34 | 35 | // CreateRole creates a new role. 36 | func (p *PermissionClient) CreateRole(ctx context.Context, name string) (*Response, error) { 37 | if name == "" { 38 | return nil, errors.New("name is required") 39 | } 40 | 41 | var resp Response 42 | err := p.client.makeRequest(ctx, http.MethodPost, "roles", nil, map[string]interface{}{ 43 | "name": name, 44 | }, &resp) 45 | return &resp, err 46 | } 47 | 48 | // DeleteRole deletes an existing role by name. 49 | func (p *PermissionClient) DeleteRole(ctx context.Context, name string) (*Response, error) { 50 | if name == "" { 51 | return nil, errors.New("name is required") 52 | } 53 | 54 | uri := path.Join("roles", name) 55 | 56 | var resp Response 57 | err := p.client.makeRequest(ctx, http.MethodDelete, uri, nil, nil, &resp) 58 | return &resp, err 59 | } 60 | 61 | type RolesResponse struct { 62 | Roles []*Role `json:"roles"` 63 | Response 64 | } 65 | 66 | // ListRole returns all roles. 67 | func (p *PermissionClient) ListRoles(ctx context.Context) (*RolesResponse, error) { 68 | var r RolesResponse 69 | err := p.client.makeRequest(ctx, http.MethodGet, "roles", nil, nil, &r) 70 | return &r, err 71 | } 72 | 73 | // CreatePermission creates a new permission. 74 | func (p *PermissionClient) CreatePermission(ctx context.Context, perm *Permission) (*Response, error) { 75 | var resp Response 76 | err := p.client.makeRequest(ctx, http.MethodPost, "permissions", nil, perm, &resp) 77 | return &resp, err 78 | } 79 | 80 | type GetPermissionResponse struct { 81 | Permission *Permission `json:"permission"` 82 | Response 83 | } 84 | 85 | // GetPermission returns a permission by id. 86 | func (p *PermissionClient) GetPermission(ctx context.Context, id string) (*GetPermissionResponse, error) { 87 | if id == "" { 88 | return nil, errors.New("id is required") 89 | } 90 | 91 | uri := path.Join("permissions", id) 92 | 93 | var perm GetPermissionResponse 94 | err := p.client.makeRequest(ctx, http.MethodGet, uri, nil, nil, &perm) 95 | return &perm, err 96 | } 97 | 98 | // UpdatePermission updates an existing permission by id. Only custom permissions can be updated. 99 | func (p *PermissionClient) UpdatePermission(ctx context.Context, id string, perm *Permission) (*Response, error) { 100 | if id == "" { 101 | return nil, errors.New("id is required") 102 | } 103 | 104 | uri := path.Join("permissions", id) 105 | 106 | var resp Response 107 | err := p.client.makeRequest(ctx, http.MethodPut, uri, nil, perm, &resp) 108 | return &resp, err 109 | } 110 | 111 | type ListPermissionsResponse struct { 112 | Permissions []*Permission `json:"permissions"` 113 | Response 114 | } 115 | 116 | // ListPermissions returns all permissions of an app. 117 | func (p *PermissionClient) ListPermissions(ctx context.Context) (*ListPermissionsResponse, error) { 118 | var perm ListPermissionsResponse 119 | err := p.client.makeRequest(ctx, http.MethodGet, "permissions", nil, nil, &perm) 120 | return &perm, err 121 | } 122 | 123 | // DeletePermission deletes a permission by id. 124 | func (p *PermissionClient) DeletePermission(ctx context.Context, id string) (*Response, error) { 125 | if id == "" { 126 | return nil, errors.New("id is required") 127 | } 128 | 129 | uri := path.Join("permissions", id) 130 | 131 | var resp Response 132 | err := p.client.makeRequest(ctx, http.MethodDelete, uri, nil, nil, &resp) 133 | return &resp, err 134 | } 135 | -------------------------------------------------------------------------------- /permission_client_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestPermissions_RoleEndpoints(t *testing.T) { 12 | c := initClient(t) 13 | p := c.Permissions() 14 | ctx := context.Background() 15 | roleName := randomString(12) 16 | 17 | _, err := p.CreateRole(ctx, roleName) 18 | require.NoError(t, err) 19 | _, _ = p.DeleteRole(ctx, roleName) 20 | // Unfortunately the API is too slow to create roles 21 | // and we don't want to wait > 10 seconds. 22 | // So we swallow potential errors here until that's fixed. 23 | // Plus we add a cleanup as well. 24 | 25 | roles, err := p.ListRoles(ctx) 26 | require.NoError(t, err) 27 | assert.NotEmpty(t, roles) 28 | 29 | t.Cleanup(func() { 30 | resp, _ := p.ListRoles(ctx) 31 | for _, role := range resp.Roles { 32 | if role.Custom { 33 | _, _ = p.DeleteRole(ctx, role.Name) 34 | } 35 | } 36 | }) 37 | } 38 | 39 | func TestPermissions_PermissionEndpoints(t *testing.T) { 40 | c := initClient(t) 41 | p := c.Permissions() 42 | ctx := context.Background() 43 | permName := randomString(12) 44 | 45 | _, err := p.CreatePermission(ctx, &Permission{ 46 | ID: permName, 47 | Name: permName, 48 | Action: "DeleteChannel", 49 | Description: "integration test", 50 | Condition: map[string]interface{}{ 51 | "$subject.magic_custom_field": map[string]string{"$eq": "true"}, 52 | }, 53 | }) 54 | require.NoError(t, err) 55 | 56 | perms, err := p.ListPermissions(ctx) 57 | require.NoError(t, err) 58 | assert.NotEmpty(t, perms) 59 | 60 | resp, err := p.GetPermission(ctx, "create-channel") 61 | require.NoError(t, err) 62 | 63 | perm := resp.Permission 64 | assert.Equal(t, "create-channel", perm.ID) 65 | assert.False(t, perm.Custom) 66 | assert.Empty(t, perm.Condition) 67 | 68 | t.Cleanup(func() { 69 | resp, _ := p.ListPermissions(ctx) 70 | for _, perm := range resp.Permissions { 71 | if perm.Description == "integration test" { 72 | _, _ = p.DeletePermission(ctx, perm.ID) 73 | } 74 | } 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /query.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type QueryOption struct { 14 | // https://getstream.io/chat/docs/#query_syntax 15 | Filter map[string]interface{} `json:"filter_conditions,omitempty"` 16 | Sort []*SortOption `json:"sort,omitempty"` 17 | 18 | UserID string `json:"user_id,omitempty"` 19 | Limit int `json:"limit,omitempty"` // pagination option: limit number of results 20 | Offset int `json:"offset,omitempty"` // pagination option: offset to return items from 21 | MessageLimit *int `json:"message_limit,omitempty"` 22 | MemberLimit *int `json:"member_limit,omitempty"` 23 | } 24 | 25 | type SortOption struct { 26 | Field string `json:"field"` // field name to sort by,from json tags(in camel case), for example created_at 27 | Direction int `json:"direction"` // [-1, 1] 28 | } 29 | 30 | type queryRequest struct { 31 | Watch bool `json:"watch"` 32 | State bool `json:"state"` 33 | Presence bool `json:"presence"` 34 | 35 | UserID string `json:"user_id,omitempty"` 36 | Limit int `json:"limit,omitempty"` 37 | Offset int `json:"offset,omitempty"` 38 | MemberLimit *int `json:"member_limit,omitempty"` 39 | MessageLimit *int `json:"message_limit,omitempty"` 40 | IncludeDeactivatedUsers bool `json:"include_deactivated_users,omitempty"` 41 | 42 | FilterConditions map[string]interface{} `json:"filter_conditions,omitempty"` 43 | Sort []*SortOption `json:"sort,omitempty"` 44 | } 45 | 46 | type QueryFlagReportsRequest struct { 47 | FilterConditions map[string]interface{} `json:"filter_conditions,omitempty"` 48 | Limit int `json:"limit,omitempty"` 49 | Offset int `json:"offset,omitempty"` 50 | } 51 | 52 | type FlagReport struct { 53 | ID string `json:"id"` 54 | Message *Message `json:"message"` 55 | FlagsCount int `json:"flags_count"` 56 | MessageUserID string `json:"message_user_id"` 57 | ChannelCid string `json:"channel_cid"` 58 | CreatedAt time.Time `json:"created_at"` 59 | UpdatedAt time.Time `json:"updated_at"` 60 | } 61 | 62 | type QueryUsersOptions struct { 63 | QueryOption 64 | 65 | IncludeDeactivatedUsers bool `json:"include_deactivated_users"` 66 | } 67 | 68 | type QueryUsersResponse struct { 69 | Users []*User `json:"users"` 70 | Response 71 | } 72 | 73 | // QueryUsers returns list of users that match QueryUsersOptions. 74 | // If any number of SortOption are set, result will be sorted by field and direction in the order of sort options. 75 | func (c *Client) QueryUsers(ctx context.Context, q *QueryUsersOptions, sorters ...*SortOption) (*QueryUsersResponse, error) { 76 | qp := queryRequest{ 77 | FilterConditions: q.Filter, 78 | Limit: q.Limit, 79 | Offset: q.Offset, 80 | IncludeDeactivatedUsers: q.IncludeDeactivatedUsers, 81 | Sort: sorters, 82 | } 83 | 84 | data, err := json.Marshal(&qp) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | values := url.Values{} 90 | values.Set("payload", string(data)) 91 | 92 | var resp QueryUsersResponse 93 | err = c.makeRequest(ctx, http.MethodGet, "users", values, nil, &resp) 94 | return &resp, err 95 | } 96 | 97 | type queryChannelResponse struct { 98 | Channels []queryChannelResponseData `json:"channels"` 99 | Response 100 | } 101 | 102 | type queryChannelResponseData struct { 103 | Channel *Channel `json:"channel"` 104 | Messages []*Message `json:"messages"` 105 | Read []*ChannelRead `json:"read"` 106 | Members []*ChannelMember `json:"members"` 107 | PendingMessages []*Message `json:"pending_messages"` 108 | PinnedMessages []*Message `json:"pinned_messages"` 109 | Hidden bool `json:"hidden"` 110 | PushPreferences *ChannelPushPreferences `json:"push_preferences"` 111 | WatcherCount int `json:"watcher_count"` 112 | Watchers []*User `json:"watchers"` 113 | } 114 | 115 | type QueryChannelsResponse struct { 116 | Channels []*Channel 117 | Response 118 | } 119 | 120 | type ChannelPushPreferences struct { 121 | ChatLevel string `json:"chat_level"` 122 | CallLevel string `json:"call_level"` 123 | DisabledUntil *time.Time `json:"disabled_until"` 124 | } 125 | 126 | // QueryChannels returns list of channels with members and messages, that match QueryOption. 127 | // If any number of SortOption are set, result will be sorted by field and direction in oder of sort options. 128 | func (c *Client) QueryChannels(ctx context.Context, q *QueryOption, sort ...*SortOption) (*QueryChannelsResponse, error) { 129 | qp := queryRequest{ 130 | State: true, 131 | FilterConditions: q.Filter, 132 | Sort: sort, 133 | UserID: q.UserID, 134 | Limit: q.Limit, 135 | Offset: q.Offset, 136 | MemberLimit: q.MemberLimit, 137 | MessageLimit: q.MessageLimit, 138 | } 139 | 140 | var resp queryChannelResponse 141 | if err := c.makeRequest(ctx, http.MethodPost, "channels", nil, qp, &resp); err != nil { 142 | return nil, err 143 | } 144 | 145 | result := make([]*Channel, len(resp.Channels)) 146 | for i, data := range resp.Channels { 147 | result[i] = data.Channel 148 | result[i].Members = data.Members 149 | result[i].Messages = data.Messages 150 | result[i].PendingMessages = data.PendingMessages 151 | result[i].PinnedMessages = data.PinnedMessages 152 | result[i].Hidden = data.Hidden 153 | result[i].PushPreferences = data.PushPreferences 154 | result[i].WatcherCount = data.WatcherCount 155 | result[i].Watchers = data.Watchers 156 | result[i].Read = data.Read 157 | result[i].client = c 158 | } 159 | 160 | return &QueryChannelsResponse{Channels: result, Response: resp.Response}, nil 161 | } 162 | 163 | type SearchRequest struct { 164 | // Required 165 | Query string `json:"query"` 166 | Filters map[string]interface{} `json:"filter_conditions"` 167 | MessageFilters map[string]interface{} `json:"message_filter_conditions"` 168 | 169 | // Pagination, optional 170 | Limit int `json:"limit,omitempty"` 171 | Offset int `json:"offset,omitempty"` 172 | Next string `json:"next,omitempty"` 173 | 174 | // Sort, optional 175 | Sort []SortOption `json:"sort,omitempty"` 176 | } 177 | 178 | type SearchFullResponse struct { 179 | Results []SearchMessageResponse `json:"results"` 180 | Next string `json:"next,omitempty"` 181 | Previous string `json:"previous,omitempty"` 182 | Response 183 | } 184 | 185 | type SearchMessageResponse struct { 186 | Message *Message `json:"message"` 187 | } 188 | 189 | type SearchResponse struct { 190 | Messages []*Message 191 | Response 192 | } 193 | 194 | // Search returns messages matching for given keyword. 195 | func (c *Client) Search(ctx context.Context, request SearchRequest) (*SearchResponse, error) { 196 | result, err := c.SearchWithFullResponse(ctx, request) 197 | if err != nil { 198 | return nil, err 199 | } 200 | messages := make([]*Message, 0, len(result.Results)) 201 | for _, res := range result.Results { 202 | messages = append(messages, res.Message) 203 | } 204 | 205 | resp := SearchResponse{ 206 | Messages: messages, 207 | Response: result.Response, 208 | } 209 | return &resp, nil 210 | } 211 | 212 | // SearchWithFullResponse performs a search and returns the full results. 213 | func (c *Client) SearchWithFullResponse(ctx context.Context, request SearchRequest) (*SearchFullResponse, error) { 214 | if request.Offset != 0 { 215 | if len(request.Sort) > 0 || request.Next != "" { 216 | return nil, errors.New("cannot use Offset with Next or Sort parameters") 217 | } 218 | } 219 | if request.Query != "" && len(request.MessageFilters) != 0 { 220 | return nil, errors.New("can only specify Query or MessageFilters, not both") 221 | } 222 | var buf strings.Builder 223 | 224 | if err := json.NewEncoder(&buf).Encode(request); err != nil { 225 | return nil, err 226 | } 227 | 228 | values := url.Values{} 229 | values.Set("payload", buf.String()) 230 | 231 | var result SearchFullResponse 232 | if err := c.makeRequest(ctx, http.MethodGet, "search", values, nil, &result); err != nil { 233 | return nil, err 234 | } 235 | return &result, nil 236 | } 237 | 238 | type QueryMessageFlagsResponse struct { 239 | Flags []*MessageFlag `json:"flags"` 240 | Response 241 | } 242 | 243 | // QueryMessageFlags returns list of message flags that match QueryOption. 244 | func (c *Client) QueryMessageFlags(ctx context.Context, q *QueryOption) (*QueryMessageFlagsResponse, error) { 245 | qp := queryRequest{ 246 | FilterConditions: q.Filter, 247 | Limit: q.Limit, 248 | Offset: q.Offset, 249 | } 250 | 251 | data, err := json.Marshal(&qp) 252 | if err != nil { 253 | return nil, err 254 | } 255 | 256 | values := url.Values{} 257 | values.Set("payload", string(data)) 258 | 259 | var resp QueryMessageFlagsResponse 260 | err = c.makeRequest(ctx, http.MethodGet, "moderation/flags/message", values, nil, &resp) 261 | return &resp, err 262 | } 263 | 264 | type QueryFlagReportsResponse struct { 265 | Response 266 | FlagReports []*FlagReport `json:"flag_reports"` 267 | } 268 | 269 | // QueryFlagReports returns list of flag reports that match the filter. 270 | func (c *Client) QueryFlagReports(ctx context.Context, q *QueryFlagReportsRequest) (*QueryFlagReportsResponse, error) { 271 | resp := &QueryFlagReportsResponse{} 272 | err := c.makeRequest(ctx, http.MethodPost, "moderation/reports", nil, q, &resp) 273 | return resp, err 274 | } 275 | -------------------------------------------------------------------------------- /query_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestClient_QueryUsers(t *testing.T) { 14 | c := initClient(t) 15 | ctx := context.Background() 16 | 17 | const n = 5 18 | ids := make([]string, n) 19 | t.Cleanup(func() { 20 | for _, id := range ids { 21 | if id != "" { 22 | _, _ = c.DeleteUser(ctx, id) 23 | } 24 | } 25 | }) 26 | 27 | for i := n - 1; i > -1; i-- { 28 | u := &User{ID: randomString(30), ExtraData: map[string]interface{}{"order": n - i - 1}} 29 | _, err := c.UpsertUser(ctx, u) 30 | require.NoError(t, err) 31 | ids[i] = u.ID 32 | time.Sleep(200 * time.Millisecond) 33 | } 34 | 35 | _, err := c.DeactivateUser(ctx, ids[n-1]) 36 | require.NoError(t, err) 37 | 38 | t.Parallel() 39 | t.Run("Query all", func(tt *testing.T) { 40 | results, err := c.QueryUsers(ctx, &QueryUsersOptions{ 41 | QueryOption: QueryOption{ 42 | Filter: map[string]interface{}{ 43 | "id": map[string]interface{}{ 44 | "$in": ids, 45 | }, 46 | }, 47 | }, 48 | }) 49 | 50 | require.NoError(tt, err) 51 | require.Len(tt, results.Users, len(ids)-1) 52 | }) 53 | 54 | t.Run("Query with offset/limit", func(tt *testing.T) { 55 | offset := 1 56 | 57 | results, err := c.QueryUsers(ctx, &QueryUsersOptions{ 58 | QueryOption: QueryOption{ 59 | Filter: map[string]interface{}{ 60 | "id": map[string]interface{}{ 61 | "$in": ids, 62 | }, 63 | }, 64 | Offset: offset, 65 | Limit: 2, 66 | }, 67 | }) 68 | 69 | require.NoError(tt, err) 70 | require.Len(tt, results.Users, 2) 71 | 72 | require.Equal(tt, results.Users[0].ID, ids[offset]) 73 | require.Equal(tt, results.Users[1].ID, ids[offset+1]) 74 | }) 75 | 76 | t.Run("Query with deactivated", func(tt *testing.T) { 77 | results, err := c.QueryUsers(ctx, &QueryUsersOptions{ 78 | QueryOption: QueryOption{ 79 | Filter: map[string]interface{}{ 80 | "id": map[string]interface{}{ 81 | "$in": ids, 82 | }, 83 | }, 84 | }, 85 | IncludeDeactivatedUsers: true, 86 | }) 87 | 88 | require.NoError(tt, err) 89 | require.Len(tt, results.Users, len(ids)) 90 | }) 91 | } 92 | 93 | func TestClient_QueryChannels(t *testing.T) { 94 | c := initClient(t) 95 | ch := initChannel(t, c) 96 | ctx := context.Background() 97 | 98 | _, err := ch.SendMessage(ctx, &Message{Text: "abc"}, "some") 99 | require.NoError(t, err) 100 | _, err = ch.SendMessage(ctx, &Message{Text: "abc"}, "some") 101 | require.NoError(t, err) 102 | 103 | messageLimit := 1 104 | resp, err := c.QueryChannels(ctx, &QueryOption{ 105 | Filter: map[string]interface{}{ 106 | "id": map[string]interface{}{ 107 | "$eq": ch.ID, 108 | }, 109 | }, 110 | MessageLimit: &messageLimit, 111 | }) 112 | 113 | require.NoError(t, err, "query channels error") 114 | require.Equal(t, ch.ID, resp.Channels[0].ID, "received channel ID") 115 | require.Len(t, resp.Channels[0].Messages, messageLimit) 116 | } 117 | 118 | func TestClient_Search(t *testing.T) { 119 | c := initClient(t) 120 | ctx := context.Background() 121 | 122 | user1, user2 := randomUser(t, c), randomUser(t, c) 123 | 124 | ch := initChannel(t, c, user1.ID, user2.ID) 125 | 126 | text := randomString(10) 127 | 128 | _, err := ch.SendMessage(ctx, &Message{Text: text + " " + randomString(25)}, user1.ID) 129 | require.NoError(t, err) 130 | 131 | _, err = ch.SendMessage(ctx, &Message{Text: text + " " + randomString(25)}, user2.ID) 132 | require.NoError(t, err) 133 | 134 | t.Run("Query", func(tt *testing.T) { 135 | resp, err := c.Search(ctx, SearchRequest{Query: text, Filters: map[string]interface{}{ 136 | "members": map[string][]string{ 137 | "$in": {user1.ID, user2.ID}, 138 | }, 139 | }}) 140 | 141 | require.NoError(tt, err) 142 | 143 | assert.Len(tt, resp.Messages, 2) 144 | }) 145 | t.Run("Message filters", func(tt *testing.T) { 146 | resp, err := c.Search(ctx, SearchRequest{ 147 | Filters: map[string]interface{}{ 148 | "members": map[string][]string{ 149 | "$in": {user1.ID, user2.ID}, 150 | }, 151 | }, 152 | MessageFilters: map[string]interface{}{ 153 | "text": map[string]interface{}{ 154 | "$q": text, 155 | }, 156 | }, 157 | }) 158 | require.NoError(tt, err) 159 | 160 | assert.Len(tt, resp.Messages, 2) 161 | }) 162 | t.Run("Query and message filters error", func(tt *testing.T) { 163 | _, err := c.Search(ctx, SearchRequest{ 164 | Filters: map[string]interface{}{ 165 | "members": map[string][]string{ 166 | "$in": {user1.ID, user2.ID}, 167 | }, 168 | }, 169 | MessageFilters: map[string]interface{}{ 170 | "text": map[string]interface{}{ 171 | "$q": text, 172 | }, 173 | }, 174 | Query: text, 175 | }) 176 | require.Error(tt, err) 177 | }) 178 | t.Run("Offset and sort error", func(tt *testing.T) { 179 | _, err := c.Search(ctx, SearchRequest{ 180 | Filters: map[string]interface{}{ 181 | "members": map[string][]string{ 182 | "$in": {user1.ID, user2.ID}, 183 | }, 184 | }, 185 | Offset: 1, 186 | Query: text, 187 | Sort: []SortOption{{ 188 | Field: "created_at", 189 | Direction: -1, 190 | }}, 191 | }) 192 | require.Error(tt, err) 193 | }) 194 | t.Run("Offset and next error", func(tt *testing.T) { 195 | _, err := c.Search(ctx, SearchRequest{ 196 | Filters: map[string]interface{}{ 197 | "members": map[string][]string{ 198 | "$in": {user1.ID, user2.ID}, 199 | }, 200 | }, 201 | Offset: 1, 202 | Query: text, 203 | Next: randomString(5), 204 | }) 205 | require.Error(tt, err) 206 | }) 207 | } 208 | 209 | func TestClient_SearchWithFullResponse(t *testing.T) { 210 | t.Skip() 211 | c := initClient(t) 212 | ch := initChannel(t, c) 213 | ctx := context.Background() 214 | 215 | user1, user2 := randomUser(t, c), randomUser(t, c) 216 | 217 | text := randomString(10) 218 | 219 | messageIDs := make([]string, 6) 220 | for i := 0; i < 6; i++ { 221 | userID := user1.ID 222 | if i%2 == 0 { 223 | userID = user2.ID 224 | } 225 | messageID := fmt.Sprintf("%d-%s", i, text) 226 | _, err := ch.SendMessage(ctx, &Message{ 227 | ID: messageID, 228 | Text: text + " " + randomString(25), 229 | }, userID) 230 | require.NoError(t, err) 231 | 232 | messageIDs[6-i] = messageID 233 | } 234 | 235 | got, err := c.SearchWithFullResponse(ctx, SearchRequest{ 236 | Query: text, 237 | Filters: map[string]interface{}{ 238 | "members": map[string][]string{ 239 | "$in": {user1.ID, user2.ID}, 240 | }, 241 | }, 242 | Sort: []SortOption{ 243 | {Field: "created_at", Direction: -1}, 244 | }, 245 | Limit: 3, 246 | }) 247 | 248 | gotMessageIDs := make([]string, 0, 6) 249 | require.NoError(t, err) 250 | assert.NotEmpty(t, got.Next) 251 | assert.Len(t, got.Results, 3) 252 | for _, result := range got.Results { 253 | gotMessageIDs = append(gotMessageIDs, result.Message.ID) 254 | } 255 | got, err = c.SearchWithFullResponse(ctx, SearchRequest{ 256 | Query: text, 257 | Filters: map[string]interface{}{ 258 | "members": map[string][]string{ 259 | "$in": {user1.ID, user2.ID}, 260 | }, 261 | }, 262 | Next: got.Next, 263 | Limit: 3, 264 | }) 265 | require.NoError(t, err) 266 | assert.NotEmpty(t, got.Previous) 267 | assert.Empty(t, got.Next) 268 | assert.Len(t, got.Results, 3) 269 | for _, result := range got.Results { 270 | gotMessageIDs = append(gotMessageIDs, result.Message.ID) 271 | } 272 | assert.Equal(t, messageIDs, gotMessageIDs) 273 | } 274 | 275 | func TestClient_QueryMessageFlags(t *testing.T) { 276 | c := initClient(t) 277 | ch := initChannel(t, c) 278 | ctx := context.Background() 279 | 280 | user1, user2 := randomUser(t, c), randomUser(t, c) 281 | for user1.ID == user2.ID { 282 | user2 = randomUser(t, c) 283 | } 284 | 285 | // send 2 messages 286 | text := randomString(10) 287 | resp, err := ch.SendMessage(ctx, &Message{Text: text + " " + randomString(25)}, user1.ID) 288 | require.NoError(t, err) 289 | msg1 := resp.Message 290 | 291 | resp, err = ch.SendMessage(ctx, &Message{Text: text + " " + randomString(25)}, user2.ID) 292 | require.NoError(t, err) 293 | msg2 := resp.Message 294 | 295 | // flag 2 messages 296 | _, err = c.FlagMessage(ctx, msg2.ID, user1.ID) 297 | require.NoError(t, err) 298 | 299 | _, err = c.FlagMessage(ctx, msg1.ID, user2.ID) 300 | require.NoError(t, err) 301 | 302 | // both flags show up in this query by channel_cid 303 | got, err := c.QueryMessageFlags(ctx, &QueryOption{ 304 | Filter: map[string]interface{}{ 305 | "channel_cid": map[string][]string{ 306 | "$in": {ch.cid()}, 307 | }, 308 | }, 309 | }) 310 | require.NoError(t, err) 311 | assert.Len(t, got.Flags, 2) 312 | 313 | // one flag shows up in this query by user_id 314 | got, err = c.QueryMessageFlags(ctx, &QueryOption{ 315 | Filter: map[string]interface{}{ 316 | "user_id": user1.ID, 317 | }, 318 | }) 319 | require.NoError(t, err) 320 | assert.Len(t, got.Flags, 1) 321 | } 322 | 323 | func TestClient_QueryFlagReportsAndReview(t *testing.T) { 324 | c := initClient(t) 325 | ch := initChannel(t, c) 326 | ctx := context.Background() 327 | user1, user2 := randomUser(t, c), randomUser(t, c) 328 | msg, err := ch.SendMessage(ctx, &Message{Text: randomString(25)}, user1.ID) 329 | require.NoError(t, err) 330 | t.Cleanup(func() { 331 | _, _ = ch.Delete(ctx) 332 | _, _ = c.DeleteUser(ctx, user1.ID, DeleteUserWithHardDelete()) 333 | _, _ = c.DeleteUser(ctx, user2.ID, DeleteUserWithHardDelete()) 334 | }) 335 | 336 | _, err = c.FlagMessage(ctx, msg.Message.ID, user1.ID) 337 | require.NoError(t, err) 338 | 339 | resp, err := c.QueryFlagReports(ctx, &QueryFlagReportsRequest{ 340 | FilterConditions: map[string]interface{}{"message_id": msg.Message.ID}, 341 | }) 342 | require.NoError(t, err) 343 | require.NotEmpty(t, resp.FlagReports) 344 | 345 | flagResp, err := c.ReviewFlagReport(ctx, resp.FlagReports[0].ID, &ReviewFlagReportRequest{ 346 | ReviewResult: "reviewed", 347 | UserID: user2.ID, 348 | }) 349 | require.NoError(t, err) 350 | require.NotNil(t, flagResp.FlagReport) 351 | } 352 | -------------------------------------------------------------------------------- /rate_limits.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const ( 13 | HeaderRateLimit = "X-Ratelimit-Limit" 14 | HeaderRateRemaining = "X-Ratelimit-Remaining" 15 | HeaderRateReset = "X-Ratelimit-Reset" 16 | ) 17 | 18 | // RateLimitInfo represents the quota and usage for a single endpoint. 19 | type RateLimitInfo struct { 20 | // Limit is the maximum number of API calls for a single time window (1 minute). 21 | Limit int64 `json:"limit"` 22 | // Remaining is the number of API calls remaining in the current time window (1 minute). 23 | Remaining int64 `json:"remaining"` 24 | // Reset is the Unix timestamp of the expiration of the current rate limit time window. 25 | Reset int64 `json:"reset"` 26 | } 27 | 28 | func NewRateLimitFromHeaders(headers http.Header) *RateLimitInfo { 29 | var rl RateLimitInfo 30 | 31 | limit, err := strconv.ParseInt(headers.Get(HeaderRateLimit), 10, 64) 32 | if err == nil { 33 | rl.Limit = limit 34 | } 35 | remaining, err := strconv.ParseInt(headers.Get(HeaderRateRemaining), 10, 64) 36 | if err == nil { 37 | rl.Remaining = remaining 38 | } 39 | reset, err := strconv.ParseInt(headers.Get(HeaderRateReset), 10, 64) 40 | if err == nil && reset > 0 { 41 | rl.Reset = reset 42 | } 43 | 44 | return &rl 45 | } 46 | 47 | // RateLimitsMap holds the rate limit information, where the keys are the names of the endpoints and the values are 48 | // the related RateLimitInfo containing the quota, usage, and reset data. 49 | type RateLimitsMap map[string]RateLimitInfo 50 | 51 | // ResetTime is a simple helper to get the time.Time representation of the Reset field of the given limit window. 52 | func (i RateLimitInfo) ResetTime() time.Time { 53 | return time.Unix(i.Reset, 0) 54 | } 55 | 56 | // GetRateLimitsResponse is the response of the Client.GetRateLimits call. It includes, if present, the rate 57 | // limits for the supported platforms, namely server-side, Android, iOS, and web. 58 | type GetRateLimitsResponse struct { 59 | ServerSide RateLimitsMap `json:"server_side,omitempty"` 60 | Android RateLimitsMap `json:"android,omitempty"` 61 | IOS RateLimitsMap `json:"ios,omitempty"` 62 | Web RateLimitsMap `json:"web,omitempty"` 63 | } 64 | 65 | type getRateLimitsParams struct { 66 | serverSide bool 67 | android bool 68 | iOS bool 69 | web bool 70 | endpoints []string 71 | } 72 | 73 | // GetRateLimitsOption configures the Client.GetRateLimits call. 74 | type GetRateLimitsOption func(p *getRateLimitsParams) 75 | 76 | // WithServerSide restricts the returned limits to server-side clients only. 77 | func WithServerSide() GetRateLimitsOption { 78 | return func(p *getRateLimitsParams) { 79 | p.serverSide = true 80 | } 81 | } 82 | 83 | // WithAndroid restricts the returned limits to Android clients only. 84 | func WithAndroid() GetRateLimitsOption { 85 | return func(p *getRateLimitsParams) { 86 | p.android = true 87 | } 88 | } 89 | 90 | // WithIOS restricts the returned limits to iOS clients only. 91 | func WithIOS() GetRateLimitsOption { 92 | return func(p *getRateLimitsParams) { 93 | p.iOS = true 94 | } 95 | } 96 | 97 | // WithWeb restricts the returned limits to web clients only. 98 | func WithWeb() GetRateLimitsOption { 99 | return func(p *getRateLimitsParams) { 100 | p.web = true 101 | } 102 | } 103 | 104 | // WithEndpoints restricts the returned limits info to the specified endpoints. 105 | func WithEndpoints(endpoints ...string) GetRateLimitsOption { 106 | return func(p *getRateLimitsParams) { 107 | p.endpoints = append(p.endpoints, endpoints...) 108 | } 109 | } 110 | 111 | // GetRateLimits returns the current rate limit quotas and usage. If no options are passed, all the limits 112 | // for all platforms are returned. 113 | func (c *Client) GetRateLimits(ctx context.Context, options ...GetRateLimitsOption) (GetRateLimitsResponse, error) { 114 | rlParams := getRateLimitsParams{} 115 | for _, opt := range options { 116 | opt(&rlParams) 117 | } 118 | 119 | params := url.Values{} 120 | if rlParams.serverSide { 121 | params.Set("server_side", "true") 122 | } 123 | if rlParams.android { 124 | params.Set("android", "true") 125 | } 126 | if rlParams.iOS { 127 | params.Set("ios", "true") 128 | } 129 | if rlParams.web { 130 | params.Set("web", "true") 131 | } 132 | if len(rlParams.endpoints) > 0 { 133 | params.Add("endpoints", strings.Join(rlParams.endpoints, ",")) 134 | } 135 | 136 | var resp GetRateLimitsResponse 137 | err := c.makeRequest(ctx, http.MethodGet, "rate_limits", params, nil, &resp) 138 | return resp, err 139 | } 140 | -------------------------------------------------------------------------------- /rate_limits_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestClient_GetRateLimits(t *testing.T) { 11 | c := initClient(t) 12 | ctx := context.Background() 13 | 14 | t.Run("get all limits", func(t *testing.T) { 15 | limits, err := c.GetRateLimits(ctx) 16 | require.NoError(t, err) 17 | require.NotEmpty(t, limits.Android) 18 | require.NotEmpty(t, limits.Web) 19 | require.NotEmpty(t, limits.IOS) 20 | require.NotEmpty(t, limits.ServerSide) 21 | }) 22 | 23 | t.Run("get only a single platform", func(t *testing.T) { 24 | limits, err := c.GetRateLimits(ctx, WithServerSide()) 25 | require.NoError(t, err) 26 | require.Empty(t, limits.Android) 27 | require.Empty(t, limits.Web) 28 | require.Empty(t, limits.IOS) 29 | require.NotEmpty(t, limits.ServerSide) 30 | }) 31 | 32 | t.Run("get only a few endpoints", func(t *testing.T) { 33 | limits, err := c.GetRateLimits(ctx, 34 | WithServerSide(), 35 | WithAndroid(), 36 | WithEndpoints( 37 | "GetRateLimits", 38 | "SendMessage", 39 | ), 40 | ) 41 | require.NoError(t, err) 42 | require.Empty(t, limits.Web) 43 | require.Empty(t, limits.IOS) 44 | 45 | require.NotEmpty(t, limits.Android) 46 | require.Len(t, limits.Android, 2) 47 | require.Equal(t, limits.Android["GetRateLimits"].Limit, limits.Android["GetRateLimits"].Remaining) 48 | 49 | require.NotEmpty(t, limits.ServerSide) 50 | require.Len(t, limits.ServerSide, 2) 51 | require.Greater(t, limits.ServerSide["GetRateLimits"].Limit, limits.ServerSide["GetRateLimits"].Remaining) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /reaction.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | ) 11 | 12 | type Reaction struct { 13 | MessageID string `json:"message_id"` 14 | UserID string `json:"user_id"` 15 | Type string `json:"type"` 16 | 17 | // any other fields the user wants to attach a reaction 18 | ExtraData map[string]interface{} `json:"-"` 19 | } 20 | 21 | type reactionForJSON Reaction 22 | 23 | func (s *Reaction) UnmarshalJSON(data []byte) error { 24 | var s2 reactionForJSON 25 | if err := json.Unmarshal(data, &s2); err != nil { 26 | return err 27 | } 28 | *s = Reaction(s2) 29 | 30 | if err := json.Unmarshal(data, &s.ExtraData); err != nil { 31 | return err 32 | } 33 | 34 | removeFromMap(s.ExtraData, *s) 35 | return nil 36 | } 37 | 38 | func (s Reaction) MarshalJSON() ([]byte, error) { 39 | return addToMapAndMarshal(s.ExtraData, reactionForJSON(s)) 40 | } 41 | 42 | type ReactionResponse struct { 43 | Message *Message `json:"message"` 44 | Reaction *Reaction `json:"reaction"` 45 | Response 46 | } 47 | 48 | type reactionRequest struct { 49 | Reaction *Reaction `json:"reaction"` 50 | } 51 | 52 | // SendReaction sends a reaction to message with given ID. 53 | // Deprecated: SendReaction is deprecated, use client.SendReaction instead. 54 | func (ch *Channel) SendReaction(ctx context.Context, reaction *Reaction, messageID, userID string) (*ReactionResponse, error) { 55 | return ch.client.SendReaction(ctx, reaction, messageID, userID) 56 | } 57 | 58 | // DeleteReaction removes a reaction from message with given ID. 59 | // Deprecated: DeleteReaction is deprecated, use client.DeleteReaction instead. 60 | func (ch *Channel) DeleteReaction(ctx context.Context, messageID, reactionType, userID string) (*ReactionResponse, error) { 61 | return ch.client.DeleteReaction(ctx, messageID, reactionType, userID) 62 | } 63 | 64 | // SendReaction sends a reaction to message with given ID. 65 | func (c *Client) SendReaction(ctx context.Context, reaction *Reaction, messageID, userID string) (*ReactionResponse, error) { 66 | switch { 67 | case reaction == nil: 68 | return nil, errors.New("reaction is nil") 69 | case messageID == "": 70 | return nil, errors.New("message ID must be not empty") 71 | case userID == "": 72 | return nil, errors.New("user ID must be not empty") 73 | } 74 | 75 | reaction.UserID = userID 76 | p := path.Join("messages", url.PathEscape(messageID), "reaction") 77 | 78 | req := reactionRequest{Reaction: reaction} 79 | 80 | var resp ReactionResponse 81 | err := c.makeRequest(ctx, http.MethodPost, p, nil, req, &resp) 82 | return &resp, err 83 | } 84 | 85 | // DeleteReaction removes a reaction from message with given ID. 86 | func (c *Client) DeleteReaction(ctx context.Context, messageID, reactionType, userID string) (*ReactionResponse, error) { 87 | switch { 88 | case messageID == "": 89 | return nil, errors.New("message ID is empty") 90 | case reactionType == "": 91 | return nil, errors.New("reaction type is empty") 92 | case userID == "": 93 | return nil, errors.New("user ID is empty") 94 | } 95 | 96 | p := path.Join("messages", url.PathEscape(messageID), "reaction", url.PathEscape(reactionType)) 97 | 98 | params := url.Values{} 99 | params.Set("user_id", userID) 100 | 101 | var resp ReactionResponse 102 | err := c.makeRequest(ctx, http.MethodDelete, p, params, nil, &resp) 103 | if err != nil { 104 | return nil, err 105 | } 106 | if resp.Message == nil { 107 | return nil, errors.New("unexpected error: response message nil") 108 | } 109 | 110 | return &resp, nil 111 | } 112 | 113 | type ReactionsResponse struct { 114 | Reactions []*Reaction `json:"reactions"` 115 | Response 116 | } 117 | 118 | // GetReactions returns list of the reactions for message with given ID. 119 | // options: Pagination params, ie {"limit":{"10"}, "idlte": {"10"}} 120 | func (c *Client) GetReactions(ctx context.Context, messageID string, options map[string][]string) (*ReactionsResponse, error) { 121 | if messageID == "" { 122 | return nil, errors.New("message ID is empty") 123 | } 124 | 125 | p := path.Join("messages", url.PathEscape(messageID), "reactions") 126 | 127 | var resp ReactionsResponse 128 | err := c.makeRequest(ctx, http.MethodGet, p, options, nil, &resp) 129 | return &resp, err 130 | } 131 | -------------------------------------------------------------------------------- /reaction_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func ExampleClient_SendReaction() { 13 | client := &Client{} 14 | msgID := "123" 15 | userID := "bob-1" 16 | ctx := context.Background() 17 | 18 | reaction := &Reaction{ 19 | Type: "love", 20 | ExtraData: map[string]interface{}{"my_custom_field": 123}, 21 | } 22 | _, err := client.SendReaction(ctx, reaction, msgID, userID) 23 | if err != nil { 24 | log.Fatalf("Found Error: %v", err) 25 | } 26 | } 27 | 28 | func TestChannel_SendReaction(t *testing.T) { 29 | c := initClient(t) 30 | ch := initChannel(t, c) 31 | user := randomUser(t, c) 32 | ctx := context.Background() 33 | msg := &Message{ 34 | Text: "test message", 35 | User: user, 36 | } 37 | 38 | resp, err := ch.SendMessage(ctx, msg, user.ID) 39 | require.NoError(t, err, "send message") 40 | 41 | reaction := Reaction{Type: "love"} 42 | reactionResp, err := c.SendReaction(ctx, &reaction, resp.Message.ID, user.ID) 43 | require.NoError(t, err, "send reaction") 44 | 45 | assert.Equal(t, 1, reactionResp.Message.ReactionCounts[reaction.Type], "reaction count", reaction) 46 | 47 | assert.Condition(t, reactionExistsCondition(reactionResp.Message.LatestReactions, reaction.Type), "latest reaction exists") 48 | } 49 | 50 | func reactionExistsCondition(reactions []*Reaction, searchType string) func() bool { 51 | return func() bool { 52 | for _, r := range reactions { 53 | if r.Type == searchType { 54 | return true 55 | } 56 | } 57 | return false 58 | } 59 | } 60 | 61 | func TestClient_DeleteReaction(t *testing.T) { 62 | c := initClient(t) 63 | ch := initChannel(t, c) 64 | user := randomUser(t, c) 65 | ctx := context.Background() 66 | msg := &Message{ 67 | Text: "test message", 68 | User: user, 69 | } 70 | 71 | resp, err := ch.SendMessage(ctx, msg, user.ID) 72 | require.NoError(t, err, "send message") 73 | 74 | reaction := Reaction{Type: "love"} 75 | reactionResp, err := c.SendReaction(ctx, &reaction, resp.Message.ID, user.ID) 76 | require.NoError(t, err, "send reaction") 77 | 78 | reactionResp, err = c.DeleteReaction(ctx, reactionResp.Message.ID, reaction.Type, user.ID) 79 | require.NoError(t, err, "delete reaction") 80 | 81 | assert.Equal(t, 0, reactionResp.Message.ReactionCounts[reaction.Type], "reaction count") 82 | assert.Empty(t, reactionResp.Message.LatestReactions, "latest reactions empty") 83 | } 84 | 85 | func TestClient_GetReactions(t *testing.T) { 86 | c := initClient(t) 87 | ch := initChannel(t, c) 88 | user := randomUser(t, c) 89 | ctx := context.Background() 90 | msg := &Message{ 91 | Text: "test message", 92 | User: user, 93 | } 94 | 95 | resp, err := ch.SendMessage(ctx, msg, user.ID) 96 | require.NoError(t, err, "send message") 97 | msg = resp.Message 98 | 99 | reactionsResp, err := c.GetReactions(ctx, msg.ID, nil) 100 | require.NoError(t, err, "get reactions") 101 | assert.Empty(t, reactionsResp.Reactions, "reactions empty") 102 | 103 | reaction := Reaction{Type: "love"} 104 | 105 | reactionResp, err := c.SendReaction(ctx, &reaction, msg.ID, user.ID) 106 | require.NoError(t, err, "send reaction") 107 | 108 | reactionsResp, err = c.GetReactions(ctx, reactionResp.Message.ID, nil) 109 | require.NoError(t, err, "get reactions") 110 | 111 | assert.Condition(t, reactionExistsCondition(reactionsResp.Reactions, reaction.Type), "reaction exists") 112 | } 113 | -------------------------------------------------------------------------------- /scripts/get_changelog_diff.js: -------------------------------------------------------------------------------- 1 | /* 2 | Here we're trying to parse the latest changes from CHANGELOG.md file. 3 | The changelog looks like this: 4 | 5 | ## 0.0.3 6 | - Something #3 7 | ## 0.0.2 8 | - Something #2 9 | ## 0.0.1 10 | - Something #1 11 | 12 | In this case we're trying to extract "- Something #3" since that's the latest change. 13 | */ 14 | module.exports = () => { 15 | const fs = require('fs') 16 | 17 | changelog = fs.readFileSync('CHANGELOG.md', 'utf8') 18 | releases = changelog.match(/## [?[0-9](.+)/g) 19 | 20 | current_release = changelog.indexOf(releases[0]) 21 | previous_release = changelog.indexOf(releases[1]) 22 | 23 | latest_changes = changelog.substr(current_release, previous_release - current_release) 24 | 25 | return latest_changes 26 | } 27 | -------------------------------------------------------------------------------- /testdata/helloworld.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/stream-chat-go/7e682b0b54b1d06430d38bec934db7e0c9ecac86/testdata/helloworld.jpg -------------------------------------------------------------------------------- /testdata/helloworld.txt: -------------------------------------------------------------------------------- 1 | Hello World! 2 | -------------------------------------------------------------------------------- /thread.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type QueryThreadsRequest struct { 10 | User *User `json:"user,omitempty"` 11 | UserID string `json:"user_id,omitempty"` 12 | 13 | Filter map[string]any `json:"filter,omitempty"` 14 | Sort *SortParamRequestList `json:"sort,omitempty"` 15 | Watch *bool `json:"watch,omitempty"` 16 | PagerRequest 17 | } 18 | 19 | type QueryThreadsResponse struct { 20 | Threads []ThreadResponse `json:"threads"` 21 | Response 22 | PagerResponse 23 | } 24 | 25 | type ThreadResponse struct { 26 | ChannelCID string `json:"channel_cid"` 27 | Channel *Channel `json:"channel,omitempty"` 28 | ParentMessageID string `json:"parent_message_id"` 29 | ParentMessage *MessageResponse `json:"parent_message,omitempty"` 30 | CreatedByUserID string `json:"created_by_user_id"` 31 | CreatedBy *UsersResponse `json:"created_by,omitempty"` 32 | ReplyCount int `json:"reply_count,omitempty"` 33 | ParticipantCount int `json:"participant_count,omitempty"` 34 | ActiveParticipantCount int `json:"active_participant_count,omitempty"` 35 | Participants ThreadParticipants `json:"thread_participants,omitempty"` 36 | LastMessageAt *time.Time `json:"last_message_at,omitempty"` 37 | CreatedAt *time.Time `json:"created_at"` 38 | UpdatedAt *time.Time `json:"updated_at"` 39 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 40 | Title string `json:"title"` 41 | Custom map[string]any `json:"custom"` 42 | 43 | LatestReplies []MessageResponse `json:"latest_replies,omitempty"` 44 | Read ChannelRead `json:"read,omitempty"` 45 | Draft Draft 46 | } 47 | 48 | type Thread struct { 49 | AppPK int `json:"app_pk"` 50 | 51 | ChannelCID string `json:"channel_cid"` 52 | Channel *Channel `json:"channel,omitempty"` 53 | 54 | ParentMessageID string `json:"parent_message_id"` 55 | ParentMessage *Message `json:"parent_message,omitempty"` 56 | 57 | CreatedByUserID string `json:"created_by_user_id"` 58 | CreatedBy *User `json:"created_by,omitempty"` 59 | 60 | ReplyCount int `json:"reply_count,omitempty"` 61 | ParticipantCount int `json:"participant_count,omitempty"` 62 | ActiveParticipantCount int `json:"active_participant_count,omitempty"` 63 | Participants ThreadParticipants `json:"thread_participants,omitempty"` 64 | 65 | LastMessageAt time.Time `json:"last_message_at,omitempty"` 66 | CreatedAt time.Time `json:"created_at"` 67 | UpdatedAt time.Time `json:"updated_at"` 68 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 69 | 70 | Title string `json:"title"` 71 | Custom map[string]any `json:"custom"` 72 | } 73 | 74 | type ThreadParticipant struct { 75 | AppPK int `json:"app_pk"` 76 | 77 | ChannelCID string `json:"channel_cid"` 78 | 79 | LastThreadMessageAt *time.Time `json:"last_thread_message_at"` 80 | ThreadID string `json:"thread_id,omitempty"` 81 | 82 | UserID string `json:"user_id,omitempty"` 83 | User *User `json:"user,omitempty"` 84 | 85 | CreatedAt time.Time `json:"created_at"` 86 | LeftThreadAt *time.Time `json:"left_thread_at,omitempty"` 87 | LastReadAt time.Time `json:"last_read_at"` 88 | 89 | Custom map[string]interface{} `json:"custom"` 90 | } 91 | 92 | type ThreadParticipants []*ThreadParticipant 93 | 94 | func (c *Client) QueryThreads(ctx context.Context, query *QueryThreadsRequest) (*QueryThreadsResponse, error) { 95 | var resp QueryThreadsResponse 96 | err := c.makeRequest(ctx, http.MethodPost, "threads", nil, query, &resp) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return &resp, nil 102 | } 103 | -------------------------------------------------------------------------------- /thread_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestClient_QueryThreads(t *testing.T) { 12 | c := initClient(t) 13 | ctx := context.Background() 14 | 15 | t.Run("basic query", func(t *testing.T) { 16 | membersID, ch, parentMsg, replyMsg := testThreadSetup(t, c, 3) 17 | 18 | limit := 10 19 | query := &QueryThreadsRequest{ 20 | Filter: map[string]any{ 21 | "channel_cid": map[string]any{ 22 | "$eq": ch.CID, 23 | }, 24 | }, 25 | Sort: &SortParamRequestList{ 26 | { 27 | Field: "created_at", 28 | Direction: -1, 29 | }, 30 | }, 31 | PagerRequest: PagerRequest{ 32 | Limit: &limit, 33 | }, 34 | UserID: membersID[0], 35 | } 36 | 37 | resp, err := c.QueryThreads(ctx, query) 38 | require.NoError(t, err) 39 | require.NotNil(t, resp, "response should not be nil") 40 | require.NotEmpty(t, resp.Threads, "threads should not be empty") 41 | 42 | thread := resp.Threads[0] 43 | assertThreadData(t, thread, ch, parentMsg, replyMsg) 44 | assertThreadParticipants(t, thread, ch.CreatedBy.ID) 45 | 46 | assert.Empty(t, resp.PagerResponse) 47 | }) 48 | 49 | t.Run("with pagination", func(t *testing.T) { 50 | membersID, ch, parentMsg1, replyMsg1 := testThreadSetup(t, c, 3) 51 | limit := 1 52 | 53 | // Create a second thread 54 | parentMsg2, err := ch.SendMessage(ctx, &Message{Text: "Parent message for thread 2"}, ch.CreatedBy.ID) 55 | require.NoError(t, err, "send second parent message") 56 | 57 | replyMsg2, err := ch.SendMessage(ctx, &Message{ 58 | Text: "Reply message 2", 59 | ParentID: parentMsg2.Message.ID, 60 | }, ch.CreatedBy.ID) 61 | require.NoError(t, err, "send second reply message") 62 | 63 | // First page query 64 | query := &QueryThreadsRequest{ 65 | Filter: map[string]any{ 66 | "channel_cid": map[string]any{ 67 | "$eq": ch.CID, 68 | }, 69 | }, 70 | Sort: &SortParamRequestList{ 71 | { 72 | Field: "created_at", 73 | Direction: 1, 74 | }, 75 | }, 76 | PagerRequest: PagerRequest{ 77 | Limit: &limit, 78 | }, 79 | UserID: membersID[0], 80 | } 81 | 82 | resp, err := c.QueryThreads(ctx, query) 83 | require.NoError(t, err) 84 | require.NotNil(t, resp, "response should not be nil") 85 | require.NotEmpty(t, resp.Threads, "threads should not be empty") 86 | 87 | thread := resp.Threads[0] 88 | assertThreadData(t, thread, ch, parentMsg1, replyMsg1) 89 | assertThreadParticipants(t, thread, ch.CreatedBy.ID) 90 | 91 | // Second page query 92 | query2 := &QueryThreadsRequest{ 93 | Filter: map[string]any{ 94 | "channel_cid": map[string]any{ 95 | "$eq": ch.CID, 96 | }, 97 | }, 98 | Sort: &SortParamRequestList{ 99 | { 100 | Field: "created_at", 101 | Direction: -1, 102 | }, 103 | }, 104 | PagerRequest: PagerRequest{ 105 | Limit: &limit, 106 | Next: resp.Next, 107 | }, 108 | UserID: membersID[0], 109 | } 110 | 111 | resp, err = c.QueryThreads(ctx, query2) 112 | require.NoError(t, err) 113 | require.NotNil(t, resp, "response should not be nil") 114 | require.NotEmpty(t, resp.Threads, "threads should not be empty") 115 | 116 | thread = resp.Threads[0] 117 | assertThreadData(t, thread, ch, parentMsg2, replyMsg2) 118 | assertThreadParticipants(t, thread, ch.CreatedBy.ID) 119 | }) 120 | } 121 | 122 | // testThreadSetup creates a channel with members and returns necessary test data 123 | func testThreadSetup(t *testing.T, c *Client, numMembers int) ([]string, *Channel, *MessageResponse, *MessageResponse) { 124 | membersID := randomUsersID(t, c, numMembers) 125 | ch := initChannel(t, c, membersID...) 126 | 127 | // Create a parent message 128 | parentMsg, err := ch.SendMessage(context.Background(), &Message{Text: "Parent message for thread"}, ch.CreatedBy.ID) 129 | require.NoError(t, err, "send parent message") 130 | 131 | // Create a thread by sending a reply 132 | replyMsg, err := ch.SendMessage(context.Background(), &Message{ 133 | Text: "Reply message", 134 | ParentID: parentMsg.Message.ID, 135 | }, ch.CreatedBy.ID) 136 | require.NoError(t, err, "send reply message") 137 | 138 | return membersID, ch, parentMsg, replyMsg 139 | } 140 | 141 | // assertThreadData validates common thread data fields 142 | func assertThreadData(t *testing.T, thread ThreadResponse, ch *Channel, parentMsg, replyMsg *MessageResponse) { 143 | assert.Equal(t, ch.CID, thread.ChannelCID, "channel CID should match") 144 | assert.Equal(t, parentMsg.Message.ID, thread.ParentMessageID, "parent message ID should match") 145 | assert.Equal(t, ch.CreatedBy.ID, thread.CreatedByUserID, "created by user ID should match") 146 | assert.Equal(t, 1, thread.ReplyCount, "reply count should be 1") 147 | assert.Equal(t, 1, thread.ParticipantCount, "participant count should be 1") 148 | assert.Equal(t, parentMsg.Message.Text, thread.Title, "title should not be empty") 149 | assert.Equal(t, replyMsg.Message.CreatedAt, thread.CreatedAt, "created at should not be zero") 150 | assert.Equal(t, replyMsg.Message.UpdatedAt, thread.UpdatedAt, "updated at should not be zero") 151 | assert.Nil(t, thread.DeletedAt, "deleted at should be nil") 152 | } 153 | 154 | // assertThreadParticipants validates thread participant data 155 | func assertThreadParticipants(t *testing.T, thread ThreadResponse, createdByID string) { 156 | require.Len(t, thread.Participants, 1, "should have one participant") 157 | assert.Equal(t, createdByID, thread.Participants[0].UserID, "participant user ID should match") 158 | assert.NotZero(t, thread.Participants[0].CreatedAt, "participant created at should not be zero") 159 | assert.NotZero(t, thread.Participants[0].LastReadAt, "participant last read at should not be zero") 160 | } 161 | -------------------------------------------------------------------------------- /unread_counts.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | ) 9 | 10 | type UnreadCountsChannel struct { 11 | ChannelID string `json:"channel_id"` 12 | UnreadCount int `json:"unread_count"` 13 | LastRead time.Time `json:"last_read"` 14 | } 15 | 16 | type UnreadCountsChannelType struct { 17 | ChannelType string `json:"channel_type"` 18 | ChannelCount int `json:"channel_count"` 19 | UnreadCount int `json:"unread_count"` 20 | } 21 | 22 | type UnreadCountsThread struct { 23 | UnreadCount int `json:"unread_count"` 24 | LastRead time.Time `json:"last_read"` 25 | LastReadMessageID string `json:"last_read_message_id"` 26 | ParentMessageID string `json:"parent_message_id"` 27 | } 28 | 29 | type UnreadCountsResponse struct { 30 | TotalUnreadCount int `json:"total_unread_count"` 31 | TotalUnreadThreadsCount int `json:"total_unread_threads_count"` 32 | Channels []UnreadCountsChannel `json:"channels"` 33 | ChannelType []UnreadCountsChannelType `json:"channel_type"` 34 | Threads []UnreadCountsThread `json:"threads"` 35 | Response 36 | } 37 | 38 | func (c *Client) UnreadCounts(ctx context.Context, userID string) (*UnreadCountsResponse, error) { 39 | var resp UnreadCountsResponse 40 | err := c.makeRequest(ctx, http.MethodGet, "unread", url.Values{"user_id": []string{userID}}, nil, &resp) 41 | return &resp, err 42 | } 43 | 44 | type UnreadCountsBatchResponse struct { 45 | CountsByUser map[string]*UnreadCountsResponse `json:"counts_by_user"` 46 | Response 47 | } 48 | 49 | func (c *Client) UnreadCountsBatch(ctx context.Context, userIDs []string) (*UnreadCountsBatchResponse, error) { 50 | var resp UnreadCountsBatchResponse 51 | err := c.makeRequest(ctx, http.MethodPost, "unread_batch", nil, map[string][]string{"user_ids": userIDs}, &resp) 52 | return &resp, err 53 | } 54 | -------------------------------------------------------------------------------- /unread_counts_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestUnreadCounts(t *testing.T) { 12 | c := initClient(t) 13 | user := randomUser(t, c) 14 | ch := initChannel(t, c, user.ID) 15 | 16 | ctx := context.Background() 17 | msg := &Message{Text: "test message"} 18 | randSender := randomString(5) 19 | var messageID string 20 | for i := 0; i < 5; i++ { 21 | resp, err := ch.SendMessage(ctx, msg, randSender) 22 | require.NoError(t, err) 23 | messageID = resp.Message.ID 24 | } 25 | 26 | resp, err := c.UnreadCounts(ctx, user.ID) 27 | require.NoError(t, err) 28 | require.Equal(t, 5, resp.TotalUnreadCount) 29 | require.Len(t, resp.Channels, 1) 30 | require.Equal(t, ch.CID, resp.Channels[0].ChannelID) 31 | require.Equal(t, 5, resp.Channels[0].UnreadCount) 32 | require.Len(t, resp.ChannelType, 1) 33 | require.Equal(t, strings.Split(ch.CID, ":")[0], resp.ChannelType[0].ChannelType) 34 | require.Equal(t, 5, resp.ChannelType[0].UnreadCount) 35 | 36 | // test unread threads 37 | threadMsg := &Message{Text: "test thread", ParentID: messageID} 38 | _, err = ch.SendMessage(ctx, threadMsg, user.ID) 39 | require.NoError(t, err) 40 | _, err = ch.SendMessage(ctx, threadMsg, randSender) 41 | require.NoError(t, err) 42 | 43 | resp, err = c.UnreadCounts(ctx, user.ID) 44 | require.NoError(t, err) 45 | require.Equal(t, 1, resp.TotalUnreadThreadsCount) 46 | require.Len(t, resp.Threads, 1) 47 | require.Equal(t, messageID, resp.Threads[0].ParentMessageID) 48 | } 49 | 50 | func TestUnreadCountsBatch(t *testing.T) { 51 | c := initClient(t) 52 | user1 := randomUser(t, c) 53 | user2 := randomUser(t, c) 54 | ch := initChannel(t, c, user1.ID, user2.ID) 55 | 56 | ctx := context.Background() 57 | msg := &Message{Text: "test message"} 58 | randSender := randomString(5) 59 | var messageID string 60 | for i := 0; i < 5; i++ { 61 | resp, err := ch.SendMessage(ctx, msg, randSender) 62 | require.NoError(t, err) 63 | messageID = resp.Message.ID 64 | } 65 | 66 | nonexistant := randomString(5) 67 | resp, err := c.UnreadCountsBatch(ctx, []string{user1.ID, user2.ID, nonexistant}) 68 | require.NoError(t, err) 69 | require.Len(t, resp.CountsByUser, 2) 70 | require.Contains(t, resp.CountsByUser, user1.ID) 71 | require.Contains(t, resp.CountsByUser, user2.ID) 72 | require.NotContains(t, resp.CountsByUser, nonexistant) 73 | 74 | // user 1 counts 75 | require.Equal(t, 5, resp.CountsByUser[user1.ID].TotalUnreadCount) 76 | require.Len(t, resp.CountsByUser[user1.ID].Channels, 1) 77 | require.Equal(t, ch.CID, resp.CountsByUser[user1.ID].Channels[0].ChannelID) 78 | require.Equal(t, 5, resp.CountsByUser[user1.ID].Channels[0].UnreadCount) 79 | require.Len(t, resp.CountsByUser[user1.ID].ChannelType, 1) 80 | require.Equal(t, strings.Split(ch.CID, ":")[0], resp.CountsByUser[user1.ID].ChannelType[0].ChannelType) 81 | require.Equal(t, 5, resp.CountsByUser[user1.ID].ChannelType[0].UnreadCount) 82 | 83 | // user 2 counts 84 | require.Equal(t, 5, resp.CountsByUser[user2.ID].TotalUnreadCount) 85 | require.Len(t, resp.CountsByUser[user2.ID].Channels, 1) 86 | require.Equal(t, ch.CID, resp.CountsByUser[user2.ID].Channels[0].ChannelID) 87 | require.Equal(t, 5, resp.CountsByUser[user2.ID].Channels[0].UnreadCount) 88 | require.Len(t, resp.CountsByUser[user2.ID].ChannelType, 1) 89 | require.Equal(t, strings.Split(ch.CID, ":")[0], resp.CountsByUser[user2.ID].ChannelType[0].ChannelType) 90 | require.Equal(t, 5, resp.CountsByUser[user2.ID].ChannelType[0].UnreadCount) 91 | 92 | // test unread threads 93 | threadMsg := &Message{Text: "test thread", ParentID: messageID} 94 | _, err = ch.SendMessage(ctx, threadMsg, user1.ID) 95 | require.NoError(t, err) 96 | _, err = ch.SendMessage(ctx, threadMsg, user2.ID) 97 | require.NoError(t, err) 98 | _, err = ch.SendMessage(ctx, threadMsg, randSender) 99 | require.NoError(t, err) 100 | 101 | resp, err = c.UnreadCountsBatch(ctx, []string{user1.ID, user2.ID, nonexistant}) 102 | require.NoError(t, err) 103 | 104 | // user 1 thread counts 105 | require.Equal(t, 1, resp.CountsByUser[user1.ID].TotalUnreadThreadsCount) 106 | require.Len(t, resp.CountsByUser[user1.ID].Threads, 1) 107 | require.Equal(t, messageID, resp.CountsByUser[user1.ID].Threads[0].ParentMessageID) 108 | 109 | // user 2 thread counts 110 | require.Equal(t, resp.CountsByUser[user2.ID].TotalUnreadThreadsCount, 1) 111 | require.Len(t, resp.CountsByUser[user2.ID].Threads, 1) 112 | require.Equal(t, messageID, resp.CountsByUser[user2.ID].Threads[0].ParentMessageID) 113 | } 114 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func init() { 13 | rand.Seed(time.Now().UnixNano()) 14 | 15 | if err := clearOldChannelTypes(); err != nil { 16 | panic(err) // app has bad data from previous runs 17 | } 18 | } 19 | 20 | func clearOldChannelTypes() error { 21 | c, err := NewClientFromEnvVars() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | ctx := context.Background() 27 | 28 | resp, err := c.ListChannelTypes(ctx) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | for _, ct := range resp.ChannelTypes { 34 | if contains(defaultChannelTypes, ct.Name) { 35 | continue 36 | } 37 | filter := map[string]interface{}{"type": ct.Name} 38 | resp, _ := c.QueryChannels(ctx, &QueryOption{Filter: filter}) 39 | 40 | hasChannel := false 41 | for _, ch := range resp.Channels { 42 | if _, err := ch.Delete(ctx); err != nil { 43 | hasChannel = true 44 | break 45 | } 46 | } 47 | 48 | if !hasChannel { 49 | _, _ = c.DeleteChannelType(ctx, ct.Name) 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | func randomUser(t *testing.T, c *Client) *User { 56 | t.Helper() 57 | 58 | ctx := context.Background() 59 | resp, err := c.UpsertUser(ctx, &User{ID: randomString(10)}) 60 | require.NoError(t, err) 61 | 62 | t.Cleanup(func() { 63 | _, _ = c.DeleteUsers(ctx, []string{resp.User.ID}, DeleteUserOptions{ 64 | User: HardDelete, 65 | Messages: HardDelete, 66 | Conversations: HardDelete, 67 | }) 68 | }) 69 | 70 | return resp.User 71 | } 72 | 73 | func randomUserWithRole(t *testing.T, c *Client, role string) *User { 74 | t.Helper() 75 | 76 | ctx := context.Background() 77 | resp, err := c.UpsertUser(ctx, &User{ 78 | ID: randomString(10), 79 | Role: role, 80 | }) 81 | require.NoError(t, err) 82 | 83 | t.Cleanup(func() { 84 | _, _ = c.DeleteUsers(ctx, []string{resp.User.ID}, DeleteUserOptions{ 85 | User: HardDelete, 86 | Messages: HardDelete, 87 | Conversations: HardDelete, 88 | }) 89 | }) 90 | 91 | return resp.User 92 | } 93 | 94 | func randomUsers(t *testing.T, c *Client, n int) []*User { 95 | t.Helper() 96 | 97 | users := make([]*User, 0, n) 98 | for i := 0; i < n; i++ { 99 | users = append(users, &User{ID: randomString(10)}) 100 | } 101 | 102 | resp, err := c.UpsertUsers(context.Background(), users...) 103 | require.NoError(t, err) 104 | users = users[:0] 105 | for _, user := range resp.Users { 106 | users = append(users, user) 107 | } 108 | return users 109 | } 110 | 111 | func randomUsersID(t *testing.T, c *Client, n int) []string { 112 | t.Helper() 113 | 114 | users := randomUsers(t, c, n) 115 | ids := make([]string, n) 116 | for i, u := range users { 117 | ids[i] = u.ID 118 | } 119 | return ids 120 | } 121 | 122 | func randomString(n int) string { 123 | bytes := make([]byte, n) 124 | for i := 0; i < n; i++ { 125 | bytes[i] = byte(65 + rand.Intn(25)) // A=65 and Z = 65+25 126 | } 127 | return string(bytes) 128 | } 129 | 130 | func contains(ls []string, s string) bool { 131 | for _, item := range ls { 132 | if item == s { 133 | return true 134 | } 135 | } 136 | return false 137 | } 138 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package stream_chat 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | versionMajor = 7 9 | versionMinor = 10 10 | versionPatch = 0 11 | ) 12 | 13 | // Version returns the version of the library. 14 | func Version() string { 15 | return "v" + fmtVersion() 16 | } 17 | 18 | func versionHeader() string { 19 | return "stream-go-client-" + fmtVersion() 20 | } 21 | 22 | func fmtVersion() string { 23 | return fmt.Sprintf("%d.%d.%d", 24 | versionMajor, 25 | versionMinor, 26 | versionPatch) 27 | } 28 | --------------------------------------------------------------------------------