├── .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 | [](https://github.com/GetStream/stream-chat-go/actions) 4 | [](https://pkg.go.dev/github.com/GetStream/stream-chat-go/v7?tab=doc) 5 | 6 |
7 |
8 |
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 |
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 | --------------------------------------------------------------------------------