collavre_slack 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +80 -0
  3. data/Rakefile +3 -0
  4. data/app/assets/stylesheets/collavre_slack/slack_integration.css +15 -0
  5. data/app/controllers/collavre_slack/application_controller.rb +12 -0
  6. data/app/controllers/collavre_slack/creatives/slack_integrations_controller.rb +101 -0
  7. data/app/controllers/collavre_slack/slack_auth_controller.rb +105 -0
  8. data/app/controllers/collavre_slack/slack_events_controller.rb +63 -0
  9. data/app/controllers/collavre_slack/slack_messages_controller.rb +24 -0
  10. data/app/javascript/collavre_slack.js +509 -0
  11. data/app/jobs/collavre_slack/application_job.rb +4 -0
  12. data/app/jobs/collavre_slack/slack_channel_sync_job.rb +45 -0
  13. data/app/jobs/collavre_slack/slack_inbound_message_delete_job.rb +24 -0
  14. data/app/jobs/collavre_slack/slack_inbound_message_job.rb +129 -0
  15. data/app/jobs/collavre_slack/slack_inbound_message_update_job.rb +22 -0
  16. data/app/jobs/collavre_slack/slack_inbound_reaction_job.rb +47 -0
  17. data/app/jobs/collavre_slack/slack_message_delete_job.rb +29 -0
  18. data/app/jobs/collavre_slack/slack_message_job.rb +45 -0
  19. data/app/jobs/collavre_slack/slack_message_update_job.rb +32 -0
  20. data/app/jobs/collavre_slack/slack_reaction_job.rb +45 -0
  21. data/app/models/collavre_slack/application_record.rb +5 -0
  22. data/app/models/collavre_slack/slack_account.rb +13 -0
  23. data/app/models/collavre_slack/slack_channel_link.rb +16 -0
  24. data/app/models/collavre_slack/slack_comment_link.rb +18 -0
  25. data/app/models/collavre_slack/slack_message_log.rb +11 -0
  26. data/app/models/collavre_slack/slack_user_mapping.rb +10 -0
  27. data/app/models/concerns/collavre_slack/slack_dispatchable.rb +97 -0
  28. data/app/models/concerns/collavre_slack/slack_reaction_dispatchable.rb +55 -0
  29. data/app/services/collavre_slack/emoji_mapping.rb +90 -0
  30. data/app/services/collavre_slack/mention_mapping.rb +45 -0
  31. data/app/services/collavre_slack/slack_client.rb +141 -0
  32. data/app/services/collavre_slack/slack_event_handler.rb +259 -0
  33. data/app/services/collavre_slack/slack_integration_service.rb +26 -0
  34. data/app/services/collavre_slack/slack_message_dispatcher.rb +24 -0
  35. data/app/views/collavre_slack/creatives/slack_integrations/index.html.erb +18 -0
  36. data/app/views/collavre_slack/integrations/_modal.html.erb +66 -0
  37. data/config/initializers/collavre_slack.rb +7 -0
  38. data/config/locales/en.yml +57 -0
  39. data/config/locales/ko.yml +57 -0
  40. data/config/routes.rb +11 -0
  41. data/db/migrate/20250201000001_create_slack_accounts.rb +17 -0
  42. data/db/migrate/20250201000002_create_slack_channel_links.rb +17 -0
  43. data/db/migrate/20250201000003_create_slack_user_mappings.rb +15 -0
  44. data/db/migrate/20250201000004_create_slack_message_logs.rb +14 -0
  45. data/db/migrate/20250201000005_add_last_synced_at_to_slack_channel_links.rb +5 -0
  46. data/db/migrate/20260130010000_create_slack_comment_links.rb +13 -0
  47. data/db/migrate/20260130010001_add_comment_id_to_slack_message_logs.rb +5 -0
  48. data/db/migrate/20260130020000_add_cascade_delete_to_slack_comment_foreign_keys.rb +11 -0
  49. data/db/migrate/20260131000000_remove_is_active_from_slack_channel_links.rb +5 -0
  50. data/lib/collavre_slack/configuration.rb +13 -0
  51. data/lib/collavre_slack/engine.rb +97 -0
  52. data/lib/collavre_slack/version.rb +3 -0
  53. data/lib/collavre_slack.rb +13 -0
  54. metadata +151 -0
@@ -0,0 +1,45 @@
1
+ module CollavreSlack
2
+ module MentionMapping
3
+ SLACK_MENTION_REGEX = /<@([A-Z0-9]+)>/.freeze
4
+ COLLABRE_MENTION_REGEX = /@([^:]+):/.freeze
5
+
6
+ def self.from_slack(text, slack_account)
7
+ return text if text.blank?
8
+
9
+ text.gsub(SLACK_MENTION_REGEX) do |match|
10
+ slack_user_id = Regexp.last_match(1)
11
+ mapping = slack_account.slack_user_mappings.includes(:collavre_user).find_by(slack_user_id: slack_user_id)
12
+
13
+ if mapping
14
+ "@#{mapping.collavre_user.name}:"
15
+ else
16
+ # Fetch display name from Slack API for unmapped users
17
+ display_name = fetch_slack_display_name(slack_account, slack_user_id)
18
+ display_name ? "@#{display_name}:" : match
19
+ end
20
+ end
21
+ end
22
+
23
+ def self.fetch_slack_display_name(slack_account, slack_user_id)
24
+ client = SlackClient.new(access_token: slack_account.access_token)
25
+ client.get_user_display_name(user_id: slack_user_id)
26
+ rescue StandardError => e
27
+ Rails.logger.warn("[CollavreSlack] Failed to fetch Slack user info: #{e.message}")
28
+ nil
29
+ end
30
+
31
+ def self.to_slack(text, slack_account)
32
+ return text if text.blank?
33
+
34
+ mappings = slack_account.slack_user_mappings.includes(:collavre_user).to_a
35
+ by_name = mappings.index_by { |mapping| mapping.collavre_user.name.to_s.downcase }
36
+ by_email = mappings.index_by { |mapping| mapping.collavre_user.email.to_s.downcase }
37
+
38
+ text.gsub(COLLABRE_MENTION_REGEX) do |match|
39
+ key = Regexp.last_match(1).to_s.downcase
40
+ mapping = by_name[key] || by_email[key]
41
+ mapping ? "<@#{mapping.slack_user_id}>" : match
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,141 @@
1
+ module CollavreSlack
2
+ class SlackClient
3
+ BASE_URL = "https://slack.com/api".freeze
4
+
5
+ def initialize(access_token: nil)
6
+ @access_token = access_token
7
+ end
8
+
9
+ def oauth_authorize_url(state: nil)
10
+ query = {
11
+ client_id: client_id,
12
+ scope: default_scopes,
13
+ redirect_uri: redirect_uri,
14
+ state: state
15
+ }.compact
16
+
17
+ "https://slack.com/oauth/v2/authorize?#{query.to_query}"
18
+ end
19
+
20
+ def oauth_access(code:, redirect_uri:)
21
+ response = connection.post("oauth.v2.access", {
22
+ client_id: client_id,
23
+ client_secret: client_secret,
24
+ code: code,
25
+ redirect_uri: redirect_uri
26
+ })
27
+
28
+ parse_response(response)
29
+ end
30
+
31
+ def list_channels
32
+ get("conversations.list", limit: 1000, types: "public_channel,private_channel")
33
+ end
34
+
35
+ def list_messages(channel:, oldest: nil, cursor: nil)
36
+ params = { channel: channel, limit: 200 }
37
+ params[:oldest] = oldest if oldest.present?
38
+ params[:cursor] = cursor if cursor.present?
39
+ get("conversations.history", params)
40
+ end
41
+
42
+ def post_message(channel:, text:)
43
+ post("chat.postMessage", { channel: channel, text: text })
44
+ end
45
+
46
+ def update_message(channel:, timestamp:, text:)
47
+ post("chat.update", { channel: channel, ts: timestamp, text: text })
48
+ end
49
+
50
+ def delete_message(channel:, timestamp:)
51
+ post("chat.delete", { channel: channel, ts: timestamp })
52
+ end
53
+
54
+ def get_user_info(user_id:)
55
+ get("users.info", user: user_id)
56
+ end
57
+
58
+ def get_user_display_name(user_id:)
59
+ response = get_user_info(user_id: user_id)
60
+ return nil unless response[:ok]
61
+
62
+ profile = response.dig(:user, :profile) || {}
63
+ profile[:display_name].presence || profile[:real_name].presence || response.dig(:user, :name)
64
+ end
65
+
66
+ def add_reaction(channel:, timestamp:, name:)
67
+ post("reactions.add", { channel: channel, timestamp: timestamp, name: name })
68
+ end
69
+
70
+ def remove_reaction(channel:, timestamp:, name:)
71
+ post("reactions.remove", { channel: channel, timestamp: timestamp, name: name })
72
+ end
73
+
74
+ def redirect_uri
75
+ CollavreSlack.config.redirect_uri
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :access_token
81
+
82
+ def client_id
83
+ CollavreSlack.config.client_id
84
+ end
85
+
86
+ def client_secret
87
+ CollavreSlack.config.client_secret
88
+ end
89
+
90
+ def default_scopes
91
+ CollavreSlack.config.scopes
92
+ end
93
+
94
+ def connection
95
+ @connection ||= Faraday.new(url: BASE_URL) do |builder|
96
+ builder.request :url_encoded
97
+ builder.request :retry, max: 3, interval: 0.2, backoff_factor: 2
98
+ builder.response :raise_error
99
+ end
100
+ end
101
+
102
+ def get(path, params = {})
103
+ request_with_handling do
104
+ connection.get(path) do |request|
105
+ request.headers["Authorization"] = "Bearer #{access_token}" if access_token.present?
106
+ request.params.update(params)
107
+ end
108
+ end
109
+ end
110
+
111
+ def post(path, payload = {})
112
+ request_with_handling do
113
+ connection.post(path) do |request|
114
+ request.headers["Authorization"] = "Bearer #{access_token}" if access_token.present?
115
+ request.headers["Content-Type"] = "application/json; charset=utf-8"
116
+ request.body = payload.to_json
117
+ end
118
+ end
119
+ end
120
+
121
+ def parse_response(response)
122
+ parsed = JSON.parse(response.body, symbolize_names: true)
123
+ parsed.merge(status: response.status, headers: response.headers)
124
+ rescue JSON::ParserError
125
+ { ok: false, error: "invalid_json", status: response.status, headers: response.headers }
126
+ end
127
+
128
+ def request_with_handling
129
+ error_response = {}
130
+ response = yield
131
+ parse_response(response)
132
+ rescue Faraday::ClientError => e
133
+ error_response = e.response || {}
134
+ body = error_response[:body].to_s
135
+ parsed = JSON.parse(body, symbolize_names: true)
136
+ parsed.merge(status: error_response[:status], headers: error_response[:headers])
137
+ rescue JSON::ParserError
138
+ { ok: false, error: "http_error", status: error_response[:status], headers: error_response[:headers] }
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,259 @@
1
+ module CollavreSlack
2
+ class SlackEventHandler
3
+ def initialize(payload:)
4
+ @payload = payload
5
+ end
6
+
7
+ def call
8
+ return handle_reaction_event if reaction_event?
9
+ return handle_message_changed if message_changed_event?
10
+ return handle_message_deleted if message_deleted_event?
11
+ return unless message_event?
12
+
13
+ slack_account = SlackAccount.find_by(team_id: team_id)
14
+ return unless slack_account
15
+
16
+ channel_link = SlackChannelLink.find_by(
17
+ slack_account: slack_account,
18
+ channel_id: channel_id
19
+ )
20
+ return unless channel_link
21
+
22
+ user_result = find_or_map_user(slack_account, event_user_id)
23
+ user = user_result[:user] || channel_link.created_by
24
+ slack_display_name = user_result[:slack_display_name]
25
+ slack_email = user_result[:slack_email]
26
+ slack_user_id = user_result[:slack_user_id]
27
+
28
+ normalized_content = MentionMapping.from_slack(formatted_content, slack_account)
29
+
30
+ # Prepend Slack username if user is not mapped
31
+ if user_result[:user].nil? && slack_display_name.present?
32
+ prefix = I18n.t("collavre_slack.messages.slack_user_prefix", name: slack_display_name)
33
+ normalized_content = "#{prefix} #{normalized_content}"
34
+ end
35
+
36
+ {
37
+ type: :message,
38
+ creative_id: channel_link.creative_id,
39
+ user_id: user&.id,
40
+ content: normalized_content,
41
+ slack_channel_link_id: channel_link.id,
42
+ slack_message_ts: event_ts,
43
+ slack_display_name: slack_display_name,
44
+ slack_email: slack_email,
45
+ slack_user_id: slack_user_id
46
+ }
47
+ end
48
+
49
+ def handle_message_changed
50
+ slack_account = SlackAccount.find_by(team_id: team_id)
51
+ return unless slack_account
52
+
53
+ # For message_changed, the edited message is in event.message
54
+ message = event_payload[:message] || {}
55
+ message_ts = message[:ts]
56
+ return unless message_ts
57
+
58
+ # Skip bot messages
59
+ return if message[:bot_id].present?
60
+
61
+ # Find the comment link by Slack message
62
+ comment_link = SlackCommentLink.find_by_slack_message(
63
+ slack_account: slack_account,
64
+ channel_id: channel_id,
65
+ message_ts: message_ts
66
+ )
67
+ return unless comment_link
68
+
69
+ new_text = message[:text].to_s
70
+ normalized_content = MentionMapping.from_slack(new_text, slack_account)
71
+
72
+ {
73
+ type: :message_updated,
74
+ comment_id: comment_link.comment_id,
75
+ content: normalized_content
76
+ }
77
+ end
78
+
79
+ def handle_message_deleted
80
+ slack_account = SlackAccount.find_by(team_id: team_id)
81
+ return unless slack_account
82
+
83
+ # For message_deleted, the deleted message ts is in event.deleted_ts
84
+ deleted_ts = event_payload[:deleted_ts]
85
+ return unless deleted_ts
86
+
87
+ # Find the comment link by Slack message
88
+ comment_link = SlackCommentLink.find_by_slack_message(
89
+ slack_account: slack_account,
90
+ channel_id: channel_id,
91
+ message_ts: deleted_ts
92
+ )
93
+ return unless comment_link
94
+
95
+ {
96
+ type: :message_deleted,
97
+ comment_id: comment_link.comment_id,
98
+ slack_comment_link_id: comment_link.id
99
+ }
100
+ end
101
+
102
+ def handle_reaction_event
103
+ slack_account = SlackAccount.find_by(team_id: team_id)
104
+ return unless slack_account
105
+
106
+ # Reaction events have item.channel and item.ts
107
+ item = event_payload[:item] || {}
108
+ reaction_channel_id = item[:channel]
109
+ reaction_message_ts = item[:ts]
110
+
111
+ return unless reaction_channel_id && reaction_message_ts
112
+
113
+ # Find the comment link by Slack message
114
+ comment_link = SlackCommentLink.find_by_slack_message(
115
+ slack_account: slack_account,
116
+ channel_id: reaction_channel_id,
117
+ message_ts: reaction_message_ts
118
+ )
119
+ return unless comment_link
120
+
121
+ # Map Slack user to Collavre user
122
+ user_result = find_or_map_user(slack_account, event_user_id)
123
+ user = user_result[:user]
124
+ return unless user # Can't add reaction without a mapped user
125
+
126
+ {
127
+ type: reaction_action,
128
+ comment_id: comment_link.comment_id,
129
+ user_id: user.id,
130
+ emoji: EmojiMapping.to_unicode(event_payload[:reaction])
131
+ }
132
+ end
133
+
134
+ private
135
+
136
+ attr_reader :payload
137
+
138
+ def message_event?
139
+ return false unless event_type == "event_callback"
140
+ return false unless event_payload[:type] == "message"
141
+ return false if event_payload[:subtype].present?
142
+ # Skip bot messages to prevent loops
143
+ return false if event_payload[:bot_id].present?
144
+ true
145
+ end
146
+
147
+ def reaction_event?
148
+ return false unless event_type == "event_callback"
149
+ %w[reaction_added reaction_removed].include?(event_payload[:type])
150
+ end
151
+
152
+ def message_changed_event?
153
+ return false unless event_type == "event_callback"
154
+ return false unless event_payload[:type] == "message"
155
+ event_payload[:subtype] == "message_changed"
156
+ end
157
+
158
+ def message_deleted_event?
159
+ return false unless event_type == "event_callback"
160
+ return false unless event_payload[:type] == "message"
161
+ event_payload[:subtype] == "message_deleted"
162
+ end
163
+
164
+ def reaction_action
165
+ event_payload[:type] == "reaction_added" ? :reaction_added : :reaction_removed
166
+ end
167
+
168
+ def team_id
169
+ payload[:team_id] || payload[:team]
170
+ end
171
+
172
+ def event_payload
173
+ payload[:event] || {}
174
+ end
175
+
176
+ def event_text
177
+ event_payload[:text].to_s
178
+ end
179
+
180
+ def formatted_content
181
+ content = event_text
182
+ content = "#{I18n.t('collavre_slack.messages.thread_reply')}\n#{content}" if thread_reply?
183
+ attachment_lines = attachment_summaries
184
+ if attachment_lines.any?
185
+ content = [ content, "", I18n.t("collavre_slack.messages.attachments"), *attachment_lines ].join("\n")
186
+ end
187
+ content
188
+ end
189
+
190
+ def attachment_summaries
191
+ files = Array(event_payload[:files])
192
+ files.filter_map do |file|
193
+ next unless file.is_a?(Hash)
194
+ name = file[:name] || file[:title] || "file"
195
+ url = file[:url_private] || file[:url_private_download]
196
+ url ? "- #{name}: #{url}" : "- #{name}"
197
+ end
198
+ end
199
+
200
+ def thread_reply?
201
+ event_payload[:thread_ts].present? && event_payload[:thread_ts] != event_payload[:ts]
202
+ end
203
+
204
+ def event_user_id
205
+ event_payload[:user]
206
+ end
207
+
208
+ def event_ts
209
+ event_payload[:ts]
210
+ end
211
+
212
+ def channel_id
213
+ event_payload[:channel]
214
+ end
215
+
216
+ def event_type
217
+ payload[:type]
218
+ end
219
+
220
+ def find_or_map_user(slack_account, slack_user_id)
221
+ # Fetch user info from Slack API first (needed for notifications even if mapping exists)
222
+ client = SlackClient.new(access_token: slack_account.access_token)
223
+ response = client.get_user_info(user_id: slack_user_id)
224
+
225
+ slack_display_name = nil
226
+ email = nil
227
+
228
+ if response[:ok]
229
+ profile = response.dig(:user, :profile) || {}
230
+ slack_display_name = profile[:display_name].presence || profile[:real_name].presence || response.dig(:user, :name)
231
+ email = profile[:email]
232
+ end
233
+
234
+ slack_info = { slack_display_name: slack_display_name, slack_email: email, slack_user_id: slack_user_id }
235
+
236
+ # Check existing mapping
237
+ mapping = slack_account.slack_user_mappings.find_by(slack_user_id: slack_user_id)
238
+ return slack_info.merge(user: mapping.collavre_user) if mapping
239
+
240
+ # Try to find Collavre user by email and auto-map
241
+ if email.present?
242
+ collavre_user = ::User.find_by(email: email)
243
+ if collavre_user
244
+ slack_account.slack_user_mappings.create(
245
+ slack_user_id: slack_user_id,
246
+ collavre_user: collavre_user
247
+ )
248
+ Rails.logger.info("[CollavreSlack] Auto-mapped Slack user #{slack_user_id} to Collavre user #{collavre_user.id} by email")
249
+ return slack_info.merge(user: collavre_user)
250
+ end
251
+ end
252
+
253
+ slack_info.merge(user: nil)
254
+ rescue StandardError => e
255
+ Rails.logger.warn("[CollavreSlack] Failed to map user: #{e.message}")
256
+ { user: nil, slack_user_id: slack_user_id }
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,26 @@
1
+ module CollavreSlack
2
+ class SlackIntegrationService
3
+ def initialize(user:, slack_account:)
4
+ @user = user
5
+ @slack_account = slack_account
6
+ end
7
+
8
+ def link_channel(creative:, channel_id:, channel_name:)
9
+ SlackChannelLink.create!(
10
+ creative: creative,
11
+ slack_account: slack_account,
12
+ channel_id: channel_id,
13
+ channel_name: channel_name,
14
+ created_by: user
15
+ )
16
+ end
17
+
18
+ def unlink_channel(link)
19
+ link.destroy!
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :user, :slack_account
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ module CollavreSlack
2
+ class SlackMessageDispatcher
3
+ def initialize(channel_link:)
4
+ @channel_link = channel_link
5
+ end
6
+
7
+ def enqueue(message:, sender: nil, comment: nil)
8
+ formatted = MentionMapping.to_slack(message.to_s, channel_link.slack_account)
9
+ log = SlackMessageLog.create!(
10
+ slack_channel_link: channel_link,
11
+ sender: sender,
12
+ comment: comment,
13
+ message: formatted,
14
+ status: "queued"
15
+ )
16
+ SlackMessageJob.perform_later(log.id)
17
+ log
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :channel_link
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ <h1><%= t("collavre_slack.views.slack_integrations.title") %></h1>
2
+
3
+ <p>
4
+ <%= link_to t("collavre_slack.views.slack_integrations.connect_slack"), slack_engine.auth_slack_path %>
5
+ </p>
6
+
7
+ <% if @links.any? %>
8
+ <ul>
9
+ <% @links.each do |link| %>
10
+ <li>
11
+ <strong><%= link.channel_name %></strong>
12
+ (<%= link.channel_id %>)
13
+ </li>
14
+ <% end %>
15
+ </ul>
16
+ <% else %>
17
+ <p><%= t("collavre_slack.views.slack_integrations.no_channels_linked") %></p>
18
+ <% end %>
@@ -0,0 +1,66 @@
1
+ <div id="slack-integration-modal"
2
+ data-success-message="<%= t('collavre_slack.modal.success_message') %>"
3
+ data-login-required="<%= t('collavre_slack.modal.login_required') %>"
4
+ data-no-creative="<%= t('collavre_slack.modal.missing_creative') %>"
5
+ data-existing-message="<%= t('collavre_slack.modal.existing_message') %>"
6
+ data-delete-confirm="<%= t('collavre_slack.modal.delete_confirm') %>"
7
+ data-delete-success="<%= t('collavre_slack.modal.delete_success') %>"
8
+ data-delete-error="<%= t('collavre_slack.modal.delete_error') %>"
9
+ data-delete-button-label="<%= t('collavre_slack.modal.delete_button') %>"
10
+ data-add-first-channel="<%= t('collavre_slack.modal.add_first_channel') %>"
11
+ data-loading="<%= t('collavre_slack.modal.loading') %>"
12
+ data-linking="<%= t('collavre_slack.modal.linking') %>"
13
+ data-link-channel="<%= t('collavre_slack.modal.link_channel') %>"
14
+ data-refresh="<%= t('collavre_slack.modal.refresh') %>"
15
+ data-refresh-error="<%= t('collavre_slack.modal.refresh_error') %>"
16
+ data-linked-label="<%= t('collavre_slack.modal.linked_label') %>"
17
+ style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1000;align-items:center;justify-content:center;">
18
+ <div class="popup-box" style="min-width:360px;max-width:90vw;">
19
+ <button type="button" id="close-slack-modal" class="popup-close-btn">&times;</button>
20
+ <h2><%= t('collavre_slack.modal.title') %></h2>
21
+ <p id="slack-integration-status" class="slack-modal-status"></p>
22
+
23
+ <div class="slack-wizard-step" id="slack-step-connect">
24
+ <p id="slack-connect-message" class="slack-modal-subtext"><%= t('collavre_slack.modal.connect_message') %></p>
25
+ <a id="slack-login-btn" href="<%= integration.routes.auth_slack_path %>" class="btn btn-primary" target="slack-auth-window" data-window-width="620" data-window-height="720">
26
+ <%= t('collavre_slack.modal.login_button') %>
27
+ </a>
28
+ <div id="slack-connected-status" style="display:none;margin-bottom:1em;padding:0.75em 1em;background:var(--color-success-bg, #d4edda);border-radius:4px;color:var(--color-success-text, #155724);">
29
+ <strong><%= t('collavre_slack.modal.connected_status') %></strong>
30
+ </div>
31
+ <div id="slack-existing-connections" style="display:none;margin-top:0.5em;">
32
+ <p style="margin-bottom:0.5em;font-weight:500;"><%= t('collavre_slack.modal.existing_intro') %></p>
33
+ <ul id="slack-existing-channel-list" style="padding-left:0;margin-bottom:0.75em;list-style:none;"></ul>
34
+ </div>
35
+ <div id="slack-add-channel-section" style="display:none;margin-top:1em;padding-top:1em;border-top:1px solid var(--color-border);">
36
+ <button type="button" id="slack-add-channel-btn" class="btn btn-primary">
37
+ <%= t('collavre_slack.modal.add_channel') %>
38
+ </button>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="slack-wizard-step" id="slack-step-channels" style="display:none;">
43
+ <p class="slack-modal-subtext"><%= t('collavre_slack.modal.choose_channel') %></p>
44
+ <div id="slack-channel-list" class="slack-list slack-modal-list-box" style="max-height:240px;overflow:auto;"></div>
45
+ </div>
46
+
47
+ <div class="slack-wizard-step" id="slack-step-summary" style="display:none;">
48
+ <p class="slack-modal-subtext"><%= t('collavre_slack.modal.summary_intro') %></p>
49
+ <div id="slack-link-summary" style="margin:1em 0;padding:1em;background:var(--color-bg-alt);border-radius:4px;">
50
+ <div><strong><%= t('collavre_slack.modal.channel_label') %></strong> <span id="slack-channel-summary"></span></div>
51
+ <div style="margin-top:0.5em;color:var(--color-text-secondary);font-size:0.9em;"><%= t('collavre_slack.modal.sync_note') %></div>
52
+ </div>
53
+ </div>
54
+
55
+ <div id="slack-wizard-error" style="display:none;margin:0.5em 0;color:#c0392b;font-weight:bold;"></div>
56
+
57
+ <div class="slack-wizard-footer" style="display:flex;justify-content:space-between;gap:0.5em;margin-top:1.5em;">
58
+ <button type="button" id="slack-prev-btn" class="btn btn-secondary" style="display:none;"><%= t('app.previous', default: 'Previous') %></button>
59
+ <div style="margin-left:auto;display:flex;gap:0.5em;">
60
+ <button type="button" id="slack-refresh-btn" class="btn btn-secondary" style="display:none;"><%= t('collavre_slack.modal.refresh') %></button>
61
+ <button type="button" id="slack-next-btn" class="btn btn-primary" style="display:none;"><%= t('app.next', default: 'Next') %></button>
62
+ <button type="button" id="slack-finish-btn" class="btn btn-primary" style="display:none;"><%= t('app.finish', default: 'Link Channel') %></button>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </div>
@@ -0,0 +1,7 @@
1
+ CollavreSlack.configure do |config|
2
+ config.client_id = ENV.fetch("SLACK_CLIENT_ID", config.client_id)
3
+ config.client_secret = ENV.fetch("SLACK_CLIENT_SECRET", config.client_secret)
4
+ config.signing_secret = ENV.fetch("SLACK_SIGNING_SECRET", config.signing_secret)
5
+ config.redirect_uri = ENV.fetch("SLACK_REDIRECT_URI", config.redirect_uri)
6
+ config.scopes = ENV.fetch("SLACK_SCOPES", config.scopes)
7
+ end
@@ -0,0 +1,57 @@
1
+ en:
2
+ collavre_slack:
3
+ integration:
4
+ label: "Slack"
5
+ description: "Sync chat messages with Slack channels"
6
+ errors:
7
+ forbidden: "Forbidden"
8
+ not_found: "Not found"
9
+ creative_already_linked: "is already linked to a Slack channel"
10
+ no_slack_account: "No Slack account connected"
11
+ authentication_required: "Authentication required"
12
+ missing_oauth_code: "Missing OAuth code"
13
+ missing_oauth_state: "Missing OAuth state"
14
+ invalid_oauth_state: "Invalid OAuth state"
15
+ user_not_found: "User not found"
16
+ oauth_failed: "Slack OAuth failed"
17
+ save_failed: "Failed to save: %{errors}"
18
+ invalid_signature: "Invalid signature"
19
+ messages:
20
+ anonymous: "Anonymous"
21
+ thread_reply: "[Thread reply]"
22
+ slack_user_prefix: "[Slack: @%{name}]"
23
+ attachments: "Attachments:"
24
+ views:
25
+ slack_integrations:
26
+ title: "Slack Integrations"
27
+ connect_slack: "Connect Slack"
28
+ no_channels_linked: "No Slack channels linked yet."
29
+ auth:
30
+ success_title: "Slack Connected"
31
+ success_message: "Slack workspace connected successfully! You can close this window."
32
+ modal:
33
+ title: "Configure Slack integration"
34
+ success_message: "Slack integration saved successfully."
35
+ login_required: "Sign in with your Slack workspace to start the integration."
36
+ missing_creative: "No Creative selected for integration."
37
+ existing_message: "You're already connected to Slack channels below."
38
+ delete_confirm: "Do you want to remove this Slack channel link?"
39
+ delete_success: "Slack channel link removed successfully."
40
+ delete_error: "Failed to remove the Slack channel link."
41
+ delete_button: "Remove"
42
+ add_first_channel: "Link a Slack Channel"
43
+ connect_message: "Connect your Slack workspace to sync messages with this Creative."
44
+ login_button: "Connect Slack Workspace"
45
+ connected_status: "Connected to Slack"
46
+ existing_intro: "Linked Slack channels:"
47
+ add_channel: "Add Another Channel"
48
+ choose_channel: "Select a Slack channel to link with this Creative."
49
+ summary_intro: "Ready to link your Slack channel:"
50
+ channel_label: "Channel:"
51
+ sync_note: "Messages posted in this Slack channel will appear in this Creative, and vice versa."
52
+ loading: "Loading..."
53
+ linking: "Linking..."
54
+ link_channel: "Link Channel"
55
+ refresh: "Refresh"
56
+ refresh_error: "Failed to refresh channels"
57
+ linked_label: "(Linked)"