From 38a649da29d9da0b6ff4f3f2817b5774f7f91245 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Fri, 7 Mar 2025 14:48:12 +0100 Subject: [PATCH 1/6] Added the support for message reminders feature * `create_reminder`: Create a reminder for a message * `update_reminder`: Update an existing reminder * `delete_reminder`: Delete a reminder * `query_reminders`: Query reminders with filtering options --- CHANGELOG.md | 10 ++++ README.md | 23 +++++++++ lib/stream-chat/client.rb | 50 ++++++++++++++++++++ spec/client_spec.rb | 99 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9077f0..6b50688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [Unreleased] + +### Features + +* Added support for message reminders: + * `create_reminder`: Create a reminder for a message + * `update_reminder`: Update an existing reminder + * `delete_reminder`: Delete a reminder + * `query_reminders`: Query reminders with filtering options + ## [3.10.0](https://github.com/GetStream/stream-chat-ruby/compare/v3.9.0...v3.10.0) (2025-02-24) ## [3.9.0](https://github.com/GetStream/stream-chat-ruby/compare/v3.7.0...v3.9.0) (2025-02-11) diff --git a/README.md b/README.md index a5184e7..f7d2926 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,29 @@ deleted_message = client.delete_message(m1['message']['id']) ``` +### Reminders + +```ruby +# Create a reminder for a message +reminder = client.create_reminder(m1['message']['id'], 'bob-1', DateTime.now + 1) + +# Create a reminder without a notification time (just mark for later) +reminder = client.create_reminder(m1['message']['id'], 'bob-1') + +# Update a reminder +updated_reminder = client.update_reminder(m1['message']['id'], 'bob-1', DateTime.now + 2) + +# Delete a reminder +client.delete_reminder(m1['message']['id'], 'bob-1') + +# Query reminders for a user +reminders = client.query_reminders('bob-1') + +# Query reminders with filters +filter = { 'channel_cid' => 'messaging:bob-and-jane' } +reminders = client.query_reminders('bob-1', filter) +``` + ### Devices ```ruby diff --git a/lib/stream-chat/client.rb b/lib/stream-chat/client.rb index 4abb6fe..d925e11 100644 --- a/lib/stream-chat/client.rb +++ b/lib/stream-chat/client.rb @@ -7,6 +7,7 @@ require 'faraday/net_http_persistent' require 'jwt' require 'time' +require 'date' require 'sorbet-runtime' require 'stream-chat/channel' require 'stream-chat/errors' @@ -900,6 +901,55 @@ def list_imports(options) get('imports', params: options) end + # Creates a reminder for a message. + # @param message_id [String] The ID of the message to create a reminder for + # @param user_id [String] The ID of the user creating the reminder + # @param remind_at [DateTime, nil] When to remind the user (optional) + # @return [StreamChat::StreamResponse] API response + sig { params(message_id: String, user_id: String, remind_at: T.nilable(DateTime)).returns(StreamChat::StreamResponse) } + def create_reminder(message_id, user_id, remind_at = nil) + data = { user_id: user_id } + data[:remind_at] = T.cast(remind_at, DateTime).rfc3339 if remind_at.instance_of?(DateTime) + post("messages/#{message_id}/reminders", data: data) + end + + # Updates a reminder for a message. + # @param message_id [String] The ID of the message with the reminder + # @param user_id [String] The ID of the user who owns the reminder + # @param remind_at [DateTime, nil] When to remind the user (optional) + # @return [StreamChat::StreamResponse] API response + sig { params(message_id: String, user_id: String, remind_at: T.nilable(DateTime)).returns(StreamChat::StreamResponse) } + def update_reminder(message_id, user_id, remind_at = nil) + data = { user_id: user_id } + data[:remind_at] = remind_at.rfc3339 if remind_at + patch("messages/#{message_id}/reminders", data: data) + end + + # Deletes a reminder for a message. + # @param message_id [String] The ID of the message with the reminder + # @param user_id [String] The ID of the user who owns the reminder + # @return [StreamChat::StreamResponse] API response + sig { params(message_id: String, user_id: String).returns(StreamChat::StreamResponse) } + def delete_reminder(message_id, user_id) + delete("messages/#{message_id}/reminders", params: { user_id: user_id }) + end + + # Queries reminders based on filter conditions. + # @param user_id [String] The ID of the user whose reminders to query + # @param filter_conditions [Hash] Conditions to filter reminders + # @param sort [Array, nil] Sort parameters (default: [{ field: 'remind_at', direction: 1 }]) + # @param options [Hash] Additional query options like limit, offset + # @return [StreamChat::StreamResponse] API response with reminders + sig { params(user_id: String, filter_conditions: T::Hash[T.untyped, T.untyped], sort: T.nilable(T::Array[T::Hash[T.untyped, T.untyped]]), options: T.untyped).returns(StreamChat::StreamResponse) } + def query_reminders(user_id, filter_conditions = {}, sort: nil, **options) + params = options.merge({ + filter_conditions: filter_conditions, + sort: sort || [{ field: 'remind_at', direction: 1 }], + user_id: user_id + }) + post('reminders/query', data: params) + end + private sig { returns(T::Hash[String, String]) } diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 9cd583e..5eb6847 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -846,4 +846,103 @@ def loop_times(times) end end end + + describe 'reminders' do + before do + @client = StreamChat::Client.from_env + @channel_id = SecureRandom.uuid + @channel = @client.channel('messaging', channel_id: @channel_id) + @channel.create('john') + @message = @channel.send_message({ 'text' => 'Hello world' }, 'john') + @message_id = @message['message']['id'] + @user_id = 'john' + end + + describe 'create_reminder' do + it 'create reminder' do + remind_at = DateTime.now + 1 + response = @client.create_reminder(@message_id, @user_id, remind_at) + + expect(response).to include('reminder') + expect(response['reminder']).to include('message_id', 'user_id', 'remind_at') + expect(response['reminder']['message_id']).to eq(@message_id) + expect(response['reminder']['user_id']).to eq(@user_id) + end + + it 'create reminder without remind_at' do + response = @client.create_reminder(@message_id, @user_id) + + expect(response).to include('reminder') + expect(response['reminder']).to include('message_id', 'user_id') + expect(response['reminder']['message_id']).to eq(@message_id) + expect(response['reminder']['user_id']).to eq(@user_id) + expect(response['reminder']['remind_at']).to be_nil + end + end + + describe 'update_reminder' do + before do + @client.create_reminder(@message_id, @user_id) + end + + it 'update reminder' do + + new_remind_at = DateTime.now + 2 + response = @client.update_reminder(@message_id, @user_id, new_remind_at) + + expect(response).to include('reminder') + expect(response['reminder']).to include('message_id', 'user_id', 'remind_at') + expect(response['reminder']['message_id']).to eq(@message_id) + expect(response['reminder']['user_id']).to eq(@user_id) + expect(DateTime.parse(response['reminder']['remind_at'])).to be_within(1).of(new_remind_at) + end + end + + describe 'delete_reminder' do + before do + @client.create_reminder(@message_id, @user_id) + end + + it 'delete reminder' do + response = @client.delete_reminder(@message_id, @user_id) + expect(response).to be_a(Hash) + end + end + + describe 'query_reminders' do + before do + remind_at = DateTime.now + 1 + @client.create_reminder(@message_id, @user_id, remind_at) + end + + it 'query reminders' do + # Query reminders for the user + response = @client.query_reminders(@user_id) + + expect(response).to include('reminders') + expect(response['reminders']).to be_an(Array) + expect(response['reminders'].length).to be >= 1 + + # Find our reminder + reminder = response['reminders'].find { |r| r['message_id'] == @message_id } + expect(reminder).not_to be_nil + expect(reminder['user_id']).to eq(@user_id) + end + + it 'query reminders with channel filter' do + # Query reminders for the user in a specific channel + filter = { 'channel_cid' => @channel.cid } + response = @client.query_reminders(@user_id, filter) + + expect(response).to include('reminders') + expect(response['reminders']).to be_an(Array) + expect(response['reminders'].length).to be >= 1 + + # All reminders should have a channel_cid + response['reminders'].each do |reminder| + expect(reminder).to include('channel_cid') + end + end + end + end end From 360a574e9f064f1952dc2a9d5c8e9124ca1efc0c Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Wed, 12 Mar 2025 14:14:25 +0100 Subject: [PATCH 2/6] chore: update CONTRIBUTING.md and add Makefile for development workflow * Add Docker-based development commands to CONTRIBUTING.md * Create Makefile with development, testing, and Docker-related targets * Include support for customizable Ruby version and Stream Chat URL * Add convenience commands for linting, testing, and type checking --- CONTRIBUTING.md | 29 +++++++++++++++++++++++++++- Makefile | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 Makefile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d0cd50..d24186a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,3 @@ - # :recycle: Contributing We welcome code changes that improve this library or fix a problem, please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. We are very happy to merge your code in the official repository. Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. See our license file for more details. @@ -63,6 +62,34 @@ Recommended settings: } ``` +For Docker-based development, you can use: + +```shell +$ make lint_with_docker # Run linters in Docker +$ make lint-fix_with_docker # Fix linting issues in Docker +$ make test_with_docker # Run tests in Docker +$ make check_with_docker # Run both linters and tests in Docker +$ make sorbet_with_docker # Run Sorbet type checker in Docker +``` + +You can customize the Ruby version used in Docker by setting the RUBY_VERSION variable: + +```shell +$ RUBY_VERSION=3.1 make test_with_docker +``` + +By default, the API client connects to the production Stream Chat API. You can override this by setting the STREAM_CHAT_URL environment variable: + +```shell +$ STREAM_CHAT_URL=http://localhost:3030 make test +``` + +When running tests in Docker, the `test_with_docker` command automatically sets up networking to allow the Docker container to access services running on your host machine via `host.docker.internal`. This is particularly useful for connecting to a local Stream Chat server: + +```shell +$ STREAM_CHAT_URL=http://host.docker.internal:3030 make test_with_docker +``` + ### Commit message convention This repository follows a commit message convention in order to automatically generate the [CHANGELOG](./CHANGELOG.md). Make sure you follow the rules of [conventional commits](https://www.conventionalcommits.org/) when opening a pull request. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ab2e83 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +STREAM_KEY ?= NOT_EXIST +STREAM_SECRET ?= NOT_EXIST +RUBY_VERSION ?= 3.0 +STREAM_CHAT_URL ?= https://chat.stream-io-api.com + +# These targets are not files +.PHONY: help check test lint lint-fix test_with_docker lint_with_docker lint-fix_with_docker + +help: ## Display this help message + @echo "Please use \`make \` where is one of" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; \ + {printf "\033[36m%-40s\033[0m %s\n", $$1, $$2}' + +lint: ## Run linters + bundle exec rubocop + +lint-fix: ## Fix linting issues + bundle exec rubocop -a + +test: ## Run tests + STREAM_KEY=$(STREAM_KEY) STREAM_SECRET=$(STREAM_SECRET) bundle exec rspec + +check: lint test ## Run linters + tests + +console: ## Start a console with the gem loaded + bundle exec rake console + +lint_with_docker: ## Run linters in Docker (set RUBY_VERSION to change Ruby version) + docker run -t -i -w /code -v $(PWD):/code ruby:$(RUBY_VERSION) sh -c "gem install bundler && bundle install && bundle exec rubocop" + +lint-fix_with_docker: ## Fix linting issues in Docker (set RUBY_VERSION to change Ruby version) + docker run -t -i -w /code -v $(PWD):/code ruby:$(RUBY_VERSION) sh -c "gem install bundler && bundle install && bundle exec rubocop -a" + +test_with_docker: ## Run tests in Docker (set RUBY_VERSION to change Ruby version) + docker run -t -i -w /code -v $(PWD):/code --add-host=host.docker.internal:host-gateway -e STREAM_KEY=$(STREAM_KEY) -e STREAM_SECRET=$(STREAM_SECRET) -e "STREAM_CHAT_URL=http://host.docker.internal:3030" ruby:$(RUBY_VERSION) sh -c "gem install bundler && bundle install && bundle exec rspec" + +check_with_docker: lint_with_docker test_with_docker ## Run linters + tests in Docker (set RUBY_VERSION to change Ruby version) + +sorbet: ## Run Sorbet type checker + bundle exec srb tc + +sorbet_with_docker: ## Run Sorbet type checker in Docker (set RUBY_VERSION to change Ruby version) + docker run -t -i -w /code -v $(PWD):/code ruby:$(RUBY_VERSION) sh -c "gem install bundler && bundle install && bundle exec srb tc" + +coverage: ## Generate test coverage report + COVERAGE=true bundle exec rspec + @echo "Coverage report available at ./coverage/index.html" + +reviewdog: ## Run reviewdog for CI + bundle exec rubocop --format json | reviewdog -f=rubocop -name=rubocop -reporter=github-pr-review \ No newline at end of file From 0af4c8e36e3245290985cf544808f8910308350a Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Wed, 12 Mar 2025 15:33:36 +0100 Subject: [PATCH 3/6] =?UTF-8?q?fixed=20linting=20errors=C3=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/client_spec.rb | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 5eb6847..e9ce750 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -862,7 +862,7 @@ def loop_times(times) it 'create reminder' do remind_at = DateTime.now + 1 response = @client.create_reminder(@message_id, @user_id, remind_at) - + expect(response).to include('reminder') expect(response['reminder']).to include('message_id', 'user_id', 'remind_at') expect(response['reminder']['message_id']).to eq(@message_id) @@ -871,7 +871,7 @@ def loop_times(times) it 'create reminder without remind_at' do response = @client.create_reminder(@message_id, @user_id) - + expect(response).to include('reminder') expect(response['reminder']).to include('message_id', 'user_id') expect(response['reminder']['message_id']).to eq(@message_id) @@ -884,12 +884,11 @@ def loop_times(times) before do @client.create_reminder(@message_id, @user_id) end - + it 'update reminder' do - new_remind_at = DateTime.now + 2 response = @client.update_reminder(@message_id, @user_id, new_remind_at) - + expect(response).to include('reminder') expect(response['reminder']).to include('message_id', 'user_id', 'remind_at') expect(response['reminder']['message_id']).to eq(@message_id) @@ -911,33 +910,33 @@ def loop_times(times) describe 'query_reminders' do before do - remind_at = DateTime.now + 1 - @client.create_reminder(@message_id, @user_id, remind_at) + remind_at = DateTime.now + 1 + @client.create_reminder(@message_id, @user_id, remind_at) end it 'query reminders' do # Query reminders for the user response = @client.query_reminders(@user_id) - + expect(response).to include('reminders') expect(response['reminders']).to be_an(Array) expect(response['reminders'].length).to be >= 1 - + # Find our reminder reminder = response['reminders'].find { |r| r['message_id'] == @message_id } expect(reminder).not_to be_nil expect(reminder['user_id']).to eq(@user_id) end - + it 'query reminders with channel filter' do # Query reminders for the user in a specific channel filter = { 'channel_cid' => @channel.cid } response = @client.query_reminders(@user_id, filter) - + expect(response).to include('reminders') expect(response['reminders']).to be_an(Array) expect(response['reminders'].length).to be >= 1 - + # All reminders should have a channel_cid response['reminders'].each do |reminder| expect(reminder).to include('channel_cid') From 496839b7658d54f2d82b39148c94bb7be507d773 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Wed, 11 Jun 2025 14:46:11 +0200 Subject: [PATCH 4/6] merge "master" into 'feature/snooze_message_reminder' --- .github/CODEOWNERS | 2 +- .rubocop.yml | 2 + CHANGELOG.md | 24 +++ lib/stream-chat/channel.rb | 35 ++++ lib/stream-chat/client.rb | 45 +++++- lib/stream-chat/moderation.rb | 289 ++++++++++++++++++++++++++++++++++ lib/stream-chat/version.rb | 2 +- sorbet/rbi/todo.rbi | 1 + spec/channel_spec.rb | 61 +++++++ spec/client_spec.rb | 188 +++++++++++++++++++++- spec/moderation_spec.rb | 223 ++++++++++++++++++++++++++ 11 files changed, 860 insertions(+), 12 deletions(-) create mode 100644 lib/stream-chat/moderation.rb create mode 100644 spec/moderation_spec.rb diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3bad320..3c9f9a2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @totalimmersion @akupila @guerinoni @JimmyPettersson85 @shaljam @vishalnarkhede +* @totalimmersion @akupila @guerinoni @JimmyPettersson85 @shaljam @vishalnarkhede @nijeesh-stream diff --git a/.rubocop.yml b/.rubocop.yml index 72100a7..819d3c9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -35,3 +35,5 @@ Style/FrozenStringLiteralComment: Gemspec/RequireMFA: Enabled: false +Naming/PredicateMethod: + Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b50688..154d68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [3.15.0](https://github.com/GetStream/stream-chat-ruby/compare/v3.14.0...v3.15.0) (2025-06-06) + +## [3.14.0](https://github.com/GetStream/stream-chat-ruby/compare/v3.13.0...v3.14.0) (2025-04-07) + +## [3.13.0](https://github.com/GetStream/stream-chat-ruby/compare/v3.12.0...v3.13.0) (2025-04-04) + + +### Features + +* deactivate users ([#162](https://github.com/GetStream/stream-chat-ruby/issues/162)) ([12bf4d1](https://github.com/GetStream/stream-chat-ruby/commit/12bf4d19846b5100f1886929b74c7ad05d753be4)) +* draft messages ([#161](https://github.com/GetStream/stream-chat-ruby/pull/161)) ([1719104](https://github.com/GetStream/stream-chat-ruby/commit/c7bccb3ad30721a20f32fd60eb13ab6d97a08208)) + +### Other + +* added nijeesh to code owners ([#156](https://github.com/GetStream/stream-chat-ruby/issues/156)) ([7cd4e54](https://github.com/GetStream/stream-chat-ruby/commit/7cd4e5443a8f283596b8eadc873030d737cd621c)) +* **release:** v3.12.0 ([#157](https://github.com/GetStream/stream-chat-ruby/issues/157)) ([993b7a3](https://github.com/GetStream/stream-chat-ruby/commit/993b7a30bd2222ee328ca7a862384c7baf7d53e1)) + +## [3.11.0](https://github.com/GetStream/stream-chat-ruby/compare/v3.9.0...v3.11.0) (2025-03-21) + + +### Other + +* **release:** 3.10.0 ([#151](https://github.com/GetStream/stream-chat-ruby/issues/151)) ([ac11fc1](https://github.com/GetStream/stream-chat-ruby/commit/ac11fc122ec97ffd1b2bce820efe55925e96277f)) + ## [Unreleased] ### Features diff --git a/lib/stream-chat/channel.rb b/lib/stream-chat/channel.rb index f910695..ed91ed7 100644 --- a/lib/stream-chat/channel.rb +++ b/lib/stream-chat/channel.rb @@ -360,6 +360,41 @@ def delete_image(url) @client.delete("#{self.url}/image", params: { url: url }) end + # Creates or updates a draft message for this channel. + # + # @param [StringKeyHash] message The draft message content + # @param [String] user_id The ID of the user creating/updating the draft + # @return [StreamChat::StreamResponse] + sig { params(message: StringKeyHash, user_id: String).returns(StreamChat::StreamResponse) } + def create_draft(message, user_id) + payload = { message: add_user_id(message, user_id) } + @client.post("#{url}/draft", data: payload) + end + + # Deletes a draft message for this channel. + # + # @param [String] user_id The ID of the user deleting the draft + # @param [String] parent_id Optional parent message ID for thread drafts + # @return [StreamChat::StreamResponse] + sig { params(user_id: String, parent_id: T.nilable(String)).returns(StreamChat::StreamResponse) } + def delete_draft(user_id, parent_id: nil) + params = { user_id: user_id } + params[:parent_id] = parent_id if parent_id + @client.delete("#{url}/draft", params: params) + end + + # Gets a draft message for this channel. + # + # @param [String] user_id The ID of the user getting the draft + # @param [String] parent_id Optional parent message ID for thread drafts + # @return [StreamChat::StreamResponse] + sig { params(user_id: String, parent_id: T.nilable(String)).returns(StreamChat::StreamResponse) } + def get_draft(user_id, parent_id: nil) + params = { user_id: user_id } + params[:parent_id] = parent_id if parent_id + @client.get("#{url}/draft", params: params) + end + private sig { params(payload: StringKeyHash, user_id: String).returns(StringKeyHash) } diff --git a/lib/stream-chat/client.rb b/lib/stream-chat/client.rb index d925e11..b9a27ad 100644 --- a/lib/stream-chat/client.rb +++ b/lib/stream-chat/client.rb @@ -15,6 +15,7 @@ require 'stream-chat/version' require 'stream-chat/util' require 'stream-chat/types' +require 'stream-chat/moderation' module StreamChat DEFAULT_BLOCKLIST = 'profanity_en_2020_v1' @@ -36,6 +37,9 @@ class Client sig { returns(Faraday::Connection) } attr_reader :conn + sig { returns(Moderation) } + attr_reader :moderation + # initializes a Stream Chat API Client # # @param [string] api_key your application api_key @@ -65,6 +69,7 @@ def initialize(api_key, api_secret, timeout = nil, **options) end end @conn = T.let(conn, Faraday::Connection) + @moderation = T.let(Moderation.new(self), Moderation) end # initializes a Stream Chat API Client from STREAM_KEY and STREAM_SECRET @@ -178,9 +183,9 @@ def review_flag_report(report_id, review_result, user_id, **details) end # Returns a message. - sig { params(id: String).returns(StreamChat::StreamResponse) } - def get_message(id) - get("messages/#{id}") + sig { params(id: String, options: T.untyped).returns(StreamChat::StreamResponse) } + def get_message(id, **options) + get("messages/#{id}", params: options) end # Searches for messages. @@ -276,6 +281,14 @@ def deactivate_user(user_id, **options) post("users/#{user_id}/deactivate", params: options) end + # Deactivates a users + sig { params(user_ids: T::Array[String], options: T.untyped).returns(StreamChat::StreamResponse) } + def deactivate_users(user_ids, **options) + raise ArgumentError, 'user_ids should not be empty' if user_ids.empty? + + post('users/deactivate', data: { user_ids: user_ids, **options }) + end + # Reactivates a deactivated user. Use deactivate_user to deactivate a user. sig { params(user_id: String, options: T.untyped).returns(StreamChat::StreamResponse) } def reactivate_user(user_id, **options) @@ -779,6 +792,22 @@ def create_command(command) post('commands', data: command) end + # Queries draft messages for the current user. + # + # @param [String] user_id The ID of the user to query drafts for + # @param [StringKeyHash] filter Optional filter conditions for the query + # @param [Array] sort Optional sort parameters + # @param [Hash] options Additional query options + # @return [StreamChat::StreamResponse] + sig { params(user_id: String, filter: T.nilable(StringKeyHash), sort: T.nilable(T::Array[StringKeyHash]), options: T.untyped).returns(StreamChat::StreamResponse) } + def query_drafts(user_id, filter: nil, sort: nil, **options) + data = { user_id: user_id } + data['filter'] = filter if filter + data['sort'] = sort if sort + data.merge!(options) if options + post('drafts/query', data: data) + end + # Gets a comamnd. sig { params(name: String).returns(StreamChat::StreamResponse) } def get_command(name) @@ -901,6 +930,16 @@ def list_imports(options) get('imports', params: options) end + sig { params(filter: StringKeyHash, sort: T.nilable(T::Hash[String, Integer]), options: T.untyped).returns(StreamChat::StreamResponse) } + def query_threads(filter, sort: nil, **options) + params = {}.merge(options).merge({ + filter: filter, + sort: StreamChat.get_sort_fields(sort) + }) + + post('threads', data: params) + end + # Creates a reminder for a message. # @param message_id [String] The ID of the message to create a reminder for # @param user_id [String] The ID of the user creating the reminder diff --git a/lib/stream-chat/moderation.rb b/lib/stream-chat/moderation.rb new file mode 100644 index 0000000..6802f5c --- /dev/null +++ b/lib/stream-chat/moderation.rb @@ -0,0 +1,289 @@ +# typed: strict +# frozen_string_literal: true + +require 'stream-chat/client' +require 'stream-chat/errors' +require 'stream-chat/util' +require 'stream-chat/types' + +module StreamChat + # Moderation class provides all the endpoints related to moderation v2 + class Moderation + extend T::Sig + + MODERATION_ENTITY_TYPES = T.let( + { + user: 'stream:user', + message: 'stream:chat:v1:message', + userprofile: 'stream:v1:user_profile' + }.freeze, + T::Hash[Symbol, String] + ) + + sig { params(client: Client).void } + def initialize(client) + @client = client + end + + # Experimental: Check user profile + # + # Warning: This is an experimental feature and the API is subject to change. + # + # This function is used to check a user profile for moderation. + # This will not create any review queue items for the user profile. + # You can just use this to check whether to allow a certain user profile to be created or not. + # + # @param [string] user_id User ID to be checked + # @param [Hash] profile Profile data to be checked + # @option profile [String] :username Username to be checked + # @option profile [String] :image Image URL to be checked + # @return [StreamChat::StreamResponse] + # + # example: + # client.moderation.check_user_profile('user-id', {username: 'bad_username', image: 'https://example.com/profile.jpg'}) + sig do + params( + user_id: String, + profile: T::Hash[Symbol, T.nilable(String)] + ).returns(StreamChat::StreamResponse) + end + def check_user_profile(user_id, profile) + raise ArgumentError, 'Either username or image must be provided' if profile[:username].nil? && profile[:image].nil? + + moderation_payload = {} + moderation_payload[:texts] = [profile[:username]] if profile[:username] + moderation_payload[:images] = [profile[:image]] if profile[:image] + + check( + T.must(MODERATION_ENTITY_TYPES[:userprofile]), + user_id, + moderation_payload, + 'user_profile:default', + entity_creator_id: user_id, + options: { + force_sync: true, + test_mode: true + } + ) + end + + # Flags a user with a reason + # + # @param [string] flagged_user_id User ID to be flagged + # @param [string] reason Reason for flagging the user + # @param [Hash] options Additional options for flagging the user + # @option options [String] :user_id User ID of the user who is flagging the target user + # @option options [Hash] :custom Additional data to be stored with the flag + sig { params(flagged_user_id: String, reason: String, options: T.untyped).returns(StreamChat::StreamResponse) } + def flag_user(flagged_user_id, reason, **options) + flag(T.must(MODERATION_ENTITY_TYPES[:user]), flagged_user_id, reason, **options) + end + + # Flags a message with a reason + # + # @param [string] message_id Message ID to be flagged + # @param [string] reason Reason for flagging the message + # @param [Hash] options Additional options for flagging the message + # @option options [String] :user_id User ID of the user who is flagging the target message + # @option options [Hash] :custom Additional data to be stored with the flag + sig { params(message_id: String, reason: String, options: T.untyped).returns(StreamChat::StreamResponse) } + def flag_message(message_id, reason, **options) + flag(T.must(MODERATION_ENTITY_TYPES[:message]), message_id, reason, **options) + end + + # Flags an entity with a reason + # + # @param [string] entity_type Entity type to be flagged + # @param [string] entity_id Entity ID to be flagged + # @param [string] reason Reason for flagging the entity + # @param [string] entity_creator_id User ID of the entity creator (optional) + # @param [Hash] options Additional options for flagging the entity + # @option options [String] :user_id User ID of the user who is flagging the target entity + # @option options [Hash] :moderation_payload Content to be flagged + # @option options [Hash] :custom Additional data to be stored with the flag + sig { params(entity_type: String, entity_id: String, reason: String, entity_creator_id: String, options: T.untyped).returns(StreamChat::StreamResponse) } + def flag(entity_type, entity_id, reason, entity_creator_id: '', **options) + @client.post('api/v2/moderation/flag', data: { + entity_type: entity_type, + entity_id: entity_id, + entity_creator_id: entity_creator_id, + reason: reason, + **options + }) + end + + # Mutes a user + # + # @param [string] target_id User ID to be muted + # @param [Hash] options Additional options for muting the user + # @option options [String] :user_id User ID of the user who is muting the target user + # @option options [Integer] :timeout Timeout for the mute in minutes + sig { params(target_id: String, options: T.untyped).returns(StreamChat::StreamResponse) } + def mute_user(target_id, **options) + @client.post('api/v2/moderation/mute', data: { + target_ids: [target_id], + **options + }) + end + + # Unmutes a user + # + # @param [string] target_id User ID to be unmuted + # @param [Hash] options Additional options for unmuting the user + # @option options [String] :user_id User ID of the user who is unmuting the target user + sig { params(target_id: String, options: T.untyped).returns(StreamChat::StreamResponse) } + def unmute_user(target_id, **options) + @client.post('api/v2/moderation/unmute', data: { + target_ids: [target_id], + **options + }) + end + + # Gets moderation report for a user + # + # @param [string] user_id User ID for which moderation report is to be fetched + # @param [Hash] options Additional options for fetching the moderation report + # @option options [Boolean] :create_user_if_not_exists Create user if not exists + # @option options [Boolean] :include_user_blocks Include user blocks + # @option options [Boolean] :include_user_mutes Include user mutes + sig { params(user_id: String, options: T.untyped).returns(StreamChat::StreamResponse) } + def get_user_moderation_report(user_id, **options) + @client.get('api/v2/moderation/user_report', params: { + user_id: user_id, + **options + }) + end + + # Queries review queue + # + # @param [Hash] filter_conditions Filter conditions for querying review queue + # @param [Array] sort Sort conditions for querying review queue + # @param [Hash] options Pagination options for querying review queue + sig { params(filter_conditions: T.untyped, sort: T.untyped, options: T.untyped).returns(StreamChat::StreamResponse) } + def query_review_queue(filter_conditions = {}, sort = [], **options) + @client.post('api/v2/moderation/review_queue', data: { + filter: filter_conditions, + sort: StreamChat.get_sort_fields(sort), + **options + }) + end + + # Upserts moderation config + # + # @param [Hash] config Moderation config to be upserted + sig { params(config: T.untyped).returns(StreamChat::StreamResponse) } + def upsert_config(config) + @client.post('api/v2/moderation/config', data: config) + end + + # Gets moderation config + # + # @param [string] key Key for which moderation config is to be fetched + # @param [Hash] data Additional data + # @option data [String] :team Team name + sig { params(key: String, data: T.untyped).returns(StreamChat::StreamResponse) } + def get_config(key, data = {}) + @client.get("api/v2/moderation/config/#{key}", params: data) + end + + # Deletes moderation config + # + # @param [string] key Key for which moderation config is to be deleted + # @param [Hash] data Additional data + # @option data [String] :team Team name + sig { params(key: String, data: T.untyped).returns(StreamChat::StreamResponse) } + def delete_config(key, data = {}) + @client.delete("api/v2/moderation/config/#{key}", params: data) + end + + # Queries moderation configs + # + # @param [Hash] filter_conditions Filter conditions for querying moderation configs + # @param [Array] sort Sort conditions for querying moderation configs + # @param [Hash] options Additional options for querying moderation configs + sig { params(filter_conditions: T.untyped, sort: T.untyped, options: T.untyped).returns(StreamChat::StreamResponse) } + def query_configs(filter_conditions, sort, **options) + @client.post('api/v2/moderation/configs', data: { + filter: filter_conditions, + sort: sort, + **options + }) + end + + # Submits a moderation action + # + # @param [string] action_type Type of action to submit + # @param [string] item_id ID of the item to submit action for + # @param [Hash] options Additional options for submitting the action + sig { params(action_type: String, item_id: String, options: T.untyped).returns(StreamChat::StreamResponse) } + def submit_action(action_type, item_id, **options) + @client.post('api/v2/moderation/submit_action', data: { + action_type: action_type, + item_id: item_id, + **options + }) + end + + # rubocop:disable Metrics/ParameterLists + # Checks content for moderation + # + # @param [string] entity_type Type of entity to be checked E.g., stream:user, stream:chat:v1:message, or any custom string + # @param [string] entity_id ID of the entity to be checked. This is mainly for tracking purposes + # @param [string] entity_creator_id ID of the entity creator + # @param [Hash] moderation_payload Content to be checked for moderation + # @option moderation_payload [Array] :texts Array of texts to be checked for moderation + # @option moderation_payload [Array] :images Array of images to be checked for moderation + # @option moderation_payload [Array] :videos Array of videos to be checked for moderation + # @option moderation_payload [Hash] :custom Additional custom data + # @param [string] config_key Key of the moderation config to use + # @param [Hash] options Additional options + # @option options [Boolean] :force_sync Force synchronous check + sig do + params( + entity_type: String, + entity_id: String, + moderation_payload: T::Hash[Symbol, T.any(T::Array[String], T::Hash[String, T.untyped])], + config_key: String, + entity_creator_id: String, + options: T::Hash[Symbol, T::Boolean] + ).returns(StreamChat::StreamResponse) + end + def check(entity_type, entity_id, moderation_payload, config_key, entity_creator_id: '', options: {}) + @client.post('api/v2/moderation/check', data: { + entity_type: entity_type, + entity_id: entity_id, + entity_creator_id: entity_creator_id, + moderation_payload: moderation_payload, + config_key: config_key, + options: options + }) + end + # rubocop:enable Metrics/ParameterLists + # Adds custom flags to an entity + # + # @param [string] entity_type Type of entity to be checked + # @param [string] entity_id ID of the entity to be checked + # @param [string] entity_creator_id ID of the entity creator + # @param [Hash] moderation_payload Content to be checked for moderation + # @param [Array] flags Array of custom flags to add + sig { params(entity_type: String, entity_id: String, moderation_payload: T.untyped, flags: T::Array[T.untyped], entity_creator_id: String).returns(StreamChat::StreamResponse) } + def add_custom_flags(entity_type, entity_id, moderation_payload, flags, entity_creator_id: '') + @client.post('api/v2/moderation/custom_check', data: { + entity_type: entity_type, + entity_id: entity_id, + entity_creator_id: entity_creator_id, + moderation_payload: moderation_payload, + flags: flags + }) + end + + # Adds custom flags to a message + # + # @param [string] message_id Message ID to be flagged + # @param [Array] flags Array of custom flags to add + sig { params(message_id: String, flags: T::Array[T.untyped]).returns(StreamChat::StreamResponse) } + def add_custom_message_flags(message_id, flags) + add_custom_flags(T.must(MODERATION_ENTITY_TYPES[:message]), message_id, {}, flags) + end + end +end diff --git a/lib/stream-chat/version.rb b/lib/stream-chat/version.rb index 65e2e6a..aaa9906 100644 --- a/lib/stream-chat/version.rb +++ b/lib/stream-chat/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module StreamChat - VERSION = '3.10.0' + VERSION = '3.15.0' end diff --git a/sorbet/rbi/todo.rbi b/sorbet/rbi/todo.rbi index ad1daaf..de1a2a7 100644 --- a/sorbet/rbi/todo.rbi +++ b/sorbet/rbi/todo.rbi @@ -8,3 +8,4 @@ module T::CompatibilityPatches::RSpecCompatibility::MethodDoubleExtensions; end module T::CompatibilityPatches::RSpecCompatibility::RecorderExtensions; end module T::Private::Methods::MethodHooks; end module T::Private::Methods::SingletonMethodHooks; end +class Prism::LexCompat::Result; end \ No newline at end of file diff --git a/spec/channel_spec.rb b/spec/channel_spec.rb index e5873c6..0962463 100644 --- a/spec/channel_spec.rb +++ b/spec/channel_spec.rb @@ -430,4 +430,65 @@ def loop_times(times) # Verify the custom field was unset expect(updated_msg['message']).not_to include 'custom_field' end + + it 'can create draft message' do + draft_message = { 'text' => 'This is a draft message' } + response = @channel.create_draft(draft_message, @random_user[:id]) + + expect(response).to include 'draft' + expect(response['draft']['message']['text']).to eq 'This is a draft message' + expect(response['draft']['channel_cid']).to eq @channel.cid + end + + it 'can get draft message' do + # First create a draft + draft_message = { 'text' => 'This is a draft to retrieve' } + @channel.create_draft(draft_message, @random_user[:id]) + + # Then get the draft + response = @channel.get_draft(@random_user[:id]) + + expect(response).to include 'draft' + expect(response['draft']['message']['text']).to eq 'This is a draft to retrieve' + expect(response['draft']['channel_cid']).to eq @channel.cid + end + + it 'can delete draft message' do + # First create a draft + draft_message = { 'text' => 'This is a draft to delete' } + @channel.create_draft(draft_message, @random_user[:id]) + + # Then delete the draft + @channel.delete_draft(@random_user[:id]) + + # Verify it's deleted by trying to get it + expect { @channel.get_draft(@random_user[:id]) }.to raise_error(StreamChat::StreamAPIException) + end + + it 'can create and manage thread draft' do + # First create a parent message + msg = @channel.send_message({ 'text' => 'Parent message' }, @random_user[:id]) + parent_id = msg['message']['id'] + + # Create a draft reply + draft_reply = { 'text' => 'This is a draft reply', 'parent_id' => parent_id } + response = @channel.create_draft(draft_reply, @random_user[:id]) + + expect(response).to include 'draft' + expect(response['draft']['message']['text']).to eq 'This is a draft reply' + expect(response['draft']['parent_id']).to eq parent_id + + # Get the draft reply + response = @channel.get_draft(@random_user[:id], parent_id: parent_id) + + expect(response).to include 'draft' + expect(response['draft']['message']['text']).to eq 'This is a draft reply' + expect(response['draft']['parent_id']).to eq parent_id + + # Delete the draft reply + @channel.delete_draft(@random_user[:id], parent_id: parent_id) + + # Verify it's deleted + expect { @channel.get_draft(@random_user[:id], parent_id: parent_id) }.to raise_error(StreamChat::StreamAPIException) + end end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index e9ce750..e93a6de 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -127,6 +127,19 @@ def loop_times(times) expect(response['users']).to include user[:id] end + it 'creates a user with team and teams_role' do + user = { + id: SecureRandom.uuid, + team: 'blue', + teams_role: { 'blue' => 'admin' } + } + response = @client.update_user(user) + expect(response).to include 'users' + expect(response['users']).to include user[:id] + expect(response['users'][user[:id]]['team']).to eq 'blue' + expect(response['users'][user[:id]]['teams_role']['blue']).to eq 'admin' + end + it 'updates multiple users' do users = [{ id: SecureRandom.uuid }, { id: SecureRandom.uuid }] response = @client.update_users(users) @@ -151,6 +164,22 @@ def loop_times(times) expect(response['users'][user_id]['field']).to eq('updated') end + it 'makes partial user update with team and teams_role' do + user_id = SecureRandom.uuid + @client.update_user({ id: user_id, name: 'Test User' }) + + response = @client.update_user_partial({ + id: user_id, + set: { + teams: ['blue'], + teams_role: { 'blue' => 'admin' } + } + }) + + expect(response['users'][user_id]['teams']).to eq(['blue']) + expect(response['users'][user_id]['teams_role']['blue']).to eq('admin') + end + it 'deletes a user' do response = @client.delete_user(@random_user[:id]) expect(response).to include 'user' @@ -169,6 +198,20 @@ def loop_times(times) expect(response['user']['id']).to eq(@random_user[:id]) end + it 'deactivates multiple users' do + response = @client.deactivate_users([@random_users[0][:id], @random_users[1][:id]]) + expect(response).to include 'task_id' + expect(response['task_id']).not_to be_empty + end + + it 'raises an error if user_ids is not an array' do + expect { @client.deactivate_users('not an array') }.to raise_error(TypeError) + end + + it 'raises an error if user_ids is empty' do + expect { @client.deactivate_users([]) }.to raise_error(ArgumentError) + end + it 'reactivates a user' do @client.deactivate_user(@random_user[:id]) response = @client.reactivate_user(@random_user[:id]) @@ -266,14 +309,40 @@ def loop_times(times) @client.mark_all_read(@random_user[:id]) end - it 'gets message by id' do - msg_id = SecureRandom.uuid - message = @channel.send_message({ - 'id' => msg_id, - 'text' => 'Hello world' - }, @random_user[:id])[:message] + describe '#get_message' do + # runs before all tests in this describe block once + before(:all) do + @user_id = SecureRandom.uuid + @msg_id = SecureRandom.uuid + @channel.send_message({ + 'id' => @msg_id, + 'text' => 'This is not deleted' + }, @user_id) + @deleted_msg_id = SecureRandom.uuid + @channel.send_message({ + 'id' => @deleted_msg_id, + 'text' => 'This is deleted' + }, @user_id) + @client.delete_message(@deleted_msg_id) + end + + it 'gets message by id' do + response = @client.get_message(@msg_id) + message = response['message'] + expect(message['id']).to eq(@msg_id) + end - expect(@client.get_message(msg_id)[:message]).to eq(message) + it 'gets deleted message when show_deleted_message is true' do + response = @client.get_message(@deleted_msg_id, show_deleted_message: true) + message = response['message'] + expect(message['id']).to eq(@deleted_msg_id) + end + + it 'also it gets non-deleted message when show_deleted_message is true' do + response = @client.get_message(@msg_id, show_deleted_message: true) + message = response['message'] + expect(message['id']).to eq(@msg_id) + end end it 'pins and unpins a message' do @@ -756,6 +825,51 @@ def loop_times(times) list_resp = @client.list_imports({ limit: 1 }) expect(list_resp['import_tasks'].length).to eq 1 end + + it 'can query drafts' do + # Create multiple drafts in different channels + draft1 = { 'text' => 'Draft in channel 1' } + @channel.create_draft(draft1, @random_user[:id]) + + # Create another channel with a draft + channel2 = @client.channel('messaging', data: { 'members' => @random_users.map { |u| u[:id] } }) + channel2.create(@random_user[:id]) + + draft2 = { 'text' => 'Draft in channel 2' } + channel2.create_draft(draft2, @random_user[:id]) + + # Sort by created_at + sort = [{ 'field' => 'created_at', 'direction' => 1 }] + response = @client.query_drafts(@random_user[:id], sort: sort) + expect(response['drafts']).not_to be_empty + expect(response['drafts'].length).to eq(2) + expect(response['drafts'][0]['channel']['id']).to eq(@channel.id) + expect(response['drafts'][1]['channel']['id']).to eq(channel2.id) + + # Query for a specific channel + response = @client.query_drafts(@random_user[:id], filter: { 'channel_cid' => @channel.cid }) + expect(response['drafts']).not_to be_empty + expect(response['drafts'].length).to eq(1) + expect(response['drafts'][0]['channel']['id']).to eq(@channel.id) + + # Query all drafts for the user + response = @client.query_drafts(@random_user[:id]) + expect(response['drafts']).not_to be_empty + expect(response['drafts'].length).to eq(2) + + # Paginate + response = @client.query_drafts(@random_user[:id], sort: sort, limit: 1) + expect(response['drafts']).not_to be_empty + expect(response['drafts'].length).to eq(1) + expect(response['drafts'][0]['channel']['id']).to eq(@channel.id) + + # Cleanup + begin + channel2.delete + rescue StandardError + # Ignore errors if channel is already deleted + end + end end describe 'permissions' do @@ -847,6 +961,66 @@ def loop_times(times) end end + describe '#query_threads' do + before(:all) do + # Create a dedicated random user for this block + @thread_test_user = { id: SecureRandom.uuid } + @client.upsert_users([@thread_test_user]) + + # Create a channel and send a message to create a thread + @thread_channel = @client.channel('messaging', channel_id: SecureRandom.uuid, data: { test: true }) + @thread_channel.create(@thread_test_user[:id]) + + # Send a message to create a thread + @thread_message = @thread_channel.send_message({ text: 'Thread parent message' }, @thread_test_user[:id]) + + # Send a reply to create a thread + @thread_channel.send_message({ text: 'Thread reply', parent_id: @thread_message['message']['id'] }, @thread_test_user[:id]) + end + + after(:all) do + @thread_channel.delete + @client.delete_user(@thread_test_user[:id]) + end + + it 'queries threads with filter' do + filter = { + 'created_by_user_id' => { '$eq' => @thread_test_user[:id] } + } + + response = @client.query_threads(filter, user_id: @thread_test_user[:id]) + + expect(response).to include 'threads' + expect(response['threads'].length).to be >= 1 + end + + it 'queries threads with sort' do + sort = { + 'created_at' => -1 + } + + response = @client.query_threads({}, sort: sort, user_id: @thread_test_user[:id]) + + expect(response).to include 'threads' + expect(response['threads'].length).to be >= 1 + end + + it 'queries threads with both filter and sort' do + filter = { + 'created_by_user_id' => { '$eq' => @thread_test_user[:id] } + } + + sort = { + 'created_at' => -1 + } + + response = @client.query_threads(filter, sort: sort, user_id: @thread_test_user[:id]) + + expect(response).to include 'threads' + expect(response['threads'].length).to be >= 1 + end + end + describe 'reminders' do before do @client = StreamChat::Client.from_env diff --git a/spec/moderation_spec.rb b/spec/moderation_spec.rb new file mode 100644 index 0000000..20b05ce --- /dev/null +++ b/spec/moderation_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require 'jwt' +require 'securerandom' +require 'stream-chat' +require 'faraday' + +describe StreamChat::Moderation do + def loop_times(times) + loop do + begin + yield() + return + rescue StandardError, RSpec::Expectations::ExpectationNotMetError + raise if times.zero? + end + + sleep(1) + times -= 1 + end + end + + before(:all) do + @client = StreamChat::Client.from_env + + @created_users = [] + + @fellowship_of_the_ring = [ + { id: 'frodo-baggins', name: 'Frodo Baggins', race: 'Hobbit', age: 50 }, + { id: 'sam-gamgee', name: 'Samwise Gamgee', race: 'Hobbit', age: 38 }, + { id: 'gandalf', name: 'Gandalf the Grey', race: 'Istari' }, + { id: 'legolas', name: 'Legolas', race: 'Elf', age: 500 } + ] + @client.upsert_users(@fellowship_of_the_ring) + @channel = @client.channel('team', channel_id: 'fellowship-of-the-ring', + data: { members: @fellowship_of_the_ring.map { |fellow| fellow[:id] } }) + @channel.create('gandalf') + end + + before(:each) do + @random_users = [{ id: SecureRandom.uuid }, { id: SecureRandom.uuid }] + @random_user = { id: SecureRandom.uuid } + users_to_insert = [@random_users[0], @random_users[1], @random_user] + + @created_users.push(*users_to_insert.map { |u| u[:id] }) + + @client.upsert_users(users_to_insert) + end + + after(:all) do + curr_idx = 0 + batch_size = 25 + + slice = @created_users.slice(0, batch_size) + + while !slice.nil? && !slice.empty? + @client.delete_users(slice, user: StreamChat::HARD_DELETE, messages: StreamChat::HARD_DELETE) + + curr_idx += batch_size + slice = @created_users.slice(curr_idx, batch_size) + end + end + + it 'properly sets up a new client' do + client = StreamChat::Client.from_env + + client.set_http_client(Faraday.new(url: 'https://getstream.io')) + expect { client.get_app_settings }.to raise_error(StreamChat::StreamAPIException) + + client.set_http_client(Faraday.new(url: 'https://chat.stream-io-api.com')) + response = client.get_app_settings + expect(response).to include 'app' + end + + it 'raises ArgumentError if no api_key is provided' do + expect { StreamChat::Client.new(nil, nil) }.to raise_error(TypeError) + end + + it 'properly handles stream response class' do + response = @client.get_app_settings + expect(response.rate_limit.limit).to be > 0 + expect(response.rate_limit.remaining).to be > 0 + expect(response.rate_limit.reset).to be_within(120).of Time.now.utc + expect(response.status_code).to be 200 + expect(response.to_json).not_to include 'rate_limit' + expect(response.to_json).not_to include 'status_code' + end + + describe 'moderation' do + before(:each) do + @moderation = @client.moderation + @test_user_id = SecureRandom.uuid + @test_message_id = SecureRandom.uuid + @test_config_key = SecureRandom.uuid + end + + it 'flagging a user and message' do + msg_response = @channel.send_message({ id: @test_message_id, text: 'Test message' }, @test_user_id) + expect(msg_response['message']['id']).to eq(@test_message_id) + expect(msg_response['message']['user']['id']).to eq(@test_user_id) + response = @moderation.flag_user( + @test_user_id, + 'inappropriate_behavior', + user_id: @random_user[:id], + custom: { severity: 'high' } + ) + expect(response['duration']).not_to be_nil + response = @moderation.flag_message( + @test_message_id, + 'inappropriate_content', + user_id: @random_user[:id], + custom: { category: 'spam' } + ) + expect(response['duration']).not_to be_nil + end + + it 'mute a user and unmute a user' do + @channel.send_message({ id: @test_message_id, text: 'Test message' }, @test_user_id) + testuserid1 = @random_user[:id] + response = @moderation.mute_user( + @test_user_id, + user_id: testuserid1, + timeout: 60 + ) + expect(response['duration']).not_to be_nil + expect(response['mutes'][0]['user']['id']).to eq(testuserid1) + response = @moderation.unmute_user( + @test_user_id, + user_id: @random_user[:id] + ) + expect(response['duration']).not_to be_nil + + response = @moderation.get_user_moderation_report( + @test_user_id, + include_user_blocks: true, + include_user_mutes: true + ) + expect(response['duration']).not_to be_nil + end + + it 'adds custom flags to an entity' do + testuserid1 = @random_user[:id] + testmsgid1 = SecureRandom.uuid + @channel.send_message({ id: testmsgid1, text: 'Test message' }, testuserid1) + entity_type = 'stream:chat:v1:message' + entity_id = testmsgid1 + moderation_payload = { + 'texts' => ['Test message'], + 'custom' => { 'original_message_type' => 'regular' } + } + flags = [{ type: 'custom_check_text', value: 'test_flag' }] + + response = @moderation.add_custom_flags(entity_type, entity_id, moderation_payload, flags, entity_creator_id: testuserid1) + expect(response['duration']).not_to be_nil + response = @moderation.add_custom_message_flags( + testmsgid1, + [{ type: 'custom_check_text', value: 'test_flag' }] + ) + expect(response['duration']).not_to be_nil + end + + it 'check user profile' do + response = @moderation.check_user_profile( + @test_user_id, + { username: 'fuck_you_123' } + ) + expect(response['duration']).not_to be_nil + expect(response['status']).to eq('complete') + expect(response['recommended_action']).to eq('remove') + + response = @moderation.check_user_profile( + @test_user_id, + { username: 'hi' } + ) + expect(response['duration']).not_to be_nil + expect(response['status']).to eq('complete') + expect(response['recommended_action']).to eq('keep') + end + + it 'config test' do + # Create moderation config + moderation_config = { + key: "chat:team:#{@channel.id}", + block_list_config: { + enabled: true, + rules: [ + { + name: 'profanity_en_2020_v1', + action: 'flag' + } + ] + } + } + @moderation.upsert_config(moderation_config) + response = @moderation.get_config("chat:team:#{@channel.id}") + expect(response['config']['key']).to eq("chat:team:#{@channel.id}") + + response = @moderation.query_configs( + { key: "chat:messaging:#{@channel.id}" }, + [] + ) + expect(response).not_to be_nil + + # Send message that should be blocked + response = @channel.send_message( + { text: 'damn' }, + @random_user[:id], + force_moderation: true + ) + + # Verify message appears in review queue + queue_response = @moderation.query_review_queue( + { entity_type: 'stream:chat:v1:message' }, + { created_at: -1 }, + limit: 1 + ) + expect(queue_response['items'][0]['entity_id']).to eq(response['message']['id']) + + response = @moderation.delete_config("chat:team:#{@channel.id}") + expect(response['duration']).not_to be_nil + end + end +end From dd4e71d12d8ded790af0016b22c0deab43962bc2 Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Thu, 12 Jun 2025 11:57:40 +0200 Subject: [PATCH 5/6] fixed failing specs --- spec/client_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/client_spec.rb b/spec/client_spec.rb index a3b86ca..3383693 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -1087,6 +1087,7 @@ def loop_times(times) @channel_id = SecureRandom.uuid @channel = @client.channel('messaging', channel_id: @channel_id) @channel.create('john') + @channel.update_partial({config_overrides: {user_message_reminders: true}}) @message = @channel.send_message({ 'text' => 'Hello world' }, 'john') @message_id = @message['message']['id'] @user_id = 'john' From ce5bd49a8daf4adcd7af3d18ad1662a5cb26107e Mon Sep 17 00:00:00 2001 From: nijeeshjoshy Date: Mon, 16 Jun 2025 21:51:51 +0200 Subject: [PATCH 6/6] fixed duplicate method definition --- lib/stream-chat/client.rb | 10 ------- spec/client_spec.rb | 60 --------------------------------------- 2 files changed, 70 deletions(-) diff --git a/lib/stream-chat/client.rb b/lib/stream-chat/client.rb index 89e0893..b9a27ad 100644 --- a/lib/stream-chat/client.rb +++ b/lib/stream-chat/client.rb @@ -940,16 +940,6 @@ def query_threads(filter, sort: nil, **options) post('threads', data: params) end - sig { params(filter: StringKeyHash, sort: T.nilable(T::Hash[String, Integer]), options: T.untyped).returns(StreamChat::StreamResponse) } - def query_threads(filter, sort: nil, **options) - params = {}.merge(options).merge({ - filter: filter, - sort: StreamChat.get_sort_fields(sort) - }) - - post('threads', data: params) - end - # Creates a reminder for a message. # @param message_id [String] The ID of the message to create a reminder for # @param user_id [String] The ID of the user creating the reminder diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 3383693..6ca8ed8 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -1021,66 +1021,6 @@ def loop_times(times) end end - describe '#query_threads' do - before(:all) do - # Create a dedicated random user for this block - @thread_test_user = { id: SecureRandom.uuid } - @client.upsert_users([@thread_test_user]) - - # Create a channel and send a message to create a thread - @thread_channel = @client.channel('messaging', channel_id: SecureRandom.uuid, data: { test: true }) - @thread_channel.create(@thread_test_user[:id]) - - # Send a message to create a thread - @thread_message = @thread_channel.send_message({ text: 'Thread parent message' }, @thread_test_user[:id]) - - # Send a reply to create a thread - @thread_channel.send_message({ text: 'Thread reply', parent_id: @thread_message['message']['id'] }, @thread_test_user[:id]) - end - - after(:all) do - @thread_channel.delete - @client.delete_user(@thread_test_user[:id]) - end - - it 'queries threads with filter' do - filter = { - 'created_by_user_id' => { '$eq' => @thread_test_user[:id] } - } - - response = @client.query_threads(filter, user_id: @thread_test_user[:id]) - - expect(response).to include 'threads' - expect(response['threads'].length).to be >= 1 - end - - it 'queries threads with sort' do - sort = { - 'created_at' => -1 - } - - response = @client.query_threads({}, sort: sort, user_id: @thread_test_user[:id]) - - expect(response).to include 'threads' - expect(response['threads'].length).to be >= 1 - end - - it 'queries threads with both filter and sort' do - filter = { - 'created_by_user_id' => { '$eq' => @thread_test_user[:id] } - } - - sort = { - 'created_at' => -1 - } - - response = @client.query_threads(filter, sort: sort, user_id: @thread_test_user[:id]) - - expect(response).to include 'threads' - expect(response['threads'].length).to be >= 1 - end - end - describe 'reminders' do before do @client = StreamChat::Client.from_env