diff --git a/app/models/web_hook_event_type.rb b/app/models/web_hook_event_type.rb index 818b4353bc8..7904472b2f3 100644 --- a/app/models/web_hook_event_type.rb +++ b/app/models/web_hook_event_type.rb @@ -16,6 +16,7 @@ class WebHookEventType < ActiveRecord::Base LIKE = 15 USER_PROMOTED = 16 TOPIC_VOTING = 17 + CHAT_MESSAGE = 18 has_and_belongs_to_many :web_hooks @@ -34,6 +35,9 @@ class WebHookEventType < ActiveRecord::Base unless defined?(SiteSetting.voting_enabled) && SiteSetting.voting_enabled ids_to_exclude << TOPIC_VOTING end + unless defined?(SiteSetting.chat_enabled) && SiteSetting.chat_enabled + ids_to_exclude << CHAT_MESSAGE + end self.where.not(id: ids_to_exclude) end diff --git a/db/fixtures/007_web_hook_event_types.rb b/db/fixtures/007_web_hook_event_types.rb index 3bf745381c7..4ee43c57c7a 100644 --- a/db/fixtures/007_web_hook_event_types.rb +++ b/db/fixtures/007_web_hook_event_types.rb @@ -74,3 +74,8 @@ WebHookEventType.seed do |b| b.id = WebHookEventType::TOPIC_VOTING b.name = "topic_voting" end + +WebHookEventType.seed do |b| + b.id = WebHookEventType::CHAT_MESSAGE + b.name = "chat_message" +end diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index 4b36fb9d635..6f5bfda6a09 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -19,6 +19,10 @@ en: descriptions: chat: create_message: "Create a chat message in a specified channel." + web_hooks: + chat_message_event: + name: "Chat message event" + details: "When a chat message is created, edited, trashed or restored." about: chat_messages_count: "Chat Messages" chat_channels_count: "Chat Channels" diff --git a/plugins/chat/lib/chat/outgoing_web_hook_extension.rb b/plugins/chat/lib/chat/outgoing_web_hook_extension.rb new file mode 100644 index 00000000000..41ee8f219aa --- /dev/null +++ b/plugins/chat/lib/chat/outgoing_web_hook_extension.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Chat + module OutgoingWebHookExtension + def self.prepended(base) + def base.enqueue_chat_message_hooks(event, payload, opts = {}) + if active_web_hooks("chat_message").exists? + WebHook.enqueue_hooks(:chat_message, event, payload: payload, **opts) + end + end + end + end +end diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index 1a9789b85b9..0473e0a05f0 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -67,6 +67,7 @@ after_initialize do Jobs::UserEmail.prepend Chat::UserEmailExtension Plugin::Instance.prepend Chat::PluginInstanceExtension Jobs::ExportCsvFile.class_eval { prepend Chat::MessagesExporter } + WebHook.prepend Chat::OutgoingWebHookExtension end if Oneboxer.respond_to?(:register_local_handler) @@ -381,6 +382,35 @@ after_initialize do end end + # outgoing webhook events + %i[ + chat_message_created + chat_message_edited + chat_message_trashed + chat_message_restored + ].each do |chat_message_event| + on(chat_message_event) do |message, channel, user| + guardian = Guardian.new(user) + + payload = { + message: Chat::MessageSerializer.new(message, { scope: guardian, root: false }).as_json, + channel: + Chat::ChannelSerializer.new( + channel, + { scope: guardian, membership: channel.membership_for(user), root: false }, + ).as_json, + } + + category_id = channel.chatable_type == "Category" ? channel.chatable_id : nil + + WebHook.enqueue_chat_message_hooks( + chat_message_event, + payload.to_json, + category_id: category_id, + ) + end + end + Discourse::Application.routes.append do mount ::Chat::Engine, at: "/chat" diff --git a/plugins/chat/spec/fabricators/outgoing_chat_message_web_hook_fabricator.rb b/plugins/chat/spec/fabricators/outgoing_chat_message_web_hook_fabricator.rb new file mode 100644 index 00000000000..46c222fe6d8 --- /dev/null +++ b/plugins/chat/spec/fabricators/outgoing_chat_message_web_hook_fabricator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Fabricator(:outgoing_chat_message_web_hook, from: :web_hook) do + transient chat_message_hook: WebHookEventType.find_by(name: "chat_message") + + after_build do |web_hook, transients| + web_hook.web_hook_event_types = [transients[:chat_message_hook]] + end +end diff --git a/plugins/chat/spec/integration/outgoing_web_hooks_spec.rb b/plugins/chat/spec/integration/outgoing_web_hooks_spec.rb new file mode 100644 index 00000000000..a951dc6fd7c --- /dev/null +++ b/plugins/chat/spec/integration/outgoing_web_hooks_spec.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +RSpec.describe "Outgoing chat webhooks" do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + describe "chat messages" do + fab!(:web_hook) { Fabricate(:outgoing_chat_message_web_hook) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + let(:message_content) { "This is a test message" } + let(:new_message_content) { "This is the edited message" } + let(:job_args) do + Jobs::EmitWebHookEvent + .jobs + .map { |job| job["args"].first } + .find { |args| args["event_type"] == "chat_message" } + end + let(:event_name) { job_args["event_name"] } + let(:event_category_id) { job_args["category_id"] } + let(:payload) { JSON.parse(job_args["payload"]) } + + def expect_response_to_be_successful + expect(response.status).to eq(200) + end + + def expect_web_hook_event_name_to_be(name) + expect(event_name).to eq(name) + end + + def expect_web_hook_event_category_to_be(category) + expect(event_category_id).to eq(category.id) + end + + def expect_web_hook_payload_message_to_match(message:, user:, &block) + payload_message = payload["message"] + + expect(payload_message["id"]).to eq(message.id) + expect(payload_message["message"]).to eq(message.message) + expect(payload_message["cooked"]).to eq(message.cooked) + expect(payload_message["created_at"]).to eq(message.created_at.iso8601) + expect(payload_message["excerpt"]).to eq(message.excerpt) + expect(payload_message["chat_channel_id"]).to eq(message.chat_channel_id) + expect(payload_message["mentioned_users"]).to be_empty + expect(payload_message["available_flags"]).to be_empty + expect(payload_message["user"]["id"]).to eq(user.id) + expect(payload_message["user"]["username"]).to eq(user.username) + expect(payload_message["user"]["avatar_template"]).to eq(user.avatar_template) + expect(payload_message["user"]["admin"]).to eq(user.admin?) + expect(payload_message["user"]["staff"]).to eq(user.staff?) + expect(payload_message["user"]["moderator"]).to eq(user.moderator?) + expect(payload_message["user"]["new_user"]).to eq(user.trust_level == TrustLevel[0]) + expect(payload_message["user"]["primary_group_name"]).to eq(user.primary_group&.name) + expect(payload_message["uploads"]).to be_empty + + yield(payload_message) if block_given? + end + + def expect_web_hook_payload_channel_to_match_category(channel:, category:, &block) + payload_channel = payload["channel"] + + expect(payload_channel["id"]).to eq(channel.id) + expect(payload_channel["allow_channel_wide_mentions"]).to eq( + channel.allow_channel_wide_mentions, + ) + expect(payload_channel["chatable_id"]).to eq(category.id) + expect(payload_channel["chatable_type"]).to eq("Category") + expect(payload_channel["chatable_url"]).to eq(category.url) + expect(payload_channel["title"]).to eq(channel.title) + expect(payload_channel["slug"]).to eq(channel.slug) + + yield(payload_channel) if block_given? + end + + def expect_web_hook_payload_channel_to_match_direct_message(channel:, direct_message:, &block) + payload_channel = payload["channel"] + + expect(payload_channel["id"]).to eq(channel.id) + expect(payload_channel["allow_channel_wide_mentions"]).to eq( + channel.allow_channel_wide_mentions, + ) + expect(payload_channel["chatable_id"]).to eq(direct_message.id) + expect(payload_channel["chatable_type"]).to eq("DirectMessage") + expect(payload_channel["chatable_url"]).to be_nil + expect(payload_channel["chatable"]["users"][0]["id"]).to eq(user2.id) + expect(payload_channel["chatable"]["users"][0]["username"]).to eq(user2.username) + expect(payload_channel["chatable"]["users"][0]["name"]).to eq(user2.name) + expect(payload_channel["chatable"]["users"][0]["avatar_template"]).to eq( + user2.avatar_template, + ) + expect(payload_channel["chatable"]["users"][0]["can_chat"]).to eq(true) + expect(payload_channel["chatable"]["users"][0]["has_chat_enabled"]).to eq(true) + expect(payload_channel["title"]).to eq(channel.title(user1)) + expect(payload_channel["slug"]).to be_nil + + yield(payload_channel) if block_given? + end + + context "for a category channel" do + fab!(:category) { Fabricate(:category) } + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user1) } + + before do + [user1, user2].each do |user| + Chat::UserChatChannelMembership.create( + user: user, + chat_channel: chat_channel, + following: true, + ) + end + + sign_in(user1) + end + + it "triggers a webhook when a chat message is created" do + post "/chat/#{chat_channel.id}.json", params: { message: message_content } + + expect_response_to_be_successful + expect_web_hook_event_name_to_be("chat_message_created") + expect_web_hook_event_category_to_be(category) + expect_web_hook_payload_message_to_match( + message: Chat::Message.last, + user: user1, + ) { |payload_message| expect(payload_message["message"]).to eq(message_content) } + expect_web_hook_payload_channel_to_match_category(channel: chat_channel, category: category) + end + + it "triggers a webhook when a chat message is edited" do + put "/chat/#{chat_channel.id}/edit/#{chat_message.id}.json", + params: { + new_message: new_message_content, + } + + expect_response_to_be_successful + expect_web_hook_event_name_to_be("chat_message_edited") + expect_web_hook_event_category_to_be(category) + expect_web_hook_payload_message_to_match( + message: Chat::Message.last, + user: user1, + ) { |payload_message| expect(payload_message["message"]).to eq(new_message_content) } + expect_web_hook_payload_channel_to_match_category(channel: chat_channel, category: category) + end + + it "triggers a webhook when a chat message is trashed" do + delete "/chat/api/channels/#{chat_message.chat_channel_id}/messages/#{chat_message.id}.json" + + expect_response_to_be_successful + expect(chat_message.reload.trashed?).to eq(true) + expect_web_hook_event_name_to_be("chat_message_trashed") + expect_web_hook_event_category_to_be(category) + expect_web_hook_payload_message_to_match(message: chat_message, user: user1) + expect_web_hook_payload_channel_to_match_category(channel: chat_channel, category: category) + end + + it "triggers a webhook when a trashed chat message is restored" do + chat_message.trash!(user1) + expect(chat_message.reload.trashed?).to eq(true) + + put "/chat/api/channels/#{chat_channel.id}/messages/#{chat_message.id}/restore.json" + + expect_response_to_be_successful + expect(chat_message.reload.trashed?).to eq(false) + expect_web_hook_event_name_to_be("chat_message_restored") + expect_web_hook_event_category_to_be(category) + expect_web_hook_payload_message_to_match(message: chat_message, user: user1) + expect_web_hook_payload_channel_to_match_category(channel: chat_channel, category: category) + end + end + + context "for a direct message channel" do + fab!(:direct_message) { Fabricate(:direct_message, users: [user1, user2]) } + fab!(:direct_message_channel) { Fabricate(:direct_message_channel, chatable: direct_message) } + fab!(:chat_message) do + Fabricate(:chat_message, chat_channel: direct_message_channel, user: user1) + end + + before { sign_in(user1) } + + it "triggers a webhook when a chat message is created" do + post "/chat/#{direct_message_channel.id}.json", params: { message: message_content } + + expect_response_to_be_successful + expect_web_hook_event_name_to_be("chat_message_created") + expect_web_hook_payload_message_to_match( + message: Chat::Message.last, + user: user1, + ) { |payload_message| expect(payload_message["message"]).to eq(message_content) } + expect_web_hook_payload_channel_to_match_direct_message( + channel: direct_message_channel, + direct_message: direct_message, + ) + end + + it "triggers a webhook when a chat message is edited" do + put "/chat/#{direct_message_channel.id}/edit/#{chat_message.id}.json", + params: { + new_message: new_message_content, + } + + expect_response_to_be_successful + expect_web_hook_event_name_to_be("chat_message_edited") + expect_web_hook_payload_message_to_match( + message: Chat::Message.last, + user: user1, + ) { |payload_message| expect(payload_message["message"]).to eq(new_message_content) } + expect_web_hook_payload_channel_to_match_direct_message( + channel: direct_message_channel, + direct_message: direct_message, + ) + end + + it "triggers a webhook when a chat message is trashed" do + delete "/chat/api/channels/#{chat_message.chat_channel_id}/messages/#{chat_message.id}.json" + + expect_response_to_be_successful + expect(chat_message.reload.trashed?).to eq(true) + expect_web_hook_event_name_to_be("chat_message_trashed") + expect_web_hook_payload_message_to_match(message: chat_message, user: user1) + expect_web_hook_payload_channel_to_match_direct_message( + channel: direct_message_channel, + direct_message: direct_message, + ) + end + + it "triggers a webhook when a trashed chat message is restored" do + chat_message.trash!(user1) + expect(chat_message.reload.trashed?).to eq(true) + + put "/chat/api/channels/#{direct_message_channel.id}/messages/#{chat_message.id}/restore.json" + + expect_response_to_be_successful + expect(chat_message.reload.trashed?).to eq(false) + expect_web_hook_event_name_to_be("chat_message_restored") + expect_web_hook_payload_message_to_match(message: chat_message, user: user1) + expect_web_hook_payload_channel_to_match_direct_message( + channel: direct_message_channel, + direct_message: direct_message, + ) + end + end + end +end diff --git a/plugins/chat/spec/services/chat/restore_message_spec.rb b/plugins/chat/spec/services/chat/restore_message_spec.rb index 3f71312212f..e7ce41d715c 100644 --- a/plugins/chat/spec/services/chat/restore_message_spec.rb +++ b/plugins/chat/spec/services/chat/restore_message_spec.rb @@ -67,8 +67,11 @@ RSpec.describe Chat::RestoreMessage do freeze_time messages = nil event = - DiscourseEvent.track_events { messages = MessageBus.track_publish { result } }.first - expect(event[:event_name]).to eq(:chat_message_restored) + DiscourseEvent + .track_events { messages = MessageBus.track_publish { result } } + .find { |e| e[:event_name] == :chat_message_restored } + + expect(event).to be_present expect(event[:params]).to eq([message, message.chat_channel, current_user]) expect( messages.find { |m| m.channel == "/chat/#{message.chat_channel_id}" }.data, diff --git a/plugins/chat/spec/services/chat/trash_message_spec.rb b/plugins/chat/spec/services/chat/trash_message_spec.rb index cee1ebba4ce..821662433ca 100644 --- a/plugins/chat/spec/services/chat/trash_message_spec.rb +++ b/plugins/chat/spec/services/chat/trash_message_spec.rb @@ -62,8 +62,11 @@ RSpec.describe Chat::TrashMessage do freeze_time messages = nil event = - DiscourseEvent.track_events { messages = MessageBus.track_publish { result } }.first - expect(event[:event_name]).to eq(:chat_message_trashed) + DiscourseEvent + .track_events { messages = MessageBus.track_publish { result } } + .find { |e| e[:event_name] == :chat_message_trashed } + + expect(event).to be_present expect(event[:params]).to eq([message, message.chat_channel, current_user]) expect(messages.find { |m| m.channel == "/chat/#{message.chat_channel_id}" }.data).to eq( { diff --git a/spec/models/web_hook_spec.rb b/spec/models/web_hook_spec.rb index 196f91a0e0c..f2477cdc6a4 100644 --- a/spec/models/web_hook_spec.rb +++ b/spec/models/web_hook_spec.rb @@ -64,6 +64,10 @@ RSpec.describe WebHook do SiteSetting.stubs(:voting_enabled).returns(true) voting_event_types = WebHookEventType.active.where(name: "topic_voting") expect(voting_event_types.count).to eq(1) + + SiteSetting.stubs(:chat_enabled).returns(true) + chat_enabled_types = WebHookEventType.active.where("name LIKE 'chat_%'") + expect(chat_enabled_types.count).to eq(1) end describe "#active_web_hooks" do