{{i18n "zendesk.credentials_not_setup"}}
65 | {{/if}} 66 | {{/if}} 67 | 68 | } 69 | -------------------------------------------------------------------------------- /plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # name: discourse-zendesk-plugin 4 | # about: Allows the creation of Zendesk tickets from Discourse topics. 5 | # meta_topic_id: 68005 6 | # version: 1.0.1 7 | # authors: Yana Agun Siswanto, Arpit Jalan 8 | # url: https://github.com/discourse/discourse-zendesk-plugin 9 | 10 | gem "inflection", "1.0.0" 11 | 12 | if Gem::Version.new(Faraday::VERSION) >= Gem::Version.new("2.0") 13 | gem "multipart-post", "2.2.3", require_name: "net/http/post/multipart" 14 | gem "faraday-multipart", "1.0.4", require_name: "faraday/multipart" 15 | gem "zendesk_api", "1.38.0.rc1" 16 | end 17 | 18 | enabled_site_setting :zendesk_enabled 19 | 20 | module ::DiscourseZendeskPlugin 21 | PLUGIN_NAME = "discourse-zendesk-plugin" 22 | 23 | ZENDESK_ID_FIELD = "discourse_zendesk_plugin_zendesk_id" 24 | ZENDESK_URL_FIELD = "discourse_zendesk_plugin_zendesk_url" 25 | ZENDESK_API_URL_FIELD = "discourse_zendesk_plugin_zendesk_api_url" 26 | end 27 | 28 | require_relative "lib/discourse_zendesk_plugin/engine" 29 | require_relative "lib/discourse_zendesk_plugin/helper" 30 | 31 | after_initialize do 32 | require_relative "app/jobs/onceoff/migrate_zendesk_autogenerate_categories_site_settings" 33 | require_relative "app/jobs/regular/zendesk_job" 34 | require_relative "lib/discourse_zendesk_plugin/post_extension" 35 | require_relative "lib/discourse_zendesk_plugin/topic_extension" 36 | 37 | reloadable_patch do |plugin| 38 | Post.prepend DiscourseZendeskPlugin::PostExtension 39 | Topic.prepend DiscourseZendeskPlugin::TopicExtension 40 | end 41 | 42 | add_to_serializer( 43 | :topic_view, 44 | ::DiscourseZendeskPlugin::ZENDESK_ID_FIELD.to_sym, 45 | respect_plugin_enabled: false, 46 | ) { object.topic.custom_fields[::DiscourseZendeskPlugin::ZENDESK_ID_FIELD] } 47 | 48 | add_to_serializer( 49 | :topic_view, 50 | ::DiscourseZendeskPlugin::ZENDESK_URL_FIELD.to_sym, 51 | respect_plugin_enabled: false, 52 | ) do 53 | id = object.topic.custom_fields[::DiscourseZendeskPlugin::ZENDESK_ID_FIELD] 54 | uri = URI.parse(SiteSetting.zendesk_url) 55 | "#{uri.scheme}://#{uri.host}/agent/tickets/#{id}" 56 | end 57 | 58 | add_to_serializer(:current_user, :discourse_zendesk_plugin_status) do 59 | SiteSetting.zendesk_jobs_email.present? && SiteSetting.zendesk_jobs_api_token.present? && 60 | SiteSetting.zendesk_url 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.0.2) 5 | base64 6 | benchmark (>= 0.3) 7 | bigdecimal 8 | concurrent-ruby (~> 1.0, >= 1.3.1) 9 | connection_pool (>= 2.2.5) 10 | drb 11 | i18n (>= 1.6, < 2) 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | ast (2.4.3) 18 | base64 (0.3.0) 19 | benchmark (0.4.1) 20 | bigdecimal (3.2.0) 21 | concurrent-ruby (1.3.5) 22 | connection_pool (2.5.3) 23 | drb (2.2.3) 24 | i18n (1.14.7) 25 | concurrent-ruby (~> 1.0) 26 | json (2.12.2) 27 | language_server-protocol (3.17.0.5) 28 | lint_roller (1.1.0) 29 | logger (1.7.0) 30 | minitest (5.25.5) 31 | parallel (1.27.0) 32 | parser (3.3.8.0) 33 | ast (~> 2.4.1) 34 | racc 35 | prettier_print (1.2.1) 36 | prism (1.4.0) 37 | racc (1.8.1) 38 | rack (3.1.16) 39 | rainbow (3.1.1) 40 | regexp_parser (2.10.0) 41 | rubocop (1.75.8) 42 | json (~> 2.3) 43 | language_server-protocol (~> 3.17.0.2) 44 | lint_roller (~> 1.1.0) 45 | parallel (~> 1.10) 46 | parser (>= 3.3.0.2) 47 | rainbow (>= 2.2.2, < 4.0) 48 | regexp_parser (>= 2.9.3, < 3.0) 49 | rubocop-ast (>= 1.44.0, < 2.0) 50 | ruby-progressbar (~> 1.7) 51 | unicode-display_width (>= 2.4.0, < 4.0) 52 | rubocop-ast (1.44.1) 53 | parser (>= 3.3.7.2) 54 | prism (~> 1.4) 55 | rubocop-capybara (2.22.1) 56 | lint_roller (~> 1.1) 57 | rubocop (~> 1.72, >= 1.72.1) 58 | rubocop-discourse (3.12.1) 59 | activesupport (>= 6.1) 60 | lint_roller (>= 1.1.0) 61 | rubocop (>= 1.73.2) 62 | rubocop-capybara (>= 2.22.0) 63 | rubocop-factory_bot (>= 2.27.0) 64 | rubocop-rails (>= 2.30.3) 65 | rubocop-rspec (>= 3.0.1) 66 | rubocop-rspec_rails (>= 2.31.0) 67 | rubocop-factory_bot (2.27.1) 68 | lint_roller (~> 1.1) 69 | rubocop (~> 1.72, >= 1.72.1) 70 | rubocop-rails (2.32.0) 71 | activesupport (>= 4.2.0) 72 | lint_roller (~> 1.1) 73 | rack (>= 1.1) 74 | rubocop (>= 1.75.0, < 2.0) 75 | rubocop-ast (>= 1.44.0, < 2.0) 76 | rubocop-rspec (3.6.0) 77 | lint_roller (~> 1.1) 78 | rubocop (~> 1.72, >= 1.72.1) 79 | rubocop-rspec_rails (2.31.0) 80 | lint_roller (~> 1.1) 81 | rubocop (~> 1.72, >= 1.72.1) 82 | rubocop-rspec (~> 3.5) 83 | ruby-progressbar (1.13.0) 84 | securerandom (0.4.1) 85 | syntax_tree (6.2.0) 86 | prettier_print (>= 1.2.0) 87 | tzinfo (2.0.6) 88 | concurrent-ruby (~> 1.0) 89 | unicode-display_width (3.1.4) 90 | unicode-emoji (~> 4.0, >= 4.0.4) 91 | unicode-emoji (4.0.4) 92 | uri (1.0.3) 93 | 94 | PLATFORMS 95 | ruby 96 | 97 | DEPENDENCIES 98 | rubocop-discourse 99 | syntax_tree 100 | 101 | BUNDLED WITH 102 | 2.6.9 103 | -------------------------------------------------------------------------------- /spec/integration/zendesk_plugin_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | RSpec.describe "Discourse Zendesk Plugin" do 5 | let(:staff) { Fabricate(:moderator) } 6 | let(:zendesk_url_default) { "https://your-url.zendesk.com/api/v2" } 7 | let(:zendesk_api_ticket_url) { zendesk_url_default + "/tickets" } 8 | 9 | let(:ticket_response) { { ticket: { id: "ticket_id", url: "ticket_url" } }.to_json } 10 | 11 | before do 12 | default_header = { "Content-Type" => "application/json; charset=UTF-8" } 13 | stub_request(:post, zendesk_api_ticket_url).to_return( 14 | status: 200, 15 | body: ticket_response, 16 | headers: default_header, 17 | ) 18 | stub_request(:get, zendesk_url_default + "/users/me").to_return( 19 | status: 200, 20 | body: { user: {} }.to_json, 21 | headers: default_header, 22 | ) 23 | end 24 | 25 | describe "Plugin Settings" do 26 | describe "Storage Preparation" do 27 | let(:zendesk_enabled_default) { false } 28 | 29 | it "has zendesk_url & zendesk_enabled site settings" do 30 | expect(SiteSetting.zendesk_url).to eq(zendesk_url_default) 31 | expect(SiteSetting.zendesk_enabled).to eq(zendesk_enabled_default) 32 | end 33 | end 34 | 35 | describe "zendesk_job_push_only_author_posts?" do 36 | it "disabled by default" do 37 | expect(SiteSetting.zendesk_job_push_only_author_posts?).to be_falsey 38 | end 39 | end 40 | end 41 | 42 | describe "Zendesk Integration" do 43 | describe "Create ticket" do 44 | let!(:topic) { Fabricate(:topic) } 45 | let!(:p1) { Fabricate(:post, topic: topic) } 46 | let(:zendesk_api_user_search_url) do 47 | zendesk_url_default + "/users/search?query=#{p1.user.email}" 48 | end 49 | let(:zendesk_api_user_create_url) { zendesk_url_default + "/users" } 50 | 51 | before do 52 | SiteSetting.zendesk_enabled = true 53 | sign_in staff 54 | default_header = { "Content-Type" => "application/json; charset=UTF-8" } 55 | stub_request(:get, zendesk_api_ticket_url + "/ticket_id/comments").to_return(status: 200) 56 | stub_request(:get, zendesk_api_user_search_url).to_return( 57 | status: 200, 58 | body: { user: {} }.to_json, 59 | headers: default_header, 60 | ) 61 | stub_request(:post, zendesk_api_user_create_url).to_return( 62 | status: 200, 63 | body: { user: { id: 24 } }.to_json, 64 | headers: default_header, 65 | ) 66 | end 67 | 68 | it "creates a new zendesk ticket" do 69 | post "/zendesk-plugin/issues.json", params: { topic_id: topic.id } 70 | 71 | expect(WebMock).to have_requested(:post, zendesk_api_ticket_url).with { |req| 72 | body = JSON.parse(req.body) 73 | body["ticket"]["submitter_id"] == 24 && body["ticket"]["priority"] == "normal" && 74 | body["ticket"]["custom_fields"].find do |field| 75 | field["imported_from"].present? && field["external_id"].present? && 76 | field["imported_by"] == "discourse_zendesk_plugin" 77 | end 78 | } 79 | 80 | custom_fields = topic.reload.custom_fields 81 | 82 | expect(custom_fields["discourse_zendesk_plugin_zendesk_api_url"]).to eq("ticket_url") 83 | expect(custom_fields["discourse_zendesk_plugin_zendesk_id"]).to eq("ticket_id") 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/jobs/regular/zendesk_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | RSpec.describe Jobs::ZendeskJob do 6 | subject(:execute) { job.execute(job_args) } 7 | 8 | let(:job) { described_class.new } 9 | 10 | let(:topic_user) { Fabricate(:user) } 11 | let(:other_user) { Fabricate(:user) } 12 | let(:post_user) { topic_user } 13 | let(:topic) do 14 | Fabricate(:topic, user: topic_user).tap do |topic| 15 | topic.custom_fields[::DiscourseZendeskPlugin::ZENDESK_ID_FIELD] = ticket_id 16 | topic.custom_fields[::DiscourseZendeskPlugin::ZENDESK_API_URL_FIELD] = ticket_url 17 | topic.save_custom_fields 18 | end 19 | end 20 | let(:ticket_id) { "1234" } 21 | let(:ticket_url) { "http://example.com/ticket/#{ticket_id}" } 22 | let(:post) { Fabricate(:post, topic: topic, user: post_user, post_number: 2) } 23 | let(:zendesk_job_push_only_author_posts) { false } 24 | let(:zendesk_job_push_all_posts) { true } 25 | let(:zendesk_enabled) { false } 26 | let(:zendesk_jobs_email) { "test@example.com" } 27 | let(:zendesk_jobs_api_token) { "1234567890" } 28 | 29 | let(:job_args) { { post_id: post.id } } 30 | 31 | before do 32 | SiteSetting.zendesk_job_push_only_author_posts = zendesk_job_push_only_author_posts 33 | SiteSetting.zendesk_enabled = zendesk_enabled 34 | SiteSetting.zendesk_jobs_email = zendesk_jobs_email 35 | SiteSetting.zendesk_jobs_api_token = zendesk_jobs_api_token 36 | SiteSetting.zendesk_job_push_all_posts = zendesk_job_push_all_posts 37 | end 38 | 39 | context "with zendesk disabled" do 40 | it "does nothing" do 41 | Topic.expects(:find_by).never 42 | Post.expects(:find_by).never 43 | execute 44 | end 45 | end 46 | 47 | context "with zendesk enabled" do 48 | let(:zendesk_enabled) { true } 49 | before(:each) do 50 | DiscourseZendeskPlugin::Helper 51 | .expects(:autogeneration_category?) 52 | .with(post.topic.category_id) 53 | .returns(true) 54 | .at_least(0) 55 | end 56 | 57 | context "with post_id" do 58 | before(:each) do 59 | Topic.expects(:find_by).never 60 | Post.expects(:find_by).with(id: post.id).returns(post).times(1) 61 | job.expects(:create_ticket).never 62 | end 63 | 64 | context "with zendesk_job_push_only_author_posts disabled" do 65 | it "adds the comment once" do 66 | job.expects(:add_comment).with(post, ticket_id).times(1) 67 | execute 68 | end 69 | 70 | context "when post not from topic author" do 71 | let(:post_user) { other_user } 72 | it "adds the comment once" do 73 | job.expects(:add_comment).with(post, ticket_id).times(1) 74 | execute 75 | end 76 | end 77 | end 78 | 79 | context "with zendesk_job_push_only_author_posts enabled" do 80 | let(:zendesk_job_push_only_author_posts) { true } 81 | 82 | context "with post from topic author" do 83 | it "adds the comment once" do 84 | job.expects(:add_comment).with(post, ticket_id).times(1) 85 | execute 86 | end 87 | end 88 | context "with post not from topic author" do 89 | let(:post_user) { other_user } 90 | it "does not adds the comment" do 91 | job.expects(:add_comment).never 92 | execute 93 | end 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/discourse_zendesk_plugin/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseZendeskPlugin 4 | module Helper 5 | def zendesk_client 6 | ::ZendeskAPI::Client.new do |config| 7 | config.url = SiteSetting.zendesk_url 8 | config.username = SiteSetting.zendesk_jobs_email 9 | config.token = SiteSetting.zendesk_jobs_api_token 10 | end 11 | end 12 | 13 | def self.autogeneration_category?(category_id) 14 | return false if category_id.blank? 15 | 16 | if SiteSetting.zendesk_autogenerate_all_categories? 17 | true 18 | else 19 | SiteSetting.zendesk_autogenerate_categories.split("|").include?(category_id.to_s) 20 | end 21 | end 22 | 23 | def create_ticket(post) 24 | zendesk_user_id = fetch_submitter(post.user)&.id 25 | if zendesk_user_id.present? 26 | ticket = 27 | zendesk_client.tickets.create( 28 | subject: post.topic.title, 29 | comment: { 30 | html_body: get_post_content(post), 31 | }, 32 | requester_id: zendesk_user_id, 33 | submitter_id: zendesk_user_id, 34 | priority: "normal", 35 | tags: SiteSetting.zendesk_tags.split("|"), 36 | external_id: post.topic.id, 37 | custom_fields: [ 38 | imported_from: ::Discourse.current_hostname, 39 | external_id: post.topic.id, 40 | imported_by: "discourse_zendesk_plugin", 41 | ], 42 | ) 43 | 44 | if ticket.present? 45 | update_topic_custom_fields(post.topic, ticket) 46 | update_post_custom_fields(post, ticket.comments.first) 47 | end 48 | end 49 | end 50 | 51 | def comment_eligible_for_sync?(post) 52 | if SiteSetting.zendesk_job_push_only_author_posts? 53 | return false if post.blank? || post.user.blank? 54 | return false if post.topic.blank? || post.topic.user.blank? 55 | 56 | post.user.id == post.topic.user.id 57 | else 58 | true 59 | end 60 | end 61 | 62 | def add_comment(post, ticket_id) 63 | return if post.blank? || post.user.blank? 64 | zendesk_user_id = fetch_submitter(post.user)&.id 65 | 66 | if zendesk_user_id.present? 67 | ticket = ZendeskAPI::Ticket.new(zendesk_client, id: ticket_id) 68 | ticket.comment = { html_body: get_post_content(post), author_id: zendesk_user_id } 69 | ticket.save 70 | update_post_custom_fields(post, ticket.comments.last) 71 | end 72 | end 73 | 74 | def get_latest_comment(ticket_id) 75 | ticket = ZendeskAPI::Ticket.new(zendesk_client, id: ticket_id) 76 | last_public_comment = nil 77 | 78 | ticket.comments.all! { |comment| last_public_comment = comment if comment.public } 79 | last_public_comment 80 | end 81 | 82 | def update_topic_custom_fields(topic, ticket) 83 | topic.custom_fields[::DiscourseZendeskPlugin::ZENDESK_ID_FIELD] = ticket["id"] 84 | topic.custom_fields[::DiscourseZendeskPlugin::ZENDESK_API_URL_FIELD] = ticket["url"] 85 | topic.save_custom_fields 86 | end 87 | 88 | def update_post_custom_fields(post, comment) 89 | return if comment.blank? 90 | 91 | post.custom_fields[::DiscourseZendeskPlugin::ZENDESK_ID_FIELD] = comment["id"] 92 | post.save_custom_fields 93 | end 94 | 95 | def fetch_submitter(user) 96 | result = zendesk_client.users.search(query: user.email) 97 | return result.first if result.present? && result.size == 1 98 | zendesk_client.users.create( 99 | name: (user.name.present? ? user.name : user.username), 100 | email: user.email, 101 | verified: true, 102 | role: "end-user", 103 | ) 104 | end 105 | 106 | def get_post_content(post) 107 | style = Email::Styles.new(post.cooked) 108 | style.format_basic 109 | style.format_html 110 | html = style.to_html 111 | 112 | "#{html} \n\n [Discourse post]" 113 | end 114 | end 115 | end 116 | --------------------------------------------------------------------------------