FEATURE: Enable optional support for threading slack posts (#38)

When creating a new Discourse post from slack with the `post` feature, record the
slack `ts` thread ID for the resulting topic post using an HTML comment to pass
the `ts` through.

When notifying slack of new Discourse posts, record the slack `ts` thread ID in
the post's topic if it has not yet been recorded. (Normally, this will be done
for the topic post, except where notifications are being posted for old topics
before this feature was created.)

Add a new rule filter `thread` which posts threaded responses to slack if there
is a `ts` recorded for the post topic.

Modify the `trigger_notifications` interface to enable other integrations to
implement similar functionality.

Present the `thread` rule in the help text and admin UI only for the slack
providers.

https://meta.discourse.org/t/optionally-threading-posts-to-parent-topic-in-slack-integration/150759
This commit is contained in:
Michael K Johnson 2020-06-15 11:45:25 -04:00 committed by GitHub
parent b2daff34eb
commit da9106127a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 200 additions and 79 deletions

View File

@ -34,7 +34,7 @@ class DiscourseChat::ChatController < ApplicationController
post = Topic.find(topic_id.to_i).posts.first post = Topic.find(topic_id.to_i).posts.first
provider.trigger_notification(post, channel) provider.trigger_notification(post, channel, nil)
render json: success_json render json: success_json
rescue Discourse::InvalidParameters, ActiveRecord::RecordNotFound => e rescue Discourse::InvalidParameters, ActiveRecord::RecordNotFound => e

View File

@ -13,7 +13,7 @@ module DiscourseChat
error_text = I18n.t("chat_integration.provider.#{provider}.parse_error") error_text = I18n.t("chat_integration.provider.#{provider}.parse_error")
case cmd case cmd
when "watch", "follow", "mute" when "thread", "watch", "follow", "mute"
return error_text if tokens.empty? return error_text if tokens.empty?
# If the first token in the command is a tag, this rule applies to all categories # If the first token in the command is a tag, this rule applies to all categories
category_name = tokens[0].start_with?('tag:') ? nil : tokens.shift category_name = tokens[0].start_with?('tag:') ? nil : tokens.shift

View File

@ -31,15 +31,16 @@ class DiscourseChat::Rule < DiscourseChat::PluginModel
" "
CASE CASE
WHEN value::json->>'filter' = 'mute' THEN 1 WHEN value::json->>'filter' = 'mute' THEN 1
WHEN value::json->>'filter' = 'watch' THEN 2 WHEN value::json->>'filter' = 'thread' THEN 2
WHEN value::json->>'filter' = 'follow' THEN 3 WHEN value::json->>'filter' = 'watch' THEN 3
WHEN value::json->>'filter' = 'follow' THEN 4
END END
") ")
} }
after_initialize :init_filter after_initialize :init_filter
validates :filter, inclusion: { in: %w(watch follow mute), validates :filter, inclusion: { in: %w(thread watch follow mute),
message: "%{value} is not a valid filter" } message: "%{value} is not a valid filter" }
validates :type, inclusion: { in: %w(normal group_message group_mention), validates :type, inclusion: { in: %w(normal group_message group_mention),

View File

@ -52,7 +52,7 @@ module DiscourseChat
# Sort by order of precedence # Sort by order of precedence
t_prec = { 'group_message' => 0, 'group_mention' => 1, 'normal' => 2 } # Group things win t_prec = { 'group_message' => 0, 'group_mention' => 1, 'normal' => 2 } # Group things win
f_prec = { 'mute' => 0, 'watch' => 1, 'follow' => 2 } #(mute always wins; watch beats follow) f_prec = { 'mute' => 0, 'thread' => 1, 'watch' => 2, 'follow' => 3 } #(mute always wins; thread beats watch beats follow)
sort_func = proc { |a, b| [t_prec[a.type], f_prec[a.filter]] <=> [t_prec[b.type], f_prec[b.filter]] } sort_func = proc { |a, b| [t_prec[a.type], f_prec[a.filter]] <=> [t_prec[b.type], f_prec[b.filter]] }
matching_rules = matching_rules.sort(&sort_func) matching_rules = matching_rules.sort(&sort_func)
@ -80,7 +80,7 @@ module DiscourseChat
next unless is_enabled = ::DiscourseChat::Provider.is_enabled(provider) next unless is_enabled = ::DiscourseChat::Provider.is_enabled(provider)
begin begin
provider.trigger_notification(post, channel) provider.trigger_notification(post, channel, rule)
channel.update_attribute('error_key', nil) if channel.error_key channel.update_attribute('error_key', nil) if channel.error_key
rescue => e rescue => e
if e.class == (DiscourseChat::ProviderError) && e.info.key?(:error_key) && !e.info[:error_key].nil? if e.class == (DiscourseChat::ProviderError) && e.info.key?(:error_key) && !e.info[:error_key].nil?

View File

@ -6,23 +6,38 @@ import {
} from "discourse-common/utils/decorators"; } from "discourse-common/utils/decorators";
export default RestModel.extend({ export default RestModel.extend({
available_filters: [ @computed("channel.provider")
{ available_filters(provider) {
id: "watch", const available = [];
name: I18n.t("chat_integration.filter.watch"),
icon: "exclamation-circle" if (provider === "slack") {
}, available.push({
{ id: "thread",
id: "follow", name: I18n.t("chat_integration.filter.thread"),
name: I18n.t("chat_integration.filter.follow"), icon: "chevron-right"
icon: "circle" });
},
{
id: "mute",
name: I18n.t("chat_integration.filter.mute"),
icon: "times-circle"
} }
],
available.push(
{
id: "watch",
name: I18n.t("chat_integration.filter.watch"),
icon: "exclamation-circle"
},
{
id: "follow",
name: I18n.t("chat_integration.filter.follow"),
icon: "circle"
},
{
id: "mute",
name: I18n.t("chat_integration.filter.mute"),
icon: "times-circle"
}
);
return available;
},
available_types: [ available_types: [
{ id: "normal", name: I18n.t("chat_integration.type.normal") }, { id: "normal", name: I18n.t("chat_integration.type.normal") },

View File

@ -18,7 +18,7 @@ export default DiscourseRoute.extend({
"rules", "rules",
channel.rules.map(rule => { channel.rules.map(rule => {
rule = this.store.createRecord("rule", rule); rule = this.store.createRecord("rule", rule);
rule.channel = channel; rule.set("channel", channel);
return rule; return rule;
}) })
); );

View File

@ -36,6 +36,7 @@ en:
mute: 'Mute' mute: 'Mute'
follow: 'First post only' follow: 'First post only'
watch: 'All posts and replies' watch: 'All posts and replies'
thread: 'All posts with threaded replies'
rule_table: rule_table:
filter: "Filter" filter: "Filter"
category: "Category" category: "Category"

View File

@ -122,8 +122,9 @@ en:
tag: "The *%{name}* tag cannot be found." tag: "The *%{name}* tag cannot be found."
category: "The *%{name}* category cannot be found. Available categories: *%{list}*" category: "The *%{name}* category cannot be found. Available categories: *%{list}*"
help: | help: |
*New rule:* `/discourse [watch|follow|mute] [category] [tag:name]` *New rule:* `/discourse [thread|watch|follow|mute] [category] [tag:name]`
(you must specify a rule type and at least one category or tag) (you must specify a rule type and at least one category or tag)
- *thread* notify this channel for new topics, thread replies if possible
- *watch* notify this channel for new topics and new replies - *watch* notify this channel for new topics and new replies
- *follow* notify this channel for new topics - *follow* notify this channel for new topics
- *mute* block notifications to this channel - *mute* block notifications to this channel

View File

@ -63,7 +63,7 @@ module DiscourseChat
message message
end end
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
# Adding ?wait=true means that we actually get a success/failure response, rather than returning asynchronously # Adding ?wait=true means that we actually get a success/failure response, rather than returning asynchronously
webhook_url = "#{channel.data['webhook_url']}?wait=true" webhook_url = "#{channel.data['webhook_url']}?wait=true"
message = generate_discord_message(post) message = generate_discord_message(post)

View File

@ -48,7 +48,7 @@ module DiscourseChat::Provider::FlowdockProvider
message message
end end
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
flow_token = channel.data["flow_token"] flow_token = channel.data["flow_token"]
message = generate_flowdock_message(post, flow_token) message = generate_flowdock_message(post, flow_token)
response = send_message("https://api.flowdock.com/messages", message) response = send_message("https://api.flowdock.com/messages", message)

View File

@ -10,7 +10,7 @@ module DiscourseChat
{ key: "webhook_url", regex: '^https://webhooks\.gitter\.im/e/\S+$', unique: true, hidden: true } { key: "webhook_url", regex: '^https://webhooks\.gitter\.im/e/\S+$', unique: true, hidden: true }
] ]
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
message = gitter_message(post) message = gitter_message(post)
response = Net::HTTP.post_form(URI(channel.data['webhook_url']), message: message) response = Net::HTTP.post_form(URI(channel.data['webhook_url']), message: message)
unless response.kind_of? Net::HTTPSuccess unless response.kind_of? Net::HTTPSuccess

View File

@ -71,7 +71,7 @@ module DiscourseChat::Provider::GroupmeProvider
end end
end end
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
data_package = generate_groupme_message(post) data_package = generate_groupme_message(post)
self.send_via_webhook(data_package, channel) self.send_via_webhook(data_package, channel)
end end

View File

@ -56,7 +56,7 @@ module DiscourseChat
message message
end end
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
message = generate_matrix_message(post) message = generate_matrix_message(post)
response = send_message(channel.data['room_id'], message) response = send_message(channel.data['room_id'], message)

View File

@ -75,7 +75,7 @@ module DiscourseChat
message message
end end
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
channel_id = channel.data['identifier'] channel_id = channel.data['identifier']
message = mattermost_message(post, channel_id) message = mattermost_message(post, channel_id)

View File

@ -68,7 +68,7 @@ module DiscourseChat::Provider::RocketchatProvider
end end
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
channel_id = channel.data['identifier'] channel_id = channel.data['identifier']
message = rocketchat_message(post, channel_id) message = rocketchat_message(post, channel_id)

View File

@ -2,6 +2,7 @@
module DiscourseChat::Provider::SlackProvider module DiscourseChat::Provider::SlackProvider
PROVIDER_NAME = "slack".freeze PROVIDER_NAME = "slack".freeze
THREAD = "thread".freeze
PROVIDER_ENABLED_SETTING = :chat_integration_slack_enabled PROVIDER_ENABLED_SETTING = :chat_integration_slack_enabled
@ -9,6 +10,18 @@ module DiscourseChat::Provider::SlackProvider
{ key: "identifier", regex: '^[@#]?\S*$', unique: true } { key: "identifier", regex: '^[@#]?\S*$', unique: true }
] ]
require_dependency 'topic'
::Topic.register_custom_field_type(DiscourseChat::Provider::SlackProvider::THREAD, :text)
class ::Topic
def slack_thread_id=(ts)
self.custom_fields[DiscourseChat::Provider::SlackProvider::THREAD] = ts
end
def slack_thread_id
self.custom_fields[DiscourseChat::Provider::SlackProvider::THREAD]
end
end
def self.excerpt(post, max_length = SiteSetting.chat_integration_slack_excerpt_length) def self.excerpt(post, max_length = SiteSetting.chat_integration_slack_excerpt_length)
doc = Nokogiri::HTML.fragment(post.excerpt(max_length, doc = Nokogiri::HTML.fragment(post.excerpt(max_length,
remap_emoji: true, remap_emoji: true,
@ -18,7 +31,7 @@ module DiscourseChat::Provider::SlackProvider
SlackMessageFormatter.format(doc.to_html) SlackMessageFormatter.format(doc.to_html)
end end
def self.slack_message(post, channel) def self.slack_message(post, channel, filter)
display_name = "@#{post.user.username}" display_name = "@#{post.user.username}"
full_name = post.user.name || "" full_name = post.user.name || ""
@ -56,6 +69,10 @@ module DiscourseChat::Provider::SlackProvider
attachments: [] attachments: []
} }
if filter == "thread" && thread_ts = topic.slack_thread_id
message[:thread_ts] = thread_ts if not thread_ts.nil?
end
summary = { summary = {
fallback: "#{topic.title} - #{display_name}", fallback: "#{topic.title} - #{display_name}",
author_name: display_name, author_name: display_name,
@ -79,11 +96,9 @@ module DiscourseChat::Provider::SlackProvider
response = nil response = nil
uri = "" uri = ""
record = DiscourseChat.pstore_get("slack_topic_#{post.topic.id}_#{channel}")
data = { # <!--SLACK_CHANNEL_ID=#{@channel_id};SLACK_TS=#{@requested_thread_ts}-->
token: SiteSetting.chat_integration_slack_access_token, slack_thread_regex = /<!--SLACK_CHANNEL_ID=(\w+);SLACK_TS=([0-9]{10}.[0-9]{6})-->/
}
req = Net::HTTP::Post.new(URI('https://slack.com/api/chat.postMessage')) req = Net::HTTP::Post.new(URI('https://slack.com/api/chat.postMessage'))
@ -94,6 +109,12 @@ module DiscourseChat::Provider::SlackProvider
channel: message[:channel].gsub('#', ''), channel: message[:channel].gsub('#', ''),
attachments: message[:attachments].to_json attachments: message[:attachments].to_json
} }
if message.key?(:thread_ts)
data[:thread_ts] = message[:thread_ts]
elsif match = slack_thread_regex.match(post.raw)
post.topic.slack_thread_id = match.captures[1]
post.topic.save_custom_fields
end
req.set_form_data(data) req.set_form_data(data)
@ -114,6 +135,12 @@ module DiscourseChat::Provider::SlackProvider
raise ::DiscourseChat::ProviderError.new info: { error_key: error_key, request: uri, response_code: response.code, response_body: response.body } raise ::DiscourseChat::ProviderError.new info: { error_key: error_key, request: uri, response_code: response.code, response_body: response.body }
end end
ts = json["ts"]
if !ts.nil? && post.topic.slack_thread_id.nil?
post.topic.slack_thread_id = ts
post.topic.save_custom_fields
end
response response
end end
@ -137,9 +164,10 @@ module DiscourseChat::Provider::SlackProvider
end end
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
channel_id = channel.data['identifier'] channel_id = channel.data['identifier']
message = slack_message(post, channel_id) filter = rule.nil? ? "" : rule.filter
message = slack_message(post, channel_id, filter)
if SiteSetting.chat_integration_slack_access_token.empty? if SiteSetting.chat_integration_slack_access_token.empty?
self.send_via_webhook(message) self.send_via_webhook(message)

View File

@ -118,6 +118,10 @@ module DiscourseChat::Provider::SlackProvider
post_content << "[#{username}]: #{url}\n" post_content << "[#{username}]: #{url}\n"
end end
if not @requested_thread_ts.nil?
post_content << "<!--SLACK_CHANNEL_ID=#{@channel_id};SLACK_TS=#{@requested_thread_ts}-->"
end
post_content post_content
end end

View File

@ -79,7 +79,7 @@ module DiscourseChat
end end
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
chat_id = channel.data['chat_id'] chat_id = channel.data['chat_id']
message = { message = {

View File

@ -46,7 +46,7 @@ module DiscourseChat
} }
end end
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
stream = channel.data['stream'] stream = channel.data['stream']
subject = channel.data['subject'] subject = channel.data['subject']

View File

@ -11,6 +11,7 @@ enabled_site_setting :chat_integration_enabled
register_asset "stylesheets/chat-integration-admin.scss" register_asset "stylesheets/chat-integration-admin.scss"
register_svg_icon "rocket" if respond_to?(:register_svg_icon) register_svg_icon "rocket" if respond_to?(:register_svg_icon)
register_svg_icon "fa-arrow-circle-o-right" if respond_to?(:register_svg_icon)
# Site setting validators must be loaded before initialize # Site setting validators must be loaded before initialize
require_relative "lib/discourse_chat/provider/slack/slack_enabled_setting_validator" require_relative "lib/discourse_chat/provider/slack/slack_enabled_setting_validator"

View File

@ -10,7 +10,7 @@ RSpec.shared_context "dummy provider" do
@@sent_messages = [] @@sent_messages = []
@@raise_exception = nil @@raise_exception = nil
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
if @@raise_exception if @@raise_exception
raise @@raise_exception raise @@raise_exception
end end
@ -50,7 +50,7 @@ RSpec.shared_context "validated dummy provider" do
@@sent_messages = [] @@sent_messages = []
def self.trigger_notification(post, channel) def self.trigger_notification(post, channel, rule)
@@sent_messages.push(post: post.id, channel: channel) @@sent_messages.push(post: post.id, channel: channel)
end end

View File

@ -32,7 +32,12 @@ RSpec.describe DiscourseChat::Manager do
expect(rule.tags).to eq(nil) expect(rule.tags).to eq(nil)
end end
it 'should work with all three filter types' do it 'should work with all four filter types' do
response = DiscourseChat::Helper.process_command(chan1, ['thread', category.slug])
rule = DiscourseChat::Rule.all.first
expect(rule.filter).to eq('thread')
response = DiscourseChat::Helper.process_command(chan1, ['watch', category.slug]) response = DiscourseChat::Helper.process_command(chan1, ['watch', category.slug])
rule = DiscourseChat::Rule.all.first rule = DiscourseChat::Rule.all.first

View File

@ -14,7 +14,7 @@ RSpec.describe DiscourseChat::Provider::DiscordProvider do
it 'sends a webhook request' do it 'sends a webhook request' do
stub1 = stub_request(:post, 'https://discordapp.com/api/webhooks/1234/abcd?wait=true').to_return(status: 200) stub1 = stub_request(:post, 'https://discordapp.com/api/webhooks/1234/abcd?wait=true').to_return(status: 200)
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
@ -22,14 +22,14 @@ RSpec.describe DiscourseChat::Provider::DiscordProvider do
stub1 = stub_request(:post, 'https://discordapp.com/api/webhooks/1234/abcd?wait=true') stub1 = stub_request(:post, 'https://discordapp.com/api/webhooks/1234/abcd?wait=true')
.with(body: hash_including(embeds: [hash_including(author: hash_including(url: /^https?:\/\//))])) .with(body: hash_including(embeds: [hash_including(author: hash_including(url: /^https?:\/\//))]))
.to_return(status: 200) .to_return(status: 200)
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:post, "https://discordapp.com/api/webhooks/1234/abcd?wait=true").to_return(status: 400) stub1 = stub_request(:post, "https://discordapp.com/api/webhooks/1234/abcd?wait=true").to_return(status: 400)
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end

View File

@ -14,14 +14,14 @@ RSpec.describe DiscourseChat::Provider::FlowdockProvider do
it 'sends a request' do it 'sends a request' do
stub1 = stub_request(:post, "https://api.flowdock.com/messages").to_return(status: 200) stub1 = stub_request(:post, "https://api.flowdock.com/messages").to_return(status: 200)
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:post, "https://api.flowdock.com/messages").to_return(status: 404, body: "{ \"error\": \"Not Found\"}") stub1 = stub_request(:post, "https://api.flowdock.com/messages").to_return(status: 404, body: "{ \"error\": \"Not Found\"}")
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
end end

View File

@ -14,14 +14,14 @@ RSpec.describe DiscourseChat::Provider::GitterProvider do
it 'sends a webhook request' do it 'sends a webhook request' do
stub1 = stub_request(:post, chan1.data['webhook_url']).to_return(body: "OK") stub1 = stub_request(:post, chan1.data['webhook_url']).to_return(body: "OK")
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:post, chan1.data['webhook_url']).to_return(status: 404, body: "{ \"error\": \"Not Found\"}") stub1 = stub_request(:post, chan1.data['webhook_url']).to_return(status: 404, body: "{ \"error\": \"Not Found\"}")
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
end end

View File

@ -15,14 +15,14 @@ RSpec.describe DiscourseChat::Provider::GroupmeProvider do
it 'sends a request' do it 'sends a request' do
stub1 = stub_request(:post, "https://api.groupme.com/v3/bots/post").to_return(status: 200) stub1 = stub_request(:post, "https://api.groupme.com/v3/bots/post").to_return(status: 200)
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:post, "https://api.groupme.com/v3/bots/post").to_return(status: 404, body: "{ \"error\": \"Not Found\"}") stub1 = stub_request(:post, "https://api.groupme.com/v3/bots/post").to_return(status: 404, body: "{ \"error\": \"Not Found\"}")
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
end end

View File

@ -15,14 +15,14 @@ RSpec.describe DiscourseChat::Provider::MatrixProvider do
it 'sends the message' do it 'sends the message' do
stub1 = stub_request(:put, %r{https://matrix.org/_matrix/client/r0/rooms/!blah:matrix.org/send/m.room.message/*}).to_return(status: 200) stub1 = stub_request(:put, %r{https://matrix.org/_matrix/client/r0/rooms/!blah:matrix.org/send/m.room.message/*}).to_return(status: 200)
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:put, %r{https://matrix.org/_matrix/client/r0/rooms/!blah:matrix.org/send/m.room.message/*}).to_return(status: 400, body: '{"errmsg":"M_UNKNOWN"}') stub1 = stub_request(:put, %r{https://matrix.org/_matrix/client/r0/rooms/!blah:matrix.org/send/m.room.message/*}).to_return(status: 400, body: '{"errmsg":"M_UNKNOWN"}')
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end

View File

@ -18,7 +18,7 @@ RSpec.describe DiscourseChat::Provider::MattermostProvider do
it 'sends a webhook request' do it 'sends a webhook request' do
stub1 = stub_request(:post, 'https://mattermost.blah/hook/abcd').to_return(status: 200) stub1 = stub_request(:post, 'https://mattermost.blah/hook/abcd').to_return(status: 200)
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
@ -40,7 +40,7 @@ RSpec.describe DiscourseChat::Provider::MattermostProvider do
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:post, "https://mattermost.blah/hook/abcd").to_return(status: 500, body: "error") stub1 = stub_request(:post, "https://mattermost.blah/hook/abcd").to_return(status: 500, body: "error")
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end

View File

@ -15,14 +15,14 @@ RSpec.describe DiscourseChat::Provider::RocketchatProvider do
it 'sends a webhook request' do it 'sends a webhook request' do
stub1 = stub_request(:post, 'https://example.com/abcd').to_return(body: "{\"success\":true}") stub1 = stub_request(:post, 'https://example.com/abcd').to_return(body: "{\"success\":true}")
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:post, 'https://example.com/abcd').to_return(status: 400, body: "{}") stub1 = stub_request(:post, 'https://example.com/abcd').to_return(status: 400, body: "{}")
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end

View File

@ -75,36 +75,65 @@ RSpec.describe DiscourseChat::Provider::SlackProvider do
it 'sends a webhook request' do it 'sends a webhook request' do
stub1 = stub_request(:post, SiteSetting.chat_integration_slack_outbound_webhook_url).to_return(body: "success") stub1 = stub_request(:post, SiteSetting.chat_integration_slack_outbound_webhook_url).to_return(body: "success")
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:post, SiteSetting.chat_integration_slack_outbound_webhook_url).to_return(status: 400, body: "error") stub1 = stub_request(:post, SiteSetting.chat_integration_slack_outbound_webhook_url).to_return(status: 400, body: "error")
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
describe 'with api token' do describe 'with api token' do
before do before do
SiteSetting.chat_integration_slack_access_token = "magic" SiteSetting.chat_integration_slack_access_token = "magic"
@ts = "#{Time.now.to_i}.012345"
@stub1 = stub_request(:post, SiteSetting.chat_integration_slack_outbound_webhook_url).to_return(body: "success") @stub1 = stub_request(:post, SiteSetting.chat_integration_slack_outbound_webhook_url).to_return(body: "success")
@stub2 = stub_request(:post, %r{https://slack.com/api/chat.postMessage}).to_return(body: "{\"ok\":true, \"ts\": \"#{Time.now.to_i}.012345\", \"message\": {\"attachments\": [], \"username\":\"blah\", \"text\":\"blah2\"} }", headers: { 'Content-Type' => 'application/json' }) @thread_stub = stub_request(:post, %r{https://slack.com/api/chat.postMessage}).with(body: hash_including("thread_ts" => @ts)).to_return(body: "{\"ok\":true, \"ts\": \"12345.67890\", \"message\": {\"attachments\": [], \"username\":\"blah\", \"text\":\"blah2\"} }", headers: { 'Content-Type' => 'application/json' })
@stub3 = stub_request(:post, %r{https://slack.com/api/chat.update}).to_return(body: '{"ok":true, "ts": "some_message_id"}', headers: { 'Content-Type' => 'application/json' }) @stub2 = stub_request(:post, %r{https://slack.com/api/chat.postMessage}).to_return(body: "{\"ok\":true, \"ts\": \"#{@ts}\", \"message\": {\"attachments\": [], \"username\":\"blah\", \"text\":\"blah2\"} }", headers: { 'Content-Type' => 'application/json' })
@channel = DiscourseChat::Channel.create(provider: 'dummy')
end end
it 'sends an api request' do it 'sends an api request' do
expect(@stub2).to have_been_requested.times(0) expect(@stub2).to have_been_requested.times(0)
expect(@thread_stub).to have_been_requested.times(0)
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(@stub1).to have_been_requested.times(0) expect(@stub1).to have_been_requested.times(0)
expect(@stub2).to have_been_requested.once expect(@stub2).to have_been_requested.once
expect(post.topic.slack_thread_id).to eq(@ts)
expect(@thread_stub).to have_been_requested.times(0)
end
it 'sends thread id for thread' do
expect(@thread_stub).to have_been_requested.times(0)
rule = DiscourseChat::Rule.create(channel: @channel, filter: "thread")
post.topic.slack_thread_id = @ts
described_class.trigger_notification(post, chan1, rule)
expect(@thread_stub).to have_been_requested.once
end
it 'recognizes slack thread ts in comment' do
post.update!(cooked: "cooked", raw: <<~RAW
My fingers are typing words that improve `raw_quality`
<!--SLACK_CHANNEL_ID=UIGNOREFORNOW;SLACK_TS=1501801629.052212-->
RAW
)
rule = DiscourseChat::Rule.create(channel: @channel, filter: "thread")
post.topic.slack_thread_id = nil
described_class.trigger_notification(post, chan1, rule)
expect(post.topic.slack_thread_id).to eq('1501801629.052212')
end end
it 'handles errors correctly' do it 'handles errors correctly' do
@stub2 = stub_request(:post, %r{https://slack.com/api/chat.postMessage}).to_return(body: "{\"ok\":false }", headers: { 'Content-Type' => 'application/json' }) @stub2 = stub_request(:post, %r{https://slack.com/api/chat.postMessage}).to_return(body: "{\"ok\":false }", headers: { 'Content-Type' => 'application/json' })
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(@stub2).to have_been_requested.once expect(@stub2).to have_been_requested.once
end end

View File

@ -22,7 +22,7 @@ RSpec.describe DiscourseChat::Provider::SlackProvider::SlackTranscript do
"type": "message", "type": "message",
"user": "U6E2W7R8C", "user": "U6E2W7R8C",
"text": "Which one?", "text": "Which one?",
"ts": "1501801634.053761" "ts": "1501801635.053761"
}, },
{ {
"type": "message", "type": "message",
@ -32,7 +32,7 @@ RSpec.describe DiscourseChat::Provider::SlackProvider::SlackTranscript do
}, },
{ {
"type": "message", "type": "message",
"user": "U6E2W7R8C", "user": "U820GH3LA",
"text": "I'm interested!!", "text": "I'm interested!!",
"ts": "1501801634.053761", "ts": "1501801634.053761",
"thread_ts": "1501801629.052212" "thread_ts": "1501801629.052212"
@ -70,6 +70,24 @@ RSpec.describe DiscourseChat::Provider::SlackProvider::SlackTranscript do
let(:users_fixture) { let(:users_fixture) {
[ [
{
id: "U6JSSESES",
name: "threader",
profile: {
image_24: "https://example.com/avatar",
display_name: "Threader",
real_name: "A. Threader"
}
},
{
id: "U820GH3LA",
name: "responder",
profile: {
image_24: "https://example.com/avatar",
display_name: "Responder",
real_name: "A. Responder"
}
},
{ {
id: "U5Z773QLS", id: "U5Z773QLS",
name: "awesomeguyemail", name: "awesomeguyemail",
@ -160,18 +178,24 @@ RSpec.describe DiscourseChat::Provider::SlackProvider::SlackTranscript do
let(:thread_transcript) { described_class.new(channel_name: "#general", channel_id: "G1234", requested_thread_ts: "1501801629.052212") } let(:thread_transcript) { described_class.new(channel_name: "#general", channel_id: "G1234", requested_thread_ts: "1501801629.052212") }
before do before do
thread_transcript.load_user_data
stub_request(:post, "https://slack.com/api/conversations.replies") stub_request(:post, "https://slack.com/api/conversations.replies")
.with(body: hash_including(token: "abcde", channel: 'G1234', ts: "1501801629.052212")) .with(body: hash_including(token: "abcde", channel: 'G1234', ts: "1501801629.052212"))
.to_return(status: 200, body: { ok: true, messages: messages_fixture }.to_json) .to_return(status: 200, body: { ok: true, messages: messages_fixture[3..4] }.to_json)
thread_transcript.load_chat_history thread_transcript.load_chat_history
end end
it 'includes messages in a thread' do it 'includes messages in a thread' do
expect(thread_transcript.messages.length).to eq(7) expect(thread_transcript.messages.length).to eq(2)
end end
it 'loads in chronological order' do # replies API presents messages in actual chronological order it 'loads in chronological order' do # replies API presents messages in actual chronological order
expect(thread_transcript.messages.first.ts).to eq('1501801665.062694') expect(thread_transcript.messages.first.ts).to eq('1501801629.052212')
end
it 'includes slack thread identifiers in body' do
text = thread_transcript.build_transcript
expect(text).to include("<!--SLACK_CHANNEL_ID=G1234;SLACK_TS=1501801629.052212-->")
end end
end end
@ -249,7 +273,7 @@ RSpec.describe DiscourseChat::Provider::SlackProvider::SlackTranscript do
it 'handles usernames correctly' do it 'handles usernames correctly' do
expect(transcript.first_message.username).to eq('awesomeguy') # Normal user expect(transcript.first_message.username).to eq('awesomeguy') # Normal user
expect(transcript.messages[1].username).to eq('Test_Community') # Bot user expect(transcript.messages[1].username).to eq('Test_Community') # Bot user
expect(transcript.messages[2].username).to eq(nil) # Unknown normal user expect(transcript.messages[3].username).to eq(nil) # Unknown normal user
# Normal user, display_name not set (fall back to real_name) # Normal user, display_name not set (fall back to real_name)
expect(transcript.messages[4].username).to eq('another_guy') expect(transcript.messages[4].username).to eq('another_guy')
end end

View File

@ -16,14 +16,14 @@ RSpec.describe DiscourseChat::Provider::TelegramProvider do
it 'sends a webhook request' do it 'sends a webhook request' do
stub1 = stub_request(:post, 'https://api.telegram.org/botTOKEN/sendMessage').to_return(body: "{\"ok\":true}") stub1 = stub_request(:post, 'https://api.telegram.org/botTOKEN/sendMessage').to_return(body: "{\"ok\":true}")
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:post, 'https://api.telegram.org/botTOKEN/sendMessage').to_return(body: "{\"ok\":false, \"description\":\"chat not found\"}") stub1 = stub_request(:post, 'https://api.telegram.org/botTOKEN/sendMessage').to_return(body: "{\"ok\":false, \"description\":\"chat not found\"}")
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end

View File

@ -17,14 +17,14 @@ RSpec.describe DiscourseChat::Provider::ZulipProvider do
it 'sends a webhook request' do it 'sends a webhook request' do
stub1 = stub_request(:post, 'https://hello.world/api/v1/messages').to_return(status: 200) stub1 = stub_request(:post, 'https://hello.world/api/v1/messages').to_return(status: 200)
described_class.trigger_notification(post, chan1) described_class.trigger_notification(post, chan1, nil)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end
it 'handles errors correctly' do it 'handles errors correctly' do
stub1 = stub_request(:post, 'https://hello.world/api/v1/messages').to_return(status: 400, body: '{}') stub1 = stub_request(:post, 'https://hello.world/api/v1/messages').to_return(status: 400, body: '{}')
expect(stub1).to have_been_requested.times(0) expect(stub1).to have_been_requested.times(0)
expect { described_class.trigger_notification(post, chan1) }.to raise_exception(::DiscourseChat::ProviderError) expect { described_class.trigger_notification(post, chan1, nil) }.to raise_exception(::DiscourseChat::ProviderError)
expect(stub1).to have_been_requested.once expect(stub1).to have_been_requested.once
end end

View File

@ -141,11 +141,12 @@ RSpec.describe DiscourseChat::Rule do
it 'can be sorted by precedence' do it 'can be sorted by precedence' do
rule2 = DiscourseChat::Rule.create(channel: channel, filter: 'mute') rule2 = DiscourseChat::Rule.create(channel: channel, filter: 'mute')
rule3 = DiscourseChat::Rule.create(channel: channel, filter: 'follow') rule3 = DiscourseChat::Rule.create(channel: channel, filter: 'follow')
rule4 = DiscourseChat::Rule.create(channel: channel, filter: 'mute') rule4 = DiscourseChat::Rule.create(channel: channel, filter: 'thread')
rule5 = DiscourseChat::Rule.create(channel: channel, filter: 'mute')
expect(DiscourseChat::Rule.all.length).to eq(4) expect(DiscourseChat::Rule.all.length).to eq(5)
expect(DiscourseChat::Rule.all.order_by_precedence.map(&:filter)).to eq(["mute", "mute", "watch", "follow"]) expect(DiscourseChat::Rule.all.order_by_precedence.map(&:filter)).to eq(["mute", "mute", "thread", "watch", "follow"])
end end
end end
@ -190,6 +191,8 @@ RSpec.describe DiscourseChat::Rule do
end end
it 'validates filter correctly' do it 'validates filter correctly' do
expect(rule.valid?).to eq(true)
rule.filter = 'thread'
expect(rule.valid?).to eq(true) expect(rule.valid?).to eq(true)
rule.filter = 'follow' rule.filter = 'follow'
expect(rule.valid?).to eq(true) expect(rule.valid?).to eq(true)

View File

@ -93,7 +93,7 @@ RSpec.describe DiscourseChat::Manager do
end end
it "should respect watch over follow" do it "should respect watch over follow" do
DiscourseChat::Rule.create!(channel: chan1, filter: 'follow', category_id: nil) # Wildcard watch DiscourseChat::Rule.create!(channel: chan1, filter: 'follow', category_id: nil) # Wildcard follow
DiscourseChat::Rule.create!(channel: chan1, filter: 'watch', category_id: category.id) # Specific watch DiscourseChat::Rule.create!(channel: chan1, filter: 'watch', category_id: category.id) # Specific watch
manager.trigger_notifications(second_post.id) manager.trigger_notifications(second_post.id)
@ -101,6 +101,15 @@ RSpec.describe DiscourseChat::Manager do
expect(provider.sent_to_channel_ids).to contain_exactly(chan1.id) expect(provider.sent_to_channel_ids).to contain_exactly(chan1.id)
end end
it "should respect thread over watch" do
DiscourseChat::Rule.create!(channel: chan1, filter: 'watch', category_id: nil) # Wildcard watch
DiscourseChat::Rule.create!(channel: chan1, filter: 'thread', category_id: category.id) # Specific thread
manager.trigger_notifications(second_post.id)
expect(provider.sent_to_channel_ids).to contain_exactly(chan1.id)
end
it "should not notify about private messages" do it "should not notify about private messages" do
DiscourseChat::Rule.create!(channel: chan1, filter: 'follow', category_id: nil) # Wildcard watch DiscourseChat::Rule.create!(channel: chan1, filter: 'follow', category_id: nil) # Wildcard watch