├── LICENSE
├── README.md
├── app
└── jobs
│ └── regular
│ └── notify_slack.rb
├── assets
├── javascripts
│ └── discourse
│ │ ├── controllers
│ │ └── admin-plugins-slack.js.es6
│ │ ├── models
│ │ └── filter-rule.js.es6
│ │ ├── routes
│ │ └── admin-plugins-slack.js.es6
│ │ ├── slack-route-map.js.es6
│ │ └── templates
│ │ └── admin
│ │ └── plugins-slack.hbs
└── stylesheets
│ └── slack-admin.scss
├── config
├── locales
│ ├── client.en.yml
│ ├── client.it.yml
│ ├── server.en.yml
│ └── server.it.yml
└── settings.yml
├── lib
├── discourse_slack
│ ├── slack.rb
│ └── slack_message_formatter.rb
└── validators
│ └── discourse_slack_enabled_setting_validator.rb
├── plugin.rb
└── spec
├── integration
└── discourse_slack
│ └── slack_spec.rb
├── jobs
└── notify_slack_spec.rb
└── lib
├── discourse_slack
├── slack_message_formatter_spec.rb
└── slack_spec.rb
└── post_creator_spec.rb
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 Civilized Discourse Construction Kit, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | :warning: This plugin is no longer supported. Please use [discourse-chat-integration](https://github.com/discourse/discourse-chat-integration) instead. Migration will take place automatically.
2 |
--------------------------------------------------------------------------------
/app/jobs/regular/notify_slack.rb:
--------------------------------------------------------------------------------
1 | module Jobs
2 | class NotifySlack < Jobs::Base
3 | def execute(args)
4 | DiscourseSlack::Slack.notify(args[:post_id])
5 | end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/controllers/admin-plugins-slack.js.es6:
--------------------------------------------------------------------------------
1 | import FilterRule from 'discourse/plugins/discourse-slack-official/discourse/models/filter-rule';
2 | import { ajax } from 'discourse/lib/ajax';
3 | import { popupAjaxError } from 'discourse/lib/ajax-error';
4 | import computed from "ember-addons/ember-computed-decorators";
5 |
6 | export default Ember.Controller.extend({
7 | filters: [
8 | { id: 'watch', name: I18n.t('slack.future.watch'), icon: 'exclamation-circle' },
9 | { id: 'follow', name: I18n.t('slack.future.follow'), icon: 'circle'},
10 | { id: 'mute', name: I18n.t('slack.future.mute'), icon: 'times-circle' }
11 | ],
12 |
13 | editing: FilterRule.create({}),
14 |
15 | @computed('editing.channel')
16 | saveDisabled(channel) {
17 | return Ember.isEmpty(channel);
18 | },
19 |
20 | actions: {
21 | // TODO: Properly implement logic on the backend
22 | // edit(rule) {
23 | // this.set(
24 | // 'editing',
25 | // FilterRule.create(rule.getProperties('filter', 'category_id', 'channel', 'tags'))
26 | // );
27 | // },
28 |
29 | save() {
30 | const rule = this.get('editing');
31 |
32 | ajax("/slack/list.json", {
33 | method: 'PUT',
34 | data: rule.getProperties('filter', 'category_id', 'channel', 'tags')
35 | }).then(() => {
36 | const model = this.get('model');
37 | const obj = model.find(x => (x.get('category_id') === rule.get('category_id') && x.get('channel') === rule.get('channel') && x.get('tags') === rule.get('tags')));
38 |
39 | if (obj) {
40 | obj.setProperties({
41 | channel: rule.channel,
42 | filter: rule.filter,
43 | tags: rule.tags
44 | });
45 | } else {
46 | model.pushObject(FilterRule.create(rule.getProperties('filter', 'category_id', 'channel', 'tags')));
47 | }
48 | }).catch(popupAjaxError);
49 | },
50 |
51 | delete(rule) {
52 | const model = this.get('model');
53 |
54 | ajax("/slack/list.json", {
55 | method: 'DELETE',
56 | data: rule.getProperties('filter', 'category_id', 'channel', 'tags')
57 | }).then(() => {
58 | const obj = model.find((x) => (x.get('category_id') === rule.get('category_id') && x.get('channel') === rule.get('channel') && x.get('tags') === rule.get('tags')));
59 | model.removeObject(obj);
60 | }).catch(popupAjaxError);
61 | },
62 |
63 | testNotification() {
64 | this.set('testingNotification', true);
65 |
66 | ajax("/slack/test.json", { method: 'PUT' })
67 | .catch(popupAjaxError)
68 | .finally(() => {
69 | this.set('testingNotification', false);
70 | });
71 | },
72 |
73 | resetSettings() {
74 | ajax("/slack/reset_settings.json", { method: 'PUT' }).catch(popupAjaxError);
75 | }
76 | }
77 | });
78 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/models/filter-rule.js.es6:
--------------------------------------------------------------------------------
1 | import RestModel from 'discourse/models/rest';
2 | import Category from 'discourse/models/category';
3 | import computed from "ember-addons/ember-computed-decorators";
4 |
5 | export default RestModel.extend({
6 | category_id: null,
7 | channel: '',
8 | filter: null,
9 |
10 | @computed('category_id')
11 | categoryName(categoryId) {
12 | if (!categoryId) {
13 | return I18n.t('slack.choose.all_categories');
14 | }
15 |
16 | const category = Category.findById(categoryId);
17 | if (!category) {
18 | return I18n.t('slack.choose.deleted_category');
19 | }
20 |
21 | return category.get('name');
22 | },
23 |
24 | @computed('filter')
25 | filterName(filter) {
26 | return I18n.t(`slack.present.${filter}`);
27 | }
28 | });
29 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/routes/admin-plugins-slack.js.es6:
--------------------------------------------------------------------------------
1 | import FilterRule from 'discourse/plugins/discourse-slack-official/discourse/models/filter-rule';
2 | import { ajax } from 'discourse/lib/ajax';
3 |
4 | export default Discourse.Route.extend({
5 | model() {
6 | return ajax("/slack/list.json").then(result => {
7 | return result.slack.map(v => FilterRule.create(v));
8 | });
9 | }
10 | });
11 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/slack-route-map.js.es6:
--------------------------------------------------------------------------------
1 | export default {
2 | resource: 'admin.adminPlugins',
3 | path: '/plugins',
4 | map() {
5 | this.route('slack');
6 | }
7 | };
8 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/templates/admin/plugins-slack.hbs:
--------------------------------------------------------------------------------
1 |
2 |
{{i18n "slack.header.rules"}}
3 |
4 |
5 |
6 | {{i18n "slack.category"}} |
7 |
8 | {{#if siteSettings.tagging_enabled}}
9 | {{i18n "slack.tags"}} |
10 | {{/if}}
11 |
12 | {{i18n "slack.channel"}} |
13 | {{i18n "slack.filter"}} |
14 | |
15 |
16 |
17 | {{#each model as |rule|}}
18 |
19 | {{rule.categoryName}} |
20 |
21 | {{#if siteSettings.tagging_enabled}}
22 | {{rule.tags}} |
23 | {{/if}}
24 |
25 | {{rule.channel}} |
26 | {{rule.filterName}} |
27 |
28 |
29 | {{!-- {{d-button action="edit" actionParam=rule icon="pencil" class="edit" title="slack.edit"}} --}}
30 | {{d-button action="delete" actionParam=rule icon="trash-o" class="delete btn-danger" title="slack.delete"}}
31 | |
32 |
33 | {{/each}}
34 |
35 |
36 |
37 | {{category-chooser
38 | value=editing.category_id
39 | rootNoneLabel="slack.choose.all_categories"
40 | rootNone=true}}
41 | |
42 |
43 | {{#if siteSettings.tagging_enabled}}
44 | {{tag-chooser tags=editing.tags placeholderKey="slack.choose.tags"}} |
45 | {{/if}}
46 |
47 | {{text-field value=editing.channel placeholderKey="slack.choose.channel" class="channel"}} |
48 | {{combo-box content=filters value=editing.filter}} |
49 |
50 |
51 | {{d-button
52 | action="save"
53 | icon="save"
54 | class="save btn-primary"
55 | title="save"
56 | disabled=saveDisabled}}
57 | |
58 |
59 |
60 |
61 |
{{{i18n "slack.command.help"}}}
62 |
63 | {{d-button action="testNotification"
64 | icon="rocket"
65 | disabled=testingNotification
66 | title="slack.test_notification"
67 | label="slack.test_notification"}}
68 |
69 | {{#d-button
70 | action="resetSettings"
71 | icon="trash"
72 | title="slack.reset_settings"
73 | label="slack.reset_settings"}}
74 | {{/d-button}}
75 |
76 |
--------------------------------------------------------------------------------
/assets/stylesheets/slack-admin.scss:
--------------------------------------------------------------------------------
1 | .admin-detail #slack {
2 | .new-filter input.channel {
3 | width: 190px;
4 | margin-bottom: 0px;
5 | }
6 |
7 | h1 {
8 | margin-bottom: 15px;
9 | }
10 |
11 | .error {
12 | color: #f55959;
13 | }
14 |
15 | .admin-contents table .new-filter td {
16 | padding: 8px 0;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/config/locales/client.en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | js:
3 | slack:
4 | title: 'Slack'
5 | header:
6 | rules: 'Filter rules'
7 | category: 'Category'
8 | channel: 'Channel'
9 | filter: 'Filter'
10 | tags: 'Tags'
11 | test_notification: 'Test Notification'
12 | reset_settings: 'Reset All Settings'
13 | edit: 'Edit'
14 | delete: 'Delete'
15 | choose:
16 | all_categories: 'All categories'
17 | deleted_category: 'Deleted category'
18 | filter: 'Choose a filter'
19 | tags: 'Choose tags'
20 | channel: '#channel, @user or Channel ID'
21 | error:
22 | category: 'Please choose a category.'
23 | channel: 'Please enter a channel to post to.'
24 | filter: 'Please choose a filter.'
25 | command:
26 | help: "To modify these settings from slack, set up a slash command using these instructions."
27 | present:
28 | mute: 'Muting'
29 | follow: 'First post only'
30 | watch: 'All posts and replies'
31 | future:
32 | mute: 'Mute notifications'
33 | follow: 'Notify of first post'
34 | watch: 'Notify of all posts and replies'
35 |
--------------------------------------------------------------------------------
/config/locales/client.it.yml:
--------------------------------------------------------------------------------
1 | it:
2 | js:
3 | slack:
4 | title: 'Slack'
5 | header:
6 | rules: 'Regole filtri'
7 | category: 'Categoria'
8 | channel: 'Canale'
9 | filter: 'Filtro'
10 | tags: 'Etichette'
11 | test_notification: 'Notifica di Prova'
12 | reset_settings: 'Ripristina Tutte Le Impostazioni'
13 | edit: 'Modifica'
14 | delete: 'Cancella'
15 | choose:
16 | all_categories: 'Tutte le categorie'
17 | deleted_category: 'Categoria eliminata'
18 | filter: 'Scegli un filtro'
19 | tags: 'Scegli le etichette'
20 | channel: '#canale, @user o ID Canale'
21 | error:
22 | category: 'Per favore scegli una categoria.'
23 | channel: 'Per favore inserisci un canale per inviare messaggi.'
24 | filter: 'Per favore scegli un filtro.'
25 | command:
26 | help: "Per modificare queste impostazioni da slack, imposta uno slash command usando queste istruzioni."
27 | present:
28 | mute: 'Silenzia'
29 | follow: 'Solo il primo messaggio'
30 | watch: 'Tutti i messaggi e le risposte'
31 | future:
32 | mute: 'Disattiva le notifiche'
33 | follow: 'Notifica del primo messaggio'
34 | watch: 'Notifica di tutti i messaggi e le risposte'
35 |
--------------------------------------------------------------------------------
/config/locales/server.en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | site_settings:
3 | slack_enabled: 'Enable the discourse-slack-official plugin'
4 | slack_discourse_username: 'Name of user act as when fetching content'
5 | slack_incoming_webhook_token: 'Token sent in the payload from the Slack outoing webhook'
6 | slack_outbound_webhook_url: 'URL for outbound slack requests'
7 | slack_discourse_excerpt_length: 'Post excerpt length'
8 | slack_icon_url: 'Icon to post to slack with (Defaults to forum icon)'
9 | slack_access_token: 'Token if you are using the Web API instead of webhooks'
10 | post_to_slack_window_secs: 'Wait (n) seconds before posting to slack, to give users a chance to edit and finalize their posts.'
11 | errors:
12 | invalid_webhook_url: "'slack outbound webhook url' is not a valid URL."
13 | slack_api_configs_are_empty: "You must set 'slack outbound webhook url' or 'slack access token' before enabling this setting."
14 | slack_discourse_username_is_empty: "You must set 'slack discourse username' before enabling this setting."
15 | slack:
16 | not_supported: The discourse-slack-official plugin is no longer supported. Please migrate to discourse-chat-integration, which supports all of the same functionality.
17 | message:
18 | not_found:
19 | tag: "I can't find the *%{name}* tag."
20 | category: "I can't find the *%{name}* category. Did you mean: %{list}"
21 | success:
22 | all_categories: "*%{command} all categories* on this channel."
23 | category: "*%{command}* category *%{name}*"
24 | tag: "*%{command}* tag *%{name}*"
25 | status:
26 | all_categories: "This channel is %{command} *all categories*"
27 | category: "This channel is %{command} category *%{name}*"
28 | with_tags: " with tags *%{tags}*"
29 | available_categories: "\nHere are your available categories: %{list}"
30 | help: |
31 | `/discourse [watch|follow|mute|unset|help|status] [category|tag:name|all]`
32 | *watch* – notify this channel for new topics and new replies
33 | *follow* – notify this channel for new topics
34 | *mute* – stop notifying this channel
35 | *status* – show current notification state and categories
36 | *unset* – unset a notification state for a tag or category
37 | command:
38 | past:
39 | mute: 'muted'
40 | follow: 'followed'
41 | watch: 'watched'
42 | unset: 'unset'
43 | present:
44 | mute: 'muting'
45 | follow: 'following'
46 | watch: 'watching'
47 |
--------------------------------------------------------------------------------
/config/locales/server.it.yml:
--------------------------------------------------------------------------------
1 | it:
2 | site_settings:
3 | slack_enabled: 'Abilita discourse-slack-official plugin'
4 | slack_discourse_username: "Nome dell'utente abilitato al recupero dei contenuti"
5 | slack_incoming_webhook_token: 'Token inviato nel payload da Slack outoing webhook'
6 | slack_outbound_webhook_url: 'URL per le richieste in uscita da slack'
7 | slack_discourse_excerpt_length: 'Lunghezza estratto del messaggio'
8 | slack_icon_url: "Icona da inviare a slack (Il valore predefinito è l'icona del forum)"
9 | slack_access_token: 'Token se stai usando le Web API invece dei webhooks'
10 | post_to_slack_window_secs: 'Aspetta (n) secondi prima di pubblicare su slack, per dare agli utenti la possibilità di modificare e finalizzare i loro messaggi.'
11 | errors:
12 | invalid_webhook_url: "'slack outbound webhook url' non è un URL valido."
13 | slack_api_configs_are_empty: "Devi configurare 'slack outbound webhook url' or 'slack access token' prima di abilitare questa impostazione."
14 | slack_discourse_username_is_empty: "Devi configurare 'slack discourse username' prima di abilitare questa impostazione."
15 | slack:
16 | message:
17 | not_found:
18 | tag: "Non riesco a trovare l'etichetta *%{name}*."
19 | category: "Non riesco a trovare la categoria *%{name}*. Intendevi: %{list}"
20 | success:
21 | all_categories: "*%{command} tutte le categorie* su questo canale."
22 | category: "*%{command}* categoria *%{name}*"
23 | tag: "*%{command}* etichetta *%{name}*"
24 | status:
25 | all_categories: "channel è %{command} *tutte le categorie*"
26 | category: "channel è %{command} categoria *%{name}*"
27 | available_categories: "\nEcco le categorie disponibili: %{list}"
28 | help: |
29 | `/discourse [watch|follow|mute|help|status] [category|tag:name|all]`
30 | *watch* – notifica su questo canale i nuovi argomenti e le nuove risposte
31 | *follow* – notifica su questo canale i nuovi argomenti
32 | *mute* – smetti di inviare notifiche a questo canale
33 | *status* – mostra lo stato attuale delle notifiche e delle categorie
34 | command:
35 | past:
36 | mute: 'silenziato'
37 | follow: 'seguito'
38 | watch: 'osservato'
39 | present:
40 | mute: 'silenziando'
41 | follow: 'seguendo'
42 | watch: 'osservando'
43 |
--------------------------------------------------------------------------------
/config/settings.yml:
--------------------------------------------------------------------------------
1 | plugins:
2 | slack_enabled:
3 | default: false
4 | client: true
5 | validator: "DiscourseSlackEnabledSettingValidator"
6 | slack_incoming_webhook_token:
7 | default: ''
8 | slack_outbound_webhook_url:
9 | default: ''
10 | slack_discourse_username:
11 | default: system
12 | slack_discourse_excerpt_length:
13 | default: 400
14 | slack_icon_url:
15 | default: ''
16 | slack_access_token:
17 | default: ''
18 | post_to_slack_window_secs:
19 | default: 20
20 |
--------------------------------------------------------------------------------
/lib/discourse_slack/slack.rb:
--------------------------------------------------------------------------------
1 | module DiscourseSlack
2 | class Slack
3 | KEY_PREFIX = 'category_'.freeze
4 |
5 | def self.filter_to_present(filter)
6 | I18n.t("slack.command.present.#{filter}")
7 | end
8 |
9 | def self.filter_to_past(filter)
10 | I18n.t("slack.command.past.#{filter}")
11 | end
12 |
13 | def self.excerpt(post, max_length = SiteSetting.slack_discourse_excerpt_length)
14 | doc = Nokogiri::HTML.fragment(post.excerpt(max_length,
15 | remap_emoji: true,
16 | keep_onebox_source: true
17 | ))
18 |
19 | SlackMessageFormatter.format(doc.to_html)
20 | end
21 |
22 | def self.format_channel(name)
23 | (name.include?("@") || name.include?("\#")) ? name : "<##{name}>"
24 | end
25 |
26 | def self.format_tags(names)
27 | return "" unless SiteSetting.tagging_enabled? && names.present?
28 |
29 | I18n.t("slack.message.status.with_tags", tags: names.join(", "))
30 | end
31 |
32 | def self.available_categories
33 | cat_list = (CategoryList.new(guardian).categories.map { |category| category.slug }).join(', ')
34 | I18n.t("slack.message.available_categories", list: cat_list)
35 | end
36 |
37 | def self.status(channel)
38 | rows = PluginStoreRow.where(plugin_name: DiscourseSlack::PLUGIN_NAME).where("key ~* :pat", pat: '^category_.*')
39 | text = ""
40 |
41 | categories = rows.map { |item| item.key.gsub('category_', '') }
42 |
43 | Category.where(id: categories).each do | category |
44 | get_store_by_channel(channel, category.id).each do |row|
45 | text << I18n.t("slack.message.status.category",
46 | command: filter_to_present(row[:filter]),
47 | name: category.name)
48 | text << format_tags(row[:tags]) << "\n"
49 | end
50 | end
51 |
52 | get_store_by_channel(channel).each do |row|
53 | text << I18n.t("slack.message.status.all_categories",
54 | command: filter_to_present(row[:filter]))
55 | text << format_tags(row[:tags]) << "\n"
56 | end
57 | text << available_categories
58 | text
59 | end
60 |
61 | def self.guardian
62 | Guardian.new(User.find_by(username: SiteSetting.slack_discourse_username))
63 | end
64 |
65 | def self.help
66 | I18n.t("slack.help")
67 | end
68 |
69 | def self.slack_message(post, channel)
70 | display_name = "@#{post.user.username}"
71 | full_name = post.user.name || ""
72 |
73 | if !(full_name.strip.empty?) && (full_name.strip.gsub(' ', '_').casecmp(post.user.username) != 0) && (full_name.strip.gsub(' ', '').casecmp(post.user.username) != 0)
74 | display_name = "#{full_name} @#{post.user.username}"
75 | end
76 |
77 | topic = post.topic
78 |
79 | category = (topic.category.parent_category) ? "[#{topic.category.parent_category.name}/#{topic.category.name}]" : "[#{topic.category.name}]"
80 |
81 | icon_url =
82 | if !SiteSetting.slack_icon_url.blank?
83 | SiteSetting.slack_icon_url
84 | elsif !SiteSetting.logo_small_url.blank?
85 | "#{Discourse.base_url}#{SiteSetting.logo_small_url}"
86 | end
87 |
88 | message = {
89 | channel: channel,
90 | username: SiteSetting.title,
91 | icon_url: icon_url,
92 | attachments: []
93 | }
94 |
95 | summary = {
96 | fallback: "#{topic.title} - #{display_name}",
97 | author_name: display_name,
98 | author_icon: post.user.small_avatar_url,
99 | color: "##{topic.category.color}",
100 | text: ::DiscourseSlack::Slack.excerpt(post),
101 | mrkdwn_in: ["text"]
102 | }
103 |
104 | record = ::PluginStore.get(DiscourseSlack::PLUGIN_NAME, "topic_#{post.topic.id}_#{channel}")
105 |
106 | if (SiteSetting.slack_access_token.empty? || post.is_first_post? || record.blank? || (record.present? && ((Time.now.to_i - record[:ts].split('.')[0].to_i) / 60) >= 5))
107 | summary[:title] = "#{topic.title} #{(category == '[uncategorized]') ? '' : category} #{topic.tags.present? ? topic.tags.map(&:name).join(', ') : ''}"
108 | summary[:title_link] = post.full_url
109 | summary[:thumb_url] = post.full_url
110 | end
111 |
112 | message[:attachments].push(summary)
113 | message
114 | end
115 |
116 | def self.get_key(id = nil)
117 | "#{KEY_PREFIX}#{id.present? ? id : '*'}"
118 | end
119 |
120 | def self.update_tag_filter(channel, filter, tag)
121 | data = get_store(nil)
122 | to_delete = []
123 |
124 | index = data.index do |item|
125 | next unless item["channel"] == channel
126 | if item["tags"]
127 | item["tags"] = item["tags"] - [tag]
128 | to_delete << item if item["tags"].empty?
129 | end
130 | item["tags"] && item["filter"] == filter
131 | end
132 |
133 | if filter != "unset"
134 | data[index]['tags'].push(tag) if index
135 | data.push(channel: channel, filter: filter, tags: [tag]) if !index
136 | end
137 |
138 | data = data - to_delete
139 | PluginStore.set(DiscourseSlack::PLUGIN_NAME, get_key(nil), data)
140 | end
141 |
142 | def self.update_all_filter(channel, filter)
143 | update_category_filter(channel, filter, nil)
144 | end
145 |
146 | def self.update_category_filter(channel, filter, category_id)
147 | data = get_store(category_id)
148 | index = data.index do |item|
149 | item["channel"] == channel && !item["tags"]
150 | end
151 |
152 | if index && filter == "unset"
153 | data.delete data[index]
154 | end
155 |
156 | if filter != "unset"
157 | data[index]['filter'] = filter if index
158 | data.push(channel: channel, filter: filter, tags: nil) if !index
159 | end
160 |
161 | PluginStore.set(DiscourseSlack::PLUGIN_NAME, get_key(category_id), data)
162 | end
163 |
164 | def self.create_filter(category_id, channel, filter, tags)
165 | data = get_store(category_id)
166 | tags = Tag.where(name: tags).pluck(:name)
167 | tags = nil if tags.blank?
168 |
169 | data.push(channel: channel, filter: filter, tags: tags)
170 | PluginStore.set(DiscourseSlack::PLUGIN_NAME, get_key(category_id), data)
171 | end
172 |
173 | def self.delete_filter(category_id, channel, tags)
174 | data = get_store(category_id)
175 | tags = nil if tags.blank?
176 |
177 | data.delete_if do |i|
178 | i['channel'] == channel && i['tags'] == tags
179 | end
180 |
181 | if data.empty?
182 | PluginStore.remove(DiscourseSlack::PLUGIN_NAME, get_key(category_id))
183 | else
184 | PluginStore.set(DiscourseSlack::PLUGIN_NAME, get_key(category_id), data)
185 | end
186 | end
187 |
188 | def self.get_store(category_id = nil)
189 | PluginStore.get(DiscourseSlack::PLUGIN_NAME, get_key(category_id)) || []
190 | end
191 |
192 | def self.get_store_by_channel(channel, category_id = nil)
193 | get_store(category_id).select { |r| format_channel(r[:channel]) == channel }
194 | end
195 |
196 | def self.notify(post_id)
197 | post = Post.find_by(id: post_id)
198 | return if post.blank? || post.post_type != Post.types[:regular] || !guardian.can_see?(post)
199 |
200 | topic = post.topic
201 | return if topic.blank? || topic.archetype == Archetype.private_message
202 |
203 | http = Net::HTTP.new(SiteSetting.slack_access_token.empty? ? "hooks.slack.com" : "slack.com" , 443)
204 | http.use_ssl = true
205 |
206 | precedence = { 'mute' => 0, 'watch' => 1, 'follow' => 1 }
207 |
208 | uniq_func = proc { |i| i.values_at(:channel, :tags) }
209 | sort_func = proc { |a, b| precedence[a] <=> precedence[b] }
210 |
211 | items = get_store(topic.category_id) | get_store
212 | responses = []
213 |
214 | items.sort_by(&sort_func).uniq(&uniq_func).each do |i|
215 | topic_tags = (SiteSetting.tagging_enabled? && topic.tags.present?) ? topic.tags.pluck(:name) : []
216 |
217 | next if SiteSetting.tagging_enabled? && i[:tags].present? && (topic_tags & i[:tags]).count == 0
218 | next if (i[:filter] == 'mute') || (!(post.is_first_post?) && i[:filter] == 'follow')
219 |
220 | message = slack_message(post, i[:channel])
221 |
222 | if !(SiteSetting.slack_access_token.empty?)
223 | response = nil
224 | uri = ""
225 | record = ::PluginStore.get(DiscourseSlack::PLUGIN_NAME, "topic_#{post.topic.id}_#{i[:channel]}")
226 |
227 | if (record.present? && ((Time.now.to_i - record[:ts].split('.')[0].to_i) / 60) < 5 && record[:message][:attachments].length < 5)
228 | attachments = record[:message][:attachments]
229 | attachments.concat message[:attachments]
230 |
231 | uri = URI("https://slack.com/api/chat.update" +
232 | "?token=#{SiteSetting.slack_access_token}" +
233 | "&username=#{CGI::escape(record[:message][:username])}" +
234 | "&text=#{CGI::escape(record[:message][:text])}" +
235 | "&channel=#{record[:channel]}" +
236 | "&attachments=#{CGI::escape(attachments.to_json)}" +
237 | "&ts=#{record[:ts]}"
238 | )
239 | else
240 | uri = URI("https://slack.com/api/chat.postMessage" +
241 | "?token=#{SiteSetting.slack_access_token}" +
242 | "&username=#{CGI::escape(message[:username])}" +
243 | "&icon_url=#{CGI::escape(message[:icon_url])}" +
244 | "&channel=#{ message[:channel].gsub('#', '') }" +
245 | "&attachments=#{CGI::escape(message[:attachments].to_json)}"
246 | )
247 | end
248 |
249 | response = http.request(Net::HTTP::Post.new(uri))
250 |
251 | ::PluginStore.set(DiscourseSlack::PLUGIN_NAME, "topic_#{post.topic.id}_#{i[:channel]}", JSON.parse(response.body))
252 | elsif !(SiteSetting.slack_outbound_webhook_url.empty?)
253 | req = Net::HTTP::Post.new(URI(SiteSetting.slack_outbound_webhook_url), 'Content-Type' => 'application/json')
254 | req.body = message.to_json
255 | response = http.request(req)
256 | end
257 |
258 | responses.push(response.body) if response
259 | end
260 |
261 | responses
262 | end
263 | end
264 | end
265 |
--------------------------------------------------------------------------------
/lib/discourse_slack/slack_message_formatter.rb:
--------------------------------------------------------------------------------
1 | module DiscourseSlack
2 | class SlackMessageFormatter < Nokogiri::XML::SAX::Document
3 | attr_reader :excerpt
4 |
5 | def initialize
6 | @excerpt = ""
7 | end
8 |
9 | def self.format(html = '')
10 | me = self.new
11 | parser = Nokogiri::HTML::SAX::Parser.new(me)
12 | parser.parse(html)
13 | me.excerpt
14 | end
15 |
16 | def start_element(name, attributes = [])
17 | if name == "a"
18 | attributes = Hash[*attributes.flatten]
19 | @in_a = true
20 | @excerpt << "<#{absolute_url(attributes['href'])}|"
21 | end
22 | end
23 |
24 | def end_element(name)
25 | if name == "a"
26 | @excerpt << ">"
27 | @in_a = false
28 | end
29 | end
30 |
31 | def characters(string)
32 | string.strip! if @in_a
33 | @excerpt << string
34 | end
35 |
36 | private
37 |
38 | def absolute_url(url)
39 | uri = URI(url) rescue nil
40 |
41 | return Discourse.current_hostname unless uri
42 | return uri.to_s unless [nil, "http", "https"].include? uri.scheme
43 |
44 | begin
45 | uri.host = Discourse.current_hostname if !uri.host
46 | uri.scheme = (SiteSetting.force_https ? 'https' : 'http') if !uri.scheme
47 | uri.to_s
48 | rescue => e
49 | Rails.logger.error [e.message, e.backtrace.join("\n"), "current_hostname: #{Discourse.current_hostname}"].join("\n\n")
50 | end
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/validators/discourse_slack_enabled_setting_validator.rb:
--------------------------------------------------------------------------------
1 | class DiscourseSlackEnabledSettingValidator
2 | def initialize(opts = {})
3 | @opts = opts
4 | end
5 |
6 | def valid_value?(val)
7 | return true if val == 'f'
8 | return false if (SiteSetting.slack_outbound_webhook_url.blank? || !valid_webhook_url?) && SiteSetting.slack_access_token.blank?
9 | return false if SiteSetting.slack_discourse_username.blank? || !valid_slack_username?
10 | true
11 | end
12 |
13 | def error_message
14 | if SiteSetting.slack_outbound_webhook_url.blank? && SiteSetting.slack_access_token.blank?
15 | I18n.t('site_settings.errors.slack_api_configs_are_empty')
16 | elsif !valid_webhook_url?
17 | I18n.t('site_settings.errors.invalid_webhook_url')
18 | elsif SiteSetting.slack_discourse_username.blank?
19 | I18n.t('site_settings.errors.slack_discourse_username_is_empty')
20 | elsif !valid_slack_username?
21 | I18n.t('site_settings.errors.invalid_username')
22 | end
23 | end
24 |
25 | private
26 |
27 | def valid_slack_username?
28 | @valid_user ||= User.where(username: SiteSetting.slack_discourse_username).exists?
29 | end
30 |
31 | def valid_webhook_url?
32 | @valid_webhook_url ||= begin
33 | !!(URI(SiteSetting.slack_outbound_webhook_url).to_s =~ URI::regexp)
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/plugin.rb:
--------------------------------------------------------------------------------
1 | # name: discourse-slack-official
2 | # about: This is intended to be a feature-rich plugin for slack-discourse integration
3 | # version: 1.1.2
4 | # authors: Nick Sahler (nicksahler), Dave McClure (mcwumbly) for slack backdoor code.
5 | # url: https://github.com/discourse/discourse-slack-official
6 |
7 | enabled_site_setting :slack_enabled
8 |
9 | register_asset "stylesheets/slack-admin.scss"
10 |
11 | load File.expand_path('../lib/validators/discourse_slack_enabled_setting_validator.rb', __FILE__)
12 |
13 | after_initialize do
14 | AdminDashboardData.add_problem_check do
15 | I18n.t("slack.not_supported")
16 | end
17 |
18 | load File.expand_path('../lib/discourse_slack/slack.rb', __FILE__)
19 | load File.expand_path('../lib/discourse_slack/slack_message_formatter.rb', __FILE__)
20 |
21 | module ::DiscourseSlack
22 | PLUGIN_NAME = "discourse-slack-official".freeze
23 |
24 | class Engine < ::Rails::Engine
25 | engine_name DiscourseSlack::PLUGIN_NAME
26 | isolate_namespace DiscourseSlack
27 | end
28 | end
29 |
30 | require_dependency File.expand_path('../app/jobs/regular/notify_slack.rb', __FILE__)
31 | require_dependency 'application_controller'
32 | require_dependency 'discourse_event'
33 | require_dependency 'admin_constraint'
34 |
35 | class ::DiscourseSlack::SlackController < ::ApplicationController
36 | requires_plugin DiscourseSlack::PLUGIN_NAME
37 |
38 | before_action :slack_token_valid?, only: :command
39 |
40 | skip_before_action :check_xhr,
41 | :preload_json,
42 | :verify_authenticity_token,
43 | :redirect_to_login_if_required,
44 | only: :command
45 |
46 | def list
47 | out = []
48 |
49 | PluginStoreRow.where(plugin_name: DiscourseSlack::PLUGIN_NAME)
50 | .where("key ~* :pat", pat: "^#{DiscourseSlack::Slack::KEY_PREFIX}.*")
51 | .each do |row|
52 |
53 | PluginStore.cast_value(row.type_name, row.value).each do |rule|
54 | category_id =
55 | if row.key == DiscourseSlack::Slack.get_key
56 | nil
57 | else
58 | row.key.gsub!(DiscourseSlack::Slack::KEY_PREFIX, '')
59 | row.key
60 | end
61 |
62 | out << {
63 | category_id: category_id,
64 | channel: rule[:channel],
65 | filter: rule[:filter],
66 | tags: rule[:tags]
67 | }
68 | end
69 | end
70 |
71 | render json: out
72 | end
73 |
74 | def test_notification
75 | DiscourseSlack::Slack.notify(
76 | Topic.order('RANDOM()')
77 | .find_by(closed: false, archived: false)
78 | .ordered_posts.first.id
79 | )
80 |
81 | render json: success_json
82 | end
83 |
84 | def reset_settings
85 | PluginStoreRow.where(plugin_name: DiscourseSlack::PLUGIN_NAME).destroy_all
86 | render json: success_json
87 | end
88 |
89 | def create
90 | params.permit(:tags, :category_id, :filter, :channel)
91 |
92 | DiscourseSlack::Slack.create_filter(params[:category_id], params[:channel], params[:filter], params[:tags])
93 | render json: success_json
94 | end
95 |
96 | def delete
97 | params.permit(:tags, :channel, :category_id)
98 |
99 | DiscourseSlack::Slack.delete_filter(params[:category_id], params[:channel], params[:tags])
100 | render json: success_json
101 | end
102 |
103 | def command
104 | guardian = DiscourseSlack::Slack.guardian
105 |
106 | tokens = params[:text].split(" ")
107 |
108 | # channel name fix
109 | channel =
110 | case params[:channel_name]
111 | when 'directmessage'
112 | "@#{params[:user_name]}"
113 | when 'privategroup'
114 | params[:channel_id]
115 | else
116 | "##{params[:channel_name]}"
117 | end
118 |
119 | cmd = tokens[0] if tokens.size > 0 && tokens.size < 3
120 |
121 | text =
122 | case cmd
123 | when "watch", "follow", "mute", "unset"
124 | if (tokens.size == 2)
125 | value = tokens[1]
126 | filter_to_past = DiscourseSlack::Slack.filter_to_past(cmd).capitalize
127 |
128 | if SiteSetting.tagging_enabled? && value.start_with?('tag:')
129 | value.sub!('tag:', '')
130 | tag = Tag.find_by(name: value)
131 |
132 | if !tag
133 | I18n.t("slack.message.not_found.tag", name: value)
134 | else
135 | DiscourseSlack::Slack.update_tag_filter(channel, cmd, tag.name)
136 | I18n.t("slack.message.success.tag", command: filter_to_past, name: tag.name)
137 | end
138 | else
139 | if (value.casecmp("all") == 0)
140 | DiscourseSlack::Slack.update_all_filter(channel, cmd)
141 | I18n.t("slack.message.success.all_categories", command: filter_to_past)
142 | elsif (category = Category.find_by(slug: value)) && guardian.can_see_category?(category)
143 | DiscourseSlack::Slack.update_category_filter(channel, cmd, category.id)
144 | I18n.t("slack.message.success.category", command: filter_to_past, name: category.name)
145 | else
146 | cat_list = (CategoryList.new(guardian).categories.map(&:slug)).join(', ')
147 | I18n.t("slack.message.not_found.category", name: tokens[1], list: cat_list)
148 | end
149 | end
150 | else
151 | DiscourseSlack::Slack.help
152 | end
153 | when "status"
154 | DiscourseSlack::Slack.status(channel)
155 | else
156 | DiscourseSlack::Slack.help
157 | end
158 |
159 | render json: { text: text }
160 | end
161 |
162 | def slack_token_valid?
163 | params.require(:token)
164 |
165 | if SiteSetting.slack_incoming_webhook_token.blank? ||
166 | SiteSetting.slack_incoming_webhook_token != params[:token]
167 |
168 | raise Discourse::InvalidAccess.new
169 | end
170 | end
171 |
172 | def topic_route(text)
173 | url = text.slice(text.index("<") + 1, text.index(">") - 1)
174 | url.sub!(Discourse.base_url, '')
175 | route = Rails.application.routes.recognize_path(url)
176 | raise Discourse::NotFound unless route[:controller] == 'topics' && route[:topic_id]
177 | route
178 | end
179 |
180 | def find_post(topic, post_number)
181 | topic.filtered_posts.where(post_number: post_number).first
182 | end
183 |
184 | def find_topic(topic_id, post_number)
185 | user = User.find_by(username: SiteSetting.slack_discourse_username)
186 | TopicView.new(topic_id, user, post_number: post_number)
187 | end
188 | end
189 |
190 | if !PluginStore.get(DiscourseSlack::PLUGIN_NAME, "not_first_time") && !Rails.env.test?
191 | PluginStore.set(DiscourseSlack::PLUGIN_NAME, "not_first_time", true)
192 | PluginStore.set(DiscourseSlack::PLUGIN_NAME, DiscourseSlack::Slack.get_key, [{ channel: "#general", filter: "follow", tags: nil }])
193 | end
194 |
195 | DiscourseEvent.on(:post_created) do |post|
196 | if SiteSetting.slack_enabled?
197 | Jobs.enqueue_in(SiteSetting.post_to_slack_window_secs.seconds,
198 | :notify_slack,
199 | post_id: post.id
200 | )
201 | end
202 | end
203 |
204 | DiscourseSlack::Engine.routes.draw do
205 | post "/command" => "slack#command"
206 |
207 | get "/list" => "slack#list", constraints: AdminConstraint.new
208 | put "/test" => "slack#test_notification", constraints: AdminConstraint.new
209 | put "/reset_settings" => "slack#reset_settings", constraints: AdminConstraint.new
210 | put "/list" => "slack#create", constraints: AdminConstraint.new
211 | delete "/list" => "slack#delete", constraints: AdminConstraint.new
212 | end
213 |
214 | Discourse::Application.routes.prepend do
215 | mount ::DiscourseSlack::Engine, at: "/slack"
216 | end
217 |
218 | add_admin_route "slack.title", "slack"
219 |
220 | Discourse::Application.routes.append do
221 | get "/admin/plugins/slack" => "admin/plugins#index", constraints: StaffConstraint.new
222 | end
223 | end
224 |
--------------------------------------------------------------------------------
/spec/integration/discourse_slack/slack_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe 'Slack', type: :request do
4 | let(:first_post) { Fabricate(:post) }
5 | let(:topic) { Fabricate(:topic, posts: [first_post]) }
6 | let(:admin) { Fabricate(:admin) }
7 | let(:category) { Fabricate(:category) }
8 | let(:tag) { Fabricate(:tag) }
9 |
10 | before do
11 | SiteSetting.slack_outbound_webhook_url = "https://hooks.slack.com/services/abcde"
12 | SiteSetting.slack_enabled = true
13 | end
14 |
15 | shared_examples 'admin constraints' do |action, route|
16 | context 'when user is not signed in' do
17 | it 'should raise the right error' do
18 | expect { public_send(action, route) }.to raise_error(ActionController::RoutingError)
19 | end
20 | end
21 |
22 | context 'when user is not an admin' do
23 | it 'should raise the right error' do
24 | sign_in(Fabricate(:user))
25 | expect { public_send(action, route) }.to raise_error(ActionController::RoutingError)
26 | end
27 | end
28 | end
29 |
30 | describe 'viewing filters' do
31 | include_examples 'admin constraints', 'get', '/slack/list.json'
32 |
33 | context 'when signed in as an admin' do
34 | before do
35 | sign_in(admin)
36 | end
37 |
38 | it 'should return the right response' do
39 | DiscourseSlack::Slack.create_filter(category.id, '#some', 'follow', [tag.name])
40 |
41 | get '/slack/list.json'
42 |
43 | expect(response).to be_success
44 |
45 | filters = JSON.parse(response.body)['slack']
46 |
47 | expect(filters.count).to eq(1)
48 |
49 | expect(filters.first).to eq(
50 | "channel" => "#some",
51 | "category_id" => category.id.to_s,
52 | "tags" => [tag.name],
53 | "filter" => "follow"
54 | )
55 | end
56 | end
57 | end
58 |
59 | describe 'adding a filter' do
60 | include_examples 'admin constraints', 'put', '/slack/list.json'
61 |
62 | context 'as an admin' do
63 | let(:tag) { Fabricate(:tag) }
64 |
65 | before do
66 | sign_in(admin)
67 | end
68 |
69 | it 'should be able to add a new filter' do
70 | channel = '#hello'
71 | category_id = 1
72 | filter = 'follow'
73 |
74 | put '/slack/list.json', params: {
75 | channel: channel,
76 | category_id: category_id,
77 | filter: filter,
78 | tags: [tag.name, 'sometag']
79 | }
80 |
81 | expect(response).to be_success
82 |
83 | data = DiscourseSlack::Slack.get_store(category_id)
84 |
85 | expect(data).to eq([
86 | "channel" => channel,
87 | "filter" => filter,
88 | "tags" => [tag.name]
89 | ])
90 | end
91 | end
92 | end
93 |
94 | describe 'removing a filter' do
95 | include_examples 'admin constraints', 'delete', '/slack/list.json'
96 |
97 | describe 'as an admin' do
98 | before do
99 | sign_in(admin)
100 | end
101 |
102 | it 'should be able to delete a filter' do
103 | DiscourseSlack::Slack.create_filter(category.id, '#some', 'follow', [tag.name])
104 |
105 | delete '/slack/list.json', params: {
106 | category_id: category.id,
107 | channel: '#some',
108 | tags: [tag.name]
109 | }
110 |
111 | expect(DiscourseSlack::Slack.get_store(category.id)).to eq([])
112 | end
113 | end
114 | end
115 |
116 | describe 'testing notification' do
117 | include_examples 'admin constraints', 'put', '/slack/test.json'
118 | end
119 |
120 | describe 'slash commands endpoint' do
121 | describe 'when forum is private' do
122 | it 'should not redirect to login page' do
123 | SiteSetting.login_required = true
124 | token = 'sometoken'
125 | SiteSetting.slack_incoming_webhook_token = token
126 |
127 | post '/slack/command.json', params: { text: 'help', token: token }
128 |
129 | expect(response.status).to eq(200)
130 | end
131 | end
132 |
133 | describe 'when the token is invalid' do
134 | it 'should raise the right error' do
135 | expect { post '/slack/command.json', params: { text: 'help' } }
136 | .to raise_error(ActionController::ParameterMissing)
137 | end
138 | end
139 |
140 | describe 'when incoming webhook token has not been set' do
141 | it 'should raise the right error' do
142 | post '/slack/command.json', params: { text: 'help', token: 'some token' }
143 |
144 | expect(response.status).to eq(403)
145 | end
146 | end
147 |
148 | describe 'when token is valid' do
149 | let(:token) { "Secret Sauce" }
150 |
151 | before do
152 | SiteSetting.slack_incoming_webhook_token = token
153 | end
154 |
155 | describe 'follow command' do
156 | it 'should add the new filter correctly' do
157 | post "/slack/command.json", params: {
158 | text: "follow #{category.slug}",
159 | channel_name: 'welcome',
160 | token: token
161 | }
162 |
163 | json = JSON.parse(response.body)
164 |
165 | expect(json["text"]).to eq(I18n.t(
166 | "slack.message.success.category", command: "Followed", name: category.name
167 | ))
168 |
169 | expect(DiscourseSlack::Slack.get_store(category.id)).to eq([
170 | "channel" => "#welcome",
171 | "filter" => "follow",
172 | "tags" => nil
173 | ])
174 |
175 | post '/slack/command.json', params: {
176 | text: "status",
177 | channel_name: "welcome",
178 | token: token
179 | }
180 |
181 | json = JSON.parse(response.body)
182 |
183 | text = I18n.t("slack.message.status.category",
184 | command: DiscourseSlack::Slack.filter_to_present("follow"),
185 | name: category.name
186 | )
187 |
188 | text << "\n"
189 | text << DiscourseSlack::Slack.available_categories
190 |
191 | expect(json["text"]).to eq(text)
192 | end
193 |
194 | it 'should add the a new tag filter correctly' do
195 | SiteSetting.tagging_enabled = true
196 |
197 | post "/slack/command.json", params: {
198 | text: "follow tag:#{tag.name}",
199 | channel_name: 'welcome',
200 | token: token
201 | }
202 |
203 | expect(response).to be_success
204 |
205 | json = JSON.parse(response.body)
206 |
207 | expect(json["text"]).to eq(I18n.t(
208 | "slack.message.success.tag", command: "Followed", name: tag.name
209 | ))
210 |
211 | expect(DiscourseSlack::Slack.get_store).to eq([
212 | "channel" => "#welcome",
213 | "filter" => "follow",
214 | "tags" => [tag.name]
215 | ])
216 |
217 | tag_2 = Fabricate(:tag)
218 |
219 | post "/slack/command.json", params: {
220 | text: "follow tag:#{tag_2.name}",
221 | channel_name: 'welcome',
222 | token: token
223 | }
224 |
225 | expect(response).to be_success
226 |
227 | expect(DiscourseSlack::Slack.get_store).to eq([
228 | "channel" => "#welcome",
229 | "filter" => "follow",
230 | "tags" => [tag.name, tag_2.name]
231 | ])
232 | end
233 |
234 | it 'should update a tag filter correctly' do
235 | SiteSetting.tagging_enabled = true
236 | tag_2 = Fabricate(:tag)
237 |
238 | post "/slack/command.json", params: {
239 | text: "follow tag:#{tag.name}",
240 | channel_name: 'welcome',
241 | token: token
242 | }
243 |
244 | post "/slack/command.json", params: {
245 | text: "follow tag:#{tag_2.name}",
246 | channel_name: 'welcome',
247 | token: token
248 | }
249 |
250 | expect(DiscourseSlack::Slack.get_store).to contain_exactly(
251 | "channel" => "#welcome", "filter" => "follow", "tags" => [tag.name, tag_2.name],
252 | )
253 |
254 | post "/slack/command.json", params: {
255 | text: "watch tag:#{tag.name}",
256 | channel_name: 'welcome',
257 | token: token
258 | }
259 |
260 | expect(DiscourseSlack::Slack.get_store).to contain_exactly(
261 | { "channel" => "#welcome", "filter" => "follow", "tags" => [tag_2.name] },
262 | "channel" => "#welcome", "filter" => "watch", "tags" => [tag.name],
263 | )
264 |
265 | post "/slack/command.json", params: {
266 | text: "watch tag:#{tag_2.name}",
267 | channel_name: 'welcome',
268 | token: token
269 | }
270 |
271 | expect(DiscourseSlack::Slack.get_store).to contain_exactly(
272 | "channel" => "#welcome", "filter" => "watch", "tags" => [tag.name, tag_2.name],
273 | )
274 | end
275 |
276 | it 'returns a not found message when a tag does not exist' do
277 | SiteSetting.tagging_enabled = true
278 |
279 | post "/slack/command.json", params: {
280 | text: "follow tag:non-existent",
281 | channel_name: 'welcome',
282 | token: token
283 | }
284 |
285 | expect(response).to be_success
286 |
287 | json = JSON.parse(response.body)
288 |
289 | expect(json["text"]).to eq(I18n.t(
290 | "slack.message.not_found.tag", name: "non-existent"
291 | ))
292 |
293 | expect(DiscourseSlack::Slack.get_store(category.id)).to be_empty
294 | end
295 |
296 | it 'should add a category filter and tag filter correctly' do
297 | SiteSetting.tagging_enabled = true
298 | tag_2 = Fabricate(:tag)
299 |
300 | post "/slack/command.json", params: {
301 | text: "watch tag:#{tag.name}",
302 | channel_name: 'welcome',
303 | token: token
304 | }
305 |
306 | expect(DiscourseSlack::Slack.get_store).to contain_exactly(
307 | "channel" => "#welcome", "filter" => "watch", "tags" => [tag.name],
308 | )
309 |
310 | post "/slack/command.json", params: {
311 | text: "follow all",
312 | channel_name: 'welcome',
313 | token: token
314 | }
315 |
316 | expect(DiscourseSlack::Slack.get_store).to contain_exactly(
317 | { "channel" => "#welcome", "filter" => "watch", "tags" => [tag.name] },
318 | "channel" => "#welcome", "filter" => "follow", "tags" => nil,
319 | )
320 | end
321 |
322 | it 'should update a category filter correctly' do
323 | post "/slack/command.json", params: {
324 | text: "follow #{category.slug}",
325 | channel_name: 'welcome',
326 | token: token
327 | }
328 |
329 | json = JSON.parse(response.body)
330 |
331 | expect(json["text"]).to eq(I18n.t(
332 | "slack.message.success.category", command: "Followed", name: category.name
333 | ))
334 |
335 | expect(DiscourseSlack::Slack.get_store(category.id)).to eq([
336 | "channel" => "#welcome",
337 | "filter" => "follow",
338 | "tags" => nil
339 | ])
340 |
341 | post "/slack/command.json", params: {
342 | text: "watch #{category.slug}",
343 | channel_name: 'welcome',
344 | token: token
345 | }
346 |
347 | json = JSON.parse(response.body)
348 |
349 | expect(json["text"]).to eq(I18n.t(
350 | "slack.message.success.category", command: "Watched", name: category.name
351 | ))
352 |
353 | expect(DiscourseSlack::Slack.get_store(category.id)).to eq([
354 | "channel" => "#welcome",
355 | "filter" => "watch",
356 | "tags" => nil
357 | ])
358 | end
359 | end
360 |
361 | describe 'unset category subscription' do
362 | it 'should unset the subscription and return the right response' do
363 | post "/slack/command.json", params: {
364 | text: "follow #{category.slug}",
365 | channel_name: 'welcome',
366 | token: token
367 | }
368 |
369 | post "/slack/command.json", params: {
370 | text: "unset #{category.slug}",
371 | channel_name: 'welcome',
372 | token: token
373 | }
374 |
375 | expect(response).to be_success
376 |
377 | json = JSON.parse(response.body)
378 |
379 | expect(json["text"]).to eq(I18n.t(
380 | "slack.message.success.category", command: "Unset", name: category.name
381 | ))
382 |
383 | expect(DiscourseSlack::Slack.get_store(category.id)).to be_empty
384 | end
385 |
386 | it 'should not unset the category filter for another channel' do
387 | post "/slack/command.json", params: {
388 | text: "follow #{category.slug}",
389 | channel_name: 'welcome',
390 | token: token
391 | }
392 |
393 | post "/slack/command.json", params: {
394 | text: "follow #{category.slug}",
395 | channel_name: 'general',
396 | token: token
397 | }
398 |
399 | post "/slack/command.json", params: {
400 | text: "unset #{category.slug}",
401 | channel_name: 'welcome',
402 | token: token
403 | }
404 |
405 | expect(DiscourseSlack::Slack.get_store(category.id)).to eq([
406 | { "channel" => "#general", "filter" => "follow", "tags" => nil },
407 | ])
408 | end
409 | end
410 |
411 | describe 'unset tag subscription' do
412 | before do
413 | SiteSetting.tagging_enabled = true
414 | end
415 |
416 | it 'should unset the tag subscription and return the right response' do
417 | post "/slack/command.json", params: {
418 | text: "follow tag:#{tag.name}",
419 | channel_name: 'welcome',
420 | token: token
421 | }
422 |
423 | post "/slack/command.json", params: {
424 | text: "unset tag:#{tag.name}",
425 | channel_name: 'welcome',
426 | token: token
427 | }
428 |
429 | expect(response).to be_success
430 | json = JSON.parse(response.body)
431 |
432 | expect(json["text"]).to eq(I18n.t(
433 | "slack.message.success.tag", command: "Unset", name: tag.name
434 | ))
435 | expect(DiscourseSlack::Slack.get_store).to be_empty
436 | end
437 |
438 | it 'should not unset other tag subscriptions for the channel' do
439 | tag_2 = Fabricate(:tag)
440 |
441 | post "/slack/command.json", params: {
442 | text: "follow tag:#{tag.name}",
443 | channel_name: 'welcome',
444 | token: token
445 | }
446 |
447 | post "/slack/command.json", params: {
448 | text: "follow tag:#{tag_2.name}",
449 | channel_name: 'welcome',
450 | token: token
451 | }
452 |
453 | post "/slack/command.json", params: {
454 | text: "unset tag:#{tag.name}",
455 | channel_name: 'welcome',
456 | token: token
457 | }
458 |
459 | expect(DiscourseSlack::Slack.get_store).to contain_exactly(
460 | "channel" => "#welcome", "filter" => "follow", "tags" => [tag_2.name],
461 | )
462 | end
463 |
464 | it 'should not unset the tag subscription for another channel' do
465 | post "/slack/command.json", params: {
466 | text: "follow tag:#{tag.name}",
467 | channel_name: 'welcome',
468 | token: token
469 | }
470 |
471 | post "/slack/command.json", params: {
472 | text: "follow tag:#{tag.name}",
473 | channel_name: 'general',
474 | token: token
475 | }
476 |
477 | post "/slack/command.json", params: {
478 | text: "unset tag:#{tag.name}",
479 | channel_name: 'welcome',
480 | token: token
481 | }
482 |
483 | expect(DiscourseSlack::Slack.get_store).to contain_exactly(
484 | "channel" => "#general", "filter" => "follow", "tags" => [tag.name],
485 | )
486 | end
487 |
488 | it 'should not unset the "all" category subscription for the channel' do
489 | post "/slack/command.json", params: {
490 | text: "follow tag:#{tag.name}",
491 | channel_name: 'welcome',
492 | token: token
493 | }
494 |
495 | post "/slack/command.json", params: {
496 | text: "follow all",
497 | channel_name: 'welcome',
498 | token: token
499 | }
500 |
501 | post "/slack/command.json", params: {
502 | text: "unset tag:#{tag.name}",
503 | channel_name: 'welcome',
504 | token: token
505 | }
506 |
507 | expect(DiscourseSlack::Slack.get_store).to contain_exactly(
508 | "channel" => "#welcome", "filter" => "follow", "tags" => nil,
509 | )
510 | end
511 | end
512 |
513 | describe 'mute command' do
514 | it 'should the new filter correctly' do
515 | post "/slack/command.json", params: {
516 | text: "mute #{category.slug}",
517 | channel_name: 'welcome',
518 | token: token
519 | }
520 |
521 | json = JSON.parse(response.body)
522 |
523 | expect(json["text"]).to eq(I18n.t(
524 | "slack.message.success.category", command: "Muted", name: category.name
525 | ))
526 |
527 | expect(DiscourseSlack::Slack.get_store(category.id)).to eq([
528 | "channel" => "#welcome",
529 | "filter" => "mute",
530 | "tags" => nil
531 | ])
532 | end
533 |
534 | it 'should add the a new tag filter correctly' do
535 | SiteSetting.tagging_enabled = true
536 |
537 | post "/slack/command.json", params: {
538 | text: "mute tag:#{tag.name}",
539 | channel_name: 'welcome',
540 | token: token
541 | }
542 |
543 | json = JSON.parse(response.body)
544 |
545 | expect(json["text"]).to eq(I18n.t(
546 | "slack.message.success.tag", command: "Muted", name: tag.name
547 | ))
548 |
549 | expect(DiscourseSlack::Slack.get_store).to eq([
550 | "channel" => "#welcome",
551 | "filter" => "mute",
552 | "tags" => [tag.name]
553 | ])
554 |
555 | post '/slack/command.json', params: {
556 | text: "status",
557 | channel_name: "welcome",
558 | token: token
559 | }
560 |
561 | json = JSON.parse(response.body)
562 |
563 | text = I18n.t("slack.message.status.all_categories",
564 | command: DiscourseSlack::Slack.filter_to_present("mute"))
565 | text << I18n.t("slack.message.status.with_tags", tags: tag.name) << "\n"
566 | text << DiscourseSlack::Slack.available_categories
567 |
568 | expect(json["text"]).to eq(text)
569 | end
570 | end
571 |
572 | describe 'help command' do
573 | it 'should return the right response' do
574 | post '/slack/command.json', params: {
575 | text: "help", channel_name: "welcome", token: token
576 | }
577 |
578 | expect(response).to be_success
579 |
580 | json = JSON.parse(response.body)
581 |
582 | expect(json["text"]).to eq(I18n.t("slack.help"))
583 | end
584 | end
585 |
586 | describe 'status command' do
587 | it 'should return the right response' do
588 | post '/slack/command.json', params: {
589 | text: "status",
590 | channel_name: "welcome",
591 | token: token
592 | }
593 |
594 | expect(response).to be_success
595 |
596 | json = JSON.parse(response.body)
597 |
598 | expect(json["text"]).to eq(DiscourseSlack::Slack.available_categories)
599 | end
600 | end
601 | end
602 | end
603 | end
604 |
--------------------------------------------------------------------------------
/spec/jobs/notify_slack_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe Jobs::NotifySlack do
4 | let(:post) { Fabricate(:post) }
5 | let(:category) { Fabricate(:category) }
6 | let(:topic) { Fabricate(:topic, category_id: category.id, posts: [post]) }
7 |
8 | before do
9 | SiteSetting.slack_outbound_webhook_url = "https://hooks.slack.com/services/abcde"
10 | SiteSetting.slack_enabled = true
11 | end
12 |
13 | before do
14 | stub_request(:post, SiteSetting.slack_outbound_webhook_url).to_return(body: "success")
15 | end
16 |
17 | context '#notify' do
18 | it "should notify if topic's category has a filter" do
19 | topic
20 | response = Jobs::NotifySlack.new.execute(post_id: post.id)
21 | expect(response[0]).to eq(nil)
22 |
23 | DiscourseSlack::Slack.create_filter(category.id, "#general", "follow", nil)
24 | response = Jobs::NotifySlack.new.execute(post_id: post.id)
25 |
26 | expect(response[0]).to eq("success")
27 | end
28 |
29 | it "should notify if topic's tag have a filter" do
30 | SiteSetting.tagging_enabled = true
31 | tag = Fabricate(:tag)
32 | topic.tags << tag
33 |
34 | response = Jobs::NotifySlack.new.execute(post_id: post.id)
35 | expect(response[0]).to eq(nil)
36 |
37 | DiscourseSlack::Slack.create_filter(nil, "#general", "follow", [tag.name])
38 | response = Jobs::NotifySlack.new.execute(post_id: post.id)
39 |
40 | expect(response[0]).to eq("success")
41 | end
42 |
43 | it "should notify if user have permission to see the post" do
44 | category = Fabricate(:category, read_restricted: true)
45 | topic = Fabricate(:topic, category: category)
46 | post = Fabricate(:post, topic: topic)
47 | user = Fabricate(:user)
48 |
49 | SiteSetting.slack_discourse_username = user.username
50 | DiscourseSlack::Slack.create_filter(nil, "#general", "follow", nil)
51 |
52 | response = Jobs::NotifySlack.new.execute(post_id: post.id)
53 | expect(response).to eq(nil)
54 |
55 | category.read_restricted = false
56 | category.save!
57 |
58 | response = Jobs::NotifySlack.new.execute(post_id: post.id)
59 | expect(response[0]).to eq("success")
60 | end
61 | end
62 |
63 | end
64 |
--------------------------------------------------------------------------------
/spec/lib/discourse_slack/slack_message_formatter_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | RSpec.describe DiscourseSlack::SlackMessageFormatter do
4 | describe '.format' do
5 | context 'links' do
6 | it 'should return the right message' do
7 | expect(described_class.format("test")).to eq('')
8 | end
9 |
10 | describe 'when text contains a link with an incomplete URL' do
11 | it 'should return the right message' do
12 | expect(described_class.format("test ")).to eq("test ")
13 |
14 | SiteSetting.force_https = true
15 |
16 | expect(described_class.format("test ")).to eq("test ")
17 | end
18 | end
19 |
20 | it "should not raise an error with unparseable urls" do
21 | expect(described_class.format("test")).to eq("")
22 | end
23 |
24 | it "should not raise an error with non http/s scheme" do
25 | expect(described_class.format(":mail:")).to eq("")
26 | expect(described_class.format(":phone:")).to eq("")
27 | end
28 |
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/spec/lib/discourse_slack/slack_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | RSpec.describe DiscourseSlack::Slack do
4 | let(:post) { Fabricate(:post) }
5 |
6 | describe '.excerpt' do
7 | describe 'when post contains emoijs' do
8 | before do
9 | post.update!(raw: ':slight_smile: This is a test')
10 | end
11 |
12 | it 'should return the right excerpt' do
13 | expect(described_class.excerpt(post)).to eq('🙂 This is a test')
14 | end
15 | end
16 |
17 | describe 'when post contains onebox' do
18 | it 'should return the right excerpt' do
19 | post.update!(cooked: <<~COOKED
20 |
45 | COOKED
46 | )
47 |
48 | expect(described_class.excerpt(post))
49 | .to eq('')
50 | end
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/spec/lib/post_creator_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | RSpec.describe PostCreator do
4 | let(:first_post) { Fabricate(:post) }
5 | let(:topic) { Fabricate(:topic, posts: [first_post]) }
6 |
7 | before do
8 | SiteSetting.slack_outbound_webhook_url = "https://hooks.slack.com/services/abcde"
9 | SiteSetting.queue_jobs = true
10 | Jobs::NotifySlack.jobs.clear
11 | end
12 |
13 | describe 'when a post is created' do
14 | describe 'when plugin is enabled' do
15 | before do
16 | SiteSetting.slack_enabled = true
17 | end
18 |
19 | it 'should schedule a job for slack post' do
20 | freeze_time
21 |
22 | post = PostCreator.new(topic.user,
23 | raw: 'aaaaaaaaaaaaaaaaasdddddddddd sorry cat walked over my keyboard',
24 | topic_id: topic.id
25 | ).create!
26 |
27 | job = Jobs::NotifySlack.jobs.last
28 |
29 | expect(job['at'])
30 | .to eq((Time.zone.now + SiteSetting.post_to_slack_window_secs.seconds).to_f)
31 |
32 | expect(job['args'].first['post_id']).to eq(post.id)
33 | end
34 | end
35 |
36 | describe 'when plugin is not enabled' do
37 | before do
38 | SiteSetting.slack_enabled = false
39 | end
40 |
41 | it 'should not schedule a job for slack post' do
42 | PostCreator.new(topic.user,
43 | raw: 'aaaaaaaaaaaaaaaaasdddddddddd sorry cat walked over my keyboard',
44 | topic_id: topic.id
45 | ).create!
46 |
47 | expect(Jobs::NotifySlack.jobs).to eq([])
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------