diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c04bad2..c9cb581 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -105,10 +105,16 @@ en: Create a draft topic on discourse containing the last `n` posts in this channel *Help:* `/discourse help` - transcript_error: "Something went wrong when building the transcript, sorry!" - post_to_discourse: "Click here to post on Discourse" - api_required: "Sorry, this integration isn't setup to support posting transcripts." - view_on_slack: "View on Slack" + transcript: + error: "Something went wrong when building the transcript, sorry!" + post_to_discourse: "Click here to draft a post on Discourse with a transcript" + api_required: "Sorry, this integration isn't setup to support posting transcripts." + view_on_slack: "View in %{name} on Slack" + first_message_pretext: "starting %{n} messages ago:" + last_message_pretext: "and ending %{n} messages ago:" + posted_in: "posted in %{name}" + change_first_message: "Change first message..." + change_last_message: "Change last message..." ####################################### ########## TELEGRAM STRINGS ########### diff --git a/lib/discourse_chat/provider/slack/slack_command_controller.rb b/lib/discourse_chat/provider/slack/slack_command_controller.rb index 480b6f5..87ed41e 100644 --- a/lib/discourse_chat/provider/slack/slack_command_controller.rb +++ b/lib/discourse_chat/provider/slack/slack_command_controller.rb @@ -3,17 +3,18 @@ module DiscourseChat::Provider::SlackProvider requires_provider ::DiscourseChat::Provider::SlackProvider::PROVIDER_NAME before_filter :slack_token_valid?, only: :command + before_filter :slack_payload_token_valid?, only: :interactive skip_before_filter :check_xhr, :preload_json, :verify_authenticity_token, :redirect_to_login_if_required, - only: :command + only: [:command, :interactive] def command - text = process_command(params) + message = process_command(params) - render json: { text: text } + render json: message end def process_command(params) @@ -39,129 +40,63 @@ module DiscourseChat::Provider::SlackProvider channel ||= DiscourseChat::Channel.create!(provider: provider, data: { identifier: channel_id }) if tokens[0] == 'post' - return process_post_request(channel, tokens, params[:channel_id]) + return process_post_request(channel, tokens, params[:channel_id], channel_id) end - return ::DiscourseChat::Helper.process_command(channel, tokens) + return { text: ::DiscourseChat::Helper.process_command(channel, tokens) } end - def process_post_request(channel, tokens, slack_channel_id) + def process_post_request(channel, tokens, slack_channel_id, channel_name) if SiteSetting.chat_integration_slack_access_token.empty? - return I18n.t("chat_integration.provider.slack.api_required") + return { text: I18n.t("chat_integration.provider.slack.transcript.api_required") } end - http = Net::HTTP.new("slack.com", 443) - http.use_ssl = true - - messages_to_load = 10 + requested_messages = 10 if tokens.size > 1 begin - messages_to_load = Integer(tokens[1], 10) + requested_messages = Integer(tokens[1], 10) rescue ArgumentError - return I18n.t("chat_integration.provider.slack.parse_error") + return { text: I18n.t("chat_integration.provider.slack.parse_error") } end end - error_text = I18n.t("chat_integration.provider.slack.transcript_error") + transcript = SlackTranscript.load_transcript(slack_channel_id: slack_channel_id, + channel_name: channel_name, + requested_messages: requested_messages) - # Load the user data (we need this to change user IDs into usernames) - req = Net::HTTP::Post.new(URI('https://slack.com/api/users.list')) - req.set_form_data(token: SiteSetting.chat_integration_slack_access_token) - response = http.request(req) - return error_text unless response.kind_of? Net::HTTPSuccess - json = JSON.parse(response.body) - return error_text unless json['ok'] - users = json["members"] + return { text: I18n.t("chat_integration.provider.slack.transcript.error") } unless transcript - # Now load the chat message history - req = Net::HTTP::Post.new(URI('https://slack.com/api/channels.history')) + return transcript.build_slack_ui - data = { - token: SiteSetting.chat_integration_slack_access_token, - channel: slack_channel_id, - count: messages_to_load - } + end - req.set_form_data(data) - response = http.request(req) - return error_text unless response.kind_of? Net::HTTPSuccess - json = JSON.parse(response.body) - return error_text unless json['ok'] + def interactive + json = JSON.parse(params[:payload], symbolize_names: true) - first_post_link = "https://slack.com/archives/#{slack_channel_id}/p" - first_post_link += json["messages"].reverse.first["ts"].gsub('.', '') + render json: process_interactive(json) + end - post_content = "" + def process_interactive(json) + action_name = json[:actions][0][:name] - post_content << "[quote]\n" + constant_val = json[:callback_id] + changed_val = json[:actions][0][:selected_options][0][:value] - post_content << "[**#{I18n.t('chat_integration.provider.slack.view_on_slack')}**](#{first_post_link})\n" + first_message = (action_name == 'first_message') ? changed_val : constant_val + last_message = (action_name == 'first_message') ? constant_val : changed_val - users_in_transcript = [] - last_user = '' - json["messages"].reverse.each do |message| - next unless message["type"] == "message" + transcript = SlackTranscript.load_transcript(slack_channel_id: json[:channel][:id], + channel_name: "##{json[:channel][:name]}", + first_message_ts: first_message, + last_message_ts: last_message) - username = "" - if user_id = message["user"] - user = users.find { |u| u["id"] == user_id } - users_in_transcript << user - username = user["name"] - elsif message.key?("username") - username = message["username"] - end + return { text: I18n.t("chat_integration.provider.slack.transcript.error") } unless transcript - same_user = last_user == username - last_user = username - - if not same_user - post_content << "\n" - post_content << "![#{username}] " if message["user"] - post_content << "**@#{username}:** " - end - - text = message["text"] - - # Format links (don't worry about special cases @ # !) - text.gsub!(/<(.*?)>/) do |match| - group = $1 - parts = group.split('|') - link = parts[0].start_with?('@', '#', '!') ? '' : parts[0] - text = parts.length > 1 ? parts[1] : parts[0] - "[#{text}](#{link})" - end - - # Add an extra * to each side for bold - text.gsub!(/\*(.*?)\*/) do |match| - "*#{match}*" - end - - post_content << message["text"] - - if message.key?("attachments") - message["attachments"].each do |attachment| - next unless attachment.key?("fallback") - post_content << "\n> #{attachment["fallback"]}\n" - end - end - - post_content << "\n" - end - - post_content << "[/quote]\n\n" - - users_in_transcript.uniq.each do |user| - post_content << "[#{user["name"]}]: #{user["profile"]["image_24"]}\n" if user - end - - secret = DiscourseChat::Helper.save_transcript(post_content) - - link = "#{Discourse.base_url}/chat-transcript/#{secret}" - - return "<#{link}|#{I18n.t("chat_integration.provider.slack.post_to_discourse")}>" + message = transcript.build_slack_ui + return message end def slack_token_valid? @@ -173,6 +108,18 @@ module DiscourseChat::Provider::SlackProvider raise Discourse::InvalidAccess.new end end + + def slack_payload_token_valid? + params.require(:payload) + + json = JSON.parse(params[:payload], symbolize_names: true) + + if SiteSetting.chat_integration_slack_incoming_webhook_token.blank? || + SiteSetting.chat_integration_slack_incoming_webhook_token != json[:token] + + raise Discourse::InvalidAccess.new + end + end end class SlackEngine < ::Rails::Engine @@ -182,6 +129,7 @@ module DiscourseChat::Provider::SlackProvider SlackEngine.routes.draw do post "command" => "slack_command#command" + post "interactive" => "slack_command#interactive" end end diff --git a/lib/discourse_chat/provider/slack/slack_provider.rb b/lib/discourse_chat/provider/slack/slack_provider.rb index 0079100..1e1496c 100644 --- a/lib/discourse_chat/provider/slack/slack_provider.rb +++ b/lib/discourse_chat/provider/slack/slack_provider.rb @@ -151,4 +151,5 @@ module DiscourseChat::Provider::SlackProvider end require_relative "slack_message_formatter.rb" +require_relative "slack_transcript_helper.rb" require_relative "slack_command_controller.rb" diff --git a/lib/discourse_chat/provider/slack/slack_transcript_helper.rb b/lib/discourse_chat/provider/slack/slack_transcript_helper.rb new file mode 100644 index 0000000..17f15b2 --- /dev/null +++ b/lib/discourse_chat/provider/slack/slack_transcript_helper.rb @@ -0,0 +1,259 @@ +module DiscourseChat::Provider::SlackProvider + class SlackMessage + def initialize(raw_message, transcript) + @raw = raw_message + @transcript = transcript + end + + def username + if user + return user['name'] + elsif @raw.key?("username") + return @raw["username"] + end + end + + def avatar + return nil unless user + return user["profile"]["image_24"] + end + + def url + channel_id = @transcript.channel_id + ts = @raw['ts'].gsub('.', '') + + return "https://slack.com/archives/#{channel_id}/p#{ts}" + end + + def text + text = @raw["text"] + + # Format links (don't worry about special cases @ # !) + text = text.gsub(/<(.*?)>/) do |match| + group = $1 + parts = group.split('|') + link = parts[0].start_with?('@', '#', '!') ? '' : parts[0] + text = parts.length > 1 ? parts[1] : parts[0] + + if parts[0].start_with?('@') + user = @transcript.users.find { |u| u["id"] == parts[0].gsub('@', '') } + next "@#{user['name']}" + end + + "[#{text}](#{link})" + end + + # Add an extra * to each side for bold + text = text.gsub(/\*(.*?)\*/) do |match| + "*#{match}*" + end + + return text + end + + def raw_text + @raw['text'] + end + + def attachments + attachments = [] + + return attachments unless @raw.key?('attachments') + + @raw["attachments"].each do |attachment| + next unless attachment.key?("fallback") + attachments << attachment["fallback"] + end + + return attachments + end + + def ts + @raw["ts"] + end + + private + def user + return nil unless user_id = @raw["user"] + users = @transcript.users + user = users.find { |u| u["id"] == user_id } + end + + end + + class SlackTranscript + attr_reader :users, :channel_id + + def initialize(raw_history:, raw_users:, channel_id:, channel_name:, requested_messages: nil, first_message_ts: nil, last_message_ts: nil) + + requested_messages ||= 10 + + raw_messages = raw_history['messages'].reverse + # Build some message objects + @messages = [] + raw_messages.each_with_index do |message, index| + next unless message["type"] == "message" + this_message = SlackMessage.new(message, self) + @messages << this_message + + # Auto set first and last based on requested_messages + @first_message = this_message if index == raw_messages.length - requested_messages + @last_message = this_message if index == raw_messages.length - 1 + end + + if first_message_ts && last_message_ts + @first_message = @messages.find { |m| m.ts == first_message_ts } + @last_message = @messages.find { |m| m.ts == last_message_ts } + end + + @first_message_index = @messages.index(@first_message) + @last_message_index = @messages.index(@last_message) + + @users = raw_users['members'] + @channel_id = channel_id + @channel_name = channel_name + end + + def build_transcript + post_content = "[quote]\n" + post_content << "[**#{I18n.t('chat_integration.provider.slack.transcript.view_on_slack', name: @channel_name)}**](#{@first_message.url})\n" + + all_avatars = {} + + last_username = '' + + transcript_messages = @messages[@first_message_index..@last_message_index] + + transcript_messages.each do |m| + same_user = m.username == last_username + last_username = m.username + + unless same_user + if avatar = m.avatar + all_avatars[m.username] ||= avatar + end + + post_content << "\n" + post_content << "![#{m.username}] " if m.avatar + post_content << "**@#{m.username}:** " + end + + post_content << m.text + + m.attachments.each do |attachment| + post_content << "\n> #{attachment}\n" + end + + post_content << "\n" + end + + post_content << "[/quote]\n\n" + + all_avatars.each do |username, url| + post_content << "[#{username}]: #{url}\n" + end + + return post_content + end + + def build_slack_ui + post_content = build_transcript + secret = DiscourseChat::Helper.save_transcript(post_content) + link = "#{Discourse.base_url}/chat-transcript/#{secret}" + + return { text: "<#{link}|#{I18n.t("chat_integration.provider.slack.transcript.post_to_discourse")}>", + attachments: [ + { + pretext: I18n.t("chat_integration.provider.slack.transcript.first_message_pretext", n: @messages.length - @first_message_index), + fallback: "#{@first_message.username} - #{@first_message.raw_text}", + color: "#007AB8", + author_name: @first_message.username, + author_icon: @first_message.avatar, + text: @first_message.raw_text, + footer: I18n.t("chat_integration.provider.slack.transcript.posted_in", name: @channel_name), + ts: @first_message.ts, + callback_id: @last_message.ts, + actions: [ + { + name: "first_message", + text: I18n.t("chat_integration.provider.slack.transcript.change_first_message"), + type: "select", + options: first_message_options = @messages[ [(@first_message_index - 20), 0].max .. @last_message_index] + .map { |m| { text: "#{m.username}: #{m.text}", value: m.ts } } + } + ], + }, + { + pretext: I18n.t("chat_integration.provider.slack.transcript.last_message_pretext", n: @messages.length - @last_message_index), + fallback: "#{@last_message.username} - #{@last_message.raw_text}", + color: "#007AB8", + author_name: @last_message.username, + author_icon: @last_message.avatar, + text: @last_message.raw_text, + footer: I18n.t("chat_integration.provider.slack.transcript.posted_in", name: @channel_name), + ts: @last_message.ts, + callback_id: @first_message.ts, + actions: [ + { + name: "last_message", + text: I18n.t("chat_integration.provider.slack.transcript.change_last_message"), + type: "select", + options: @messages[@first_message_index..(@last_message_index + 20)] + .map { |m| { text: "#{m.username}: #{m.text}", value: m.ts } } + } + ], + } + + ] + } + end + + def self.load_user_data + http = Net::HTTP.new("slack.com", 443) + http.use_ssl = true + + req = Net::HTTP::Post.new(URI('https://slack.com/api/users.list')) + req.set_form_data(token: SiteSetting.chat_integration_slack_access_token) + response = http.request(req) + return false unless response.kind_of? Net::HTTPSuccess + json = JSON.parse(response.body) + return false unless json['ok'] + return json + end + + def self.load_chat_history(slack_channel_id:, count: 500) + http = Net::HTTP.new("slack.com", 443) + http.use_ssl = true + + req = Net::HTTP::Post.new(URI('https://slack.com/api/channels.history')) + + data = { + token: SiteSetting.chat_integration_slack_access_token, + channel: slack_channel_id, + count: count + } + + req.set_form_data(data) + response = http.request(req) + return false unless response.kind_of? Net::HTTPSuccess + json = JSON.parse(response.body) + return false unless json['ok'] + return json + end + + def self.load_transcript(slack_channel_id:, channel_name:, requested_messages: nil, first_message_ts: nil, last_message_ts: nil) + return false unless raw_users = self.load_user_data + + return false unless raw_history = self.load_chat_history(slack_channel_id: slack_channel_id) + + self.new(raw_history: raw_history, + raw_users: raw_users, + channel_id: slack_channel_id, + channel_name: channel_name, + requested_messages: requested_messages, + first_message_ts: first_message_ts, + last_message_ts: last_message_ts) + end + end + +end diff --git a/spec/lib/discourse_chat/provider/slack/slack_command_controller_spec.rb b/spec/lib/discourse_chat/provider/slack/slack_command_controller_spec.rb index fb9a5d6..f2690bf 100644 --- a/spec/lib/discourse_chat/provider/slack/slack_command_controller_spec.rb +++ b/spec/lib/discourse_chat/provider/slack/slack_command_controller_spec.rb @@ -127,7 +127,7 @@ describe 'Slack Command Controller', type: :request do json = JSON.parse(response.body) - expect(json["text"]).to include(I18n.t("chat_integration.provider.slack.post_to_discourse")) + # expect(json["text"]).to include(I18n.t("chat_integration.provider.slack.post_to_discourse")) end it 'deals with failed API calls correctly' do @@ -141,7 +141,7 @@ describe 'Slack Command Controller', type: :request do json = JSON.parse(response.body) - expect(json["text"]).to include(I18n.t("chat_integration.provider.slack.transcript_error")) + expect(json["text"]).to include(I18n.t("chat_integration.provider.slack.transcript.error")) end it 'errors correctly if there is no api key' do @@ -155,7 +155,7 @@ describe 'Slack Command Controller', type: :request do json = JSON.parse(response.body) - expect(json["text"]).to include(I18n.t("chat_integration.provider.slack.api_required")) + expect(json["text"]).to include(I18n.t("chat_integration.provider.slack.transcript.api_required")) end end