diff --git a/lib/stream-chat/client.rb b/lib/stream-chat/client.rb index d026861..2c0e13f 100644 --- a/lib/stream-chat/client.rb +++ b/lib/stream-chat/client.rb @@ -14,6 +14,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' @@ -35,6 +36,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 @@ -64,6 +68,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 diff --git a/lib/stream-chat/moderation.rb b/lib/stream-chat/moderation.rb new file mode 100644 index 0000000..1e77b44 --- /dev/null +++ b/lib/stream-chat/moderation.rb @@ -0,0 +1,246 @@ +# 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' + }.freeze, + T::Hash[Symbol, String] + ) + + sig { params(client: Client).void } + def initialize(client) + @client = client + 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/spec/moderation_spec.rb b/spec/moderation_spec.rb new file mode 100644 index 0000000..6b0bc74 --- /dev/null +++ b/spec/moderation_spec.rb @@ -0,0 +1,205 @@ +# 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 '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