├── 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 | 7 | 8 | {{#if siteSettings.tagging_enabled}} 9 | 10 | {{/if}} 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{#each model as |rule|}} 18 | 19 | 20 | 21 | {{#if siteSettings.tagging_enabled}} 22 | 23 | {{/if}} 24 | 25 | 26 | 27 | 28 | 32 | 33 | {{/each}} 34 | 35 | 36 | 42 | 43 | {{#if siteSettings.tagging_enabled}} 44 | 45 | {{/if}} 46 | 47 | 48 | 49 | 50 | 58 | 59 |
{{i18n "slack.category"}}{{i18n "slack.tags"}}{{i18n "slack.channel"}}{{i18n "slack.filter"}}
{{rule.categoryName}}{{rule.tags}}{{rule.channel}}{{rule.filterName}} 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 |
37 | {{category-chooser 38 | value=editing.category_id 39 | rootNoneLabel="slack.choose.all_categories" 40 | rootNone=true}} 41 | {{tag-chooser tags=editing.tags placeholderKey="slack.choose.tags"}}{{text-field value=editing.channel placeholderKey="slack.choose.channel" class="channel"}}{{combo-box content=filters value=editing.filter}} 51 | {{d-button 52 | action="save" 53 | icon="save" 54 | class="save btn-primary" 55 | title="save" 56 | disabled=saveDisabled}} 57 |
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 | --------------------------------------------------------------------------------