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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a0a684e5d2b0b7f2ebcd19950a0782e515d24ad1f935360f1b3c10dc59b77636
4
+ data.tar.gz: bf77b0f047f322017289c622509460e1128f013a6c871b004b199e7675612826
5
+ SHA512:
6
+ metadata.gz: 4b3eb5ff2a65bfda47e5d478a8fefd0f843bf52f783e061f9227afa593f3c2cab2c9de123234ef71d5af4a7339b2c5dd34cbb6ad13643ac76ae6f6fc41809958
7
+ data.tar.gz: d5d9db76818d0d5e271efae68349c7d6f87cd91a8b765597e36ed4a8f3f2dff119f5eb9e7998b1ef45cead4372c2bf878d462e78597cc65bd4e62607df6dddf1
data/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # Collavre Slack Engine
2
+
3
+ Slack integration plugin engine for Collavre. Provides OAuth installation, channel linking, and bi-directional chat sync.
4
+
5
+ ## Documentation
6
+
7
+ - [Setup Guide](docs/SETUP.md) - Step-by-step instructions for creating and configuring a Slack App
8
+ - [Architecture](docs/ARCHITECTURE.md) - Technical documentation about the integration
9
+
10
+ ## Installation
11
+
12
+ ### 1. Add to Gemfile
13
+
14
+ ```ruby
15
+ # Gemfile
16
+ gem "collavre_slack", path: "engines/collavre_slack"
17
+ ```
18
+
19
+ ### 2. Add JavaScript Import (Required)
20
+
21
+ The JavaScript must be explicitly imported in your host application:
22
+
23
+ ```javascript
24
+ // app/javascript/application.js
25
+ import "collavre_slack"
26
+ ```
27
+
28
+ This is required because the host app controls which integrations are loaded. Without this import, the Slack integration modal and badge functionality will not work.
29
+
30
+ ### 3. Add Stylesheet (Required)
31
+
32
+ Include the Slack integration stylesheet in your layout:
33
+
34
+ ```erb
35
+ <%# app/views/layouts/application.html.erb %>
36
+ <%= stylesheet_link_tag "collavre_slack/slack_integration" %>
37
+ ```
38
+
39
+ ### 4. Configure Environment Variables
40
+
41
+ | Variable | Required | Description |
42
+ |----------|----------|-------------|
43
+ | `SLACK_CLIENT_ID` | Yes | OAuth Client ID |
44
+ | `SLACK_CLIENT_SECRET` | Yes | OAuth Client Secret |
45
+ | `SLACK_SIGNING_SECRET` | Yes | Webhook signature verification |
46
+ | `SLACK_REDIRECT_URI` | Yes | OAuth callback URL |
47
+ | `SLACK_SCOPES` | No | OAuth scopes (has sensible defaults) |
48
+
49
+ ### 5. Run Migrations
50
+
51
+ ```bash
52
+ rails db:migrate
53
+ ```
54
+
55
+ ## Automatic Configuration
56
+
57
+ The following are automatically configured by the engine:
58
+
59
+ - **Routes**: Mounted at `/slack` (no manual `mount` needed in `routes.rb`)
60
+ - **Asset Paths**: Stylesheets are automatically added to Propshaft asset paths
61
+ - **Migrations**: Database migrations are automatically included
62
+ - **i18n**: Locale files (en, ko) are automatically loaded
63
+ - **Integration Registry**: Automatically registers with `Collavre::IntegrationRegistry`
64
+
65
+ ## Slack App Setup
66
+
67
+ 1. Create a Slack App at [api.slack.com/apps](https://api.slack.com/apps)
68
+ 2. Configure OAuth scopes and redirect URL
69
+ 3. Set up Event Subscriptions
70
+
71
+ See [docs/SETUP.md](docs/SETUP.md) for detailed instructions.
72
+
73
+ ## Features
74
+
75
+ - OAuth 2.0 installation flow
76
+ - Channel linking to Creatives
77
+ - Bi-directional message sync (Slack ↔ Collavre)
78
+ - User mention mapping
79
+ - Rate limit handling with automatic retry
80
+ - Request signature verification for security
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require_relative "lib/collavre_slack"
2
+
3
+ CollavreSlack::Engine.load_tasks
@@ -0,0 +1,15 @@
1
+ /* Slack channel selection */
2
+ .slack-channel-item {
3
+ padding: 0.5em 1em;
4
+ cursor: pointer;
5
+ border-bottom: 1px solid var(--color-border);
6
+ }
7
+
8
+ .slack-channel-item:hover {
9
+ background: var(--color-drag-over);
10
+ }
11
+
12
+ .slack-channel-item.active {
13
+ background: var(--color-drag-over);
14
+ border-left: 3px solid var(--color-secondary-active);
15
+ }
@@ -0,0 +1,12 @@
1
+ module CollavreSlack
2
+ class ApplicationController < ::ApplicationController
3
+ protect_from_forgery with: :exception
4
+
5
+ private
6
+
7
+ def slack_engine
8
+ CollavreSlack::Engine.routes.url_helpers
9
+ end
10
+ helper_method :slack_engine
11
+ end
12
+ end
@@ -0,0 +1,101 @@
1
+ module CollavreSlack
2
+ module Creatives
3
+ class SlackIntegrationsController < ApplicationController
4
+ before_action :set_creative
5
+
6
+ def index
7
+ # Always use origin creative for Slack links (chat messages are on origin)
8
+ target_creative = @creative.effective_origin
9
+ @links = SlackChannelLink.where(creative: target_creative)
10
+ respond_to do |format|
11
+ format.json do
12
+ slack_account = Current.user ? SlackAccount.find_by(user: Current.user) : nil
13
+ channels = slack_account ? fetch_channels(slack_account) : []
14
+
15
+ render json: {
16
+ connected: slack_account.present?,
17
+ channels: channels,
18
+ links: @links.map { |link|
19
+ {
20
+ id: link.id,
21
+ channel_id: link.channel_id,
22
+ channel_name: link.channel_name,
23
+ last_synced_at: link.last_synced_at
24
+ }
25
+ }
26
+ }
27
+ end
28
+ format.html
29
+ end
30
+ end
31
+
32
+ def create
33
+ unless @creative.has_permission?(Current.user, :feedback)
34
+ render json: { success: false, error: I18n.t("collavre_slack.errors.forbidden") }, status: :forbidden
35
+ return
36
+ end
37
+
38
+ # Find the user's Slack account
39
+ slack_account = if params[:slack_account_id].present?
40
+ SlackAccount.find(params[:slack_account_id])
41
+ else
42
+ SlackAccount.find_by(user: Current.user)
43
+ end
44
+
45
+ unless slack_account
46
+ render json: { success: false, error: I18n.t("collavre_slack.errors.no_slack_account") }, status: :unprocessable_entity
47
+ return
48
+ end
49
+
50
+ # Always link to origin creative (chat messages are on origin)
51
+ target_creative = @creative.effective_origin
52
+
53
+ service = SlackIntegrationService.new(user: Current.user, slack_account: slack_account)
54
+ link = service.link_channel(creative: target_creative, channel_id: params[:channel_id], channel_name: params[:channel_name])
55
+
56
+ if link.persisted?
57
+ render json: { success: true, link: { id: link.id, channel_id: link.channel_id, channel_name: link.channel_name } }, status: :created
58
+ else
59
+ render json: { success: false, error: link.errors.full_messages.join(", ") }, status: :unprocessable_entity
60
+ end
61
+ end
62
+
63
+ def destroy
64
+ link = SlackChannelLink.find(params[:id])
65
+ # Check against origin creative
66
+ target_creative = @creative.effective_origin
67
+ unless link.creative_id == target_creative.id
68
+ render json: { success: false, error: I18n.t("collavre_slack.errors.not_found") }, status: :not_found
69
+ return
70
+ end
71
+
72
+ unless @creative.has_permission?(Current.user, :feedback)
73
+ render json: { success: false, error: I18n.t("collavre_slack.errors.forbidden") }, status: :forbidden
74
+ return
75
+ end
76
+
77
+ SlackIntegrationService.new(user: Current.user, slack_account: link.slack_account).unlink_channel(link)
78
+ render json: { success: true }
79
+ end
80
+
81
+ private
82
+
83
+ def set_creative
84
+ @creative = Collavre::Creative.find(params[:creative_id])
85
+ end
86
+
87
+ def fetch_channels(slack_account)
88
+ client = SlackClient.new(access_token: slack_account.access_token)
89
+ response = client.list_channels
90
+ return [] unless response[:ok]
91
+
92
+ (response[:channels] || []).map do |channel|
93
+ { id: channel[:id], name: channel[:name] }
94
+ end
95
+ rescue StandardError => e
96
+ Rails.logger.error("Failed to fetch Slack channels: #{e.message}")
97
+ []
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,105 @@
1
+ module CollavreSlack
2
+ class SlackAuthController < ApplicationController
3
+ allow_unauthenticated_access only: :callback
4
+
5
+ def start
6
+ unless Current.user
7
+ render json: { error: I18n.t("collavre_slack.errors.authentication_required") }, status: :unauthorized
8
+ return
9
+ end
10
+
11
+ # Sign the state with user_id to recover it securely in callback (popup windows don't share session)
12
+ state_data = { nonce: SecureRandom.hex(16), user_id: Current.user.id, exp: 10.minutes.from_now.to_i }
13
+ state = message_verifier.generate(state_data)
14
+ session[:slack_oauth_state] = state
15
+
16
+ redirect_to slack_client.oauth_authorize_url(state: state), allow_other_host: true
17
+ end
18
+
19
+ def callback
20
+ unless params[:code].present?
21
+ render json: { error: I18n.t("collavre_slack.errors.missing_oauth_code") }, status: :unprocessable_entity
22
+ return
23
+ end
24
+
25
+ # Verify and decode user_id from signed state (required - popup windows don't share session)
26
+ unless params[:state].present?
27
+ render json: { error: I18n.t("collavre_slack.errors.missing_oauth_state") }, status: :unprocessable_entity
28
+ return
29
+ end
30
+
31
+ begin
32
+ state_data = message_verifier.verify(params[:state]).with_indifferent_access
33
+ Rails.logger.info("[SlackAuth] State data: #{state_data.inspect}")
34
+ # Check expiration
35
+ if state_data[:exp] && Time.at(state_data[:exp]) < Time.current
36
+ render json: { error: I18n.t("collavre_slack.errors.invalid_oauth_state") }, status: :unprocessable_entity
37
+ return
38
+ end
39
+ user = ::User.find(state_data[:user_id])
40
+ Rails.logger.info("[SlackAuth] Verified user_id=#{user.id} from signed state")
41
+ rescue ActiveSupport::MessageVerifier::InvalidSignature => e
42
+ Rails.logger.warn("[SlackAuth] Invalid state signature: #{e.message}")
43
+ render json: { error: I18n.t("collavre_slack.errors.invalid_oauth_state") }, status: :unprocessable_entity
44
+ return
45
+ rescue ActiveRecord::RecordNotFound
46
+ Rails.logger.warn("[SlackAuth] User not found from state")
47
+ render json: { error: I18n.t("collavre_slack.errors.user_not_found") }, status: :unprocessable_entity
48
+ return
49
+ end
50
+
51
+ oauth_response = slack_client.oauth_access(code: params[:code], redirect_uri: slack_client.redirect_uri)
52
+ if oauth_response[:ok] != true
53
+ render json: { error: oauth_response[:error] || I18n.t("collavre_slack.errors.oauth_failed") }, status: :unprocessable_entity
54
+ return
55
+ end
56
+
57
+ Rails.logger.info("[SlackAuth] OAuth response: team=#{oauth_response.dig(:team, :id)}, user=#{user&.id}")
58
+
59
+ account = SlackAccount.find_or_initialize_by(team_id: oauth_response.dig(:team, :id))
60
+ account.user ||= user
61
+ account.team_name = oauth_response.dig(:team, :name)
62
+ account.access_token = oauth_response[:access_token]
63
+ account.authed_user_id = oauth_response.dig(:authed_user, :id)
64
+ account.scopes = oauth_response[:scope]
65
+
66
+ if account.save
67
+ # Render HTML that closes the popup and notifies the parent window
68
+ render html: close_popup_html.html_safe, layout: false
69
+ else
70
+ Rails.logger.error("[SlackAuth] Failed to save account: #{account.errors.full_messages.join(', ')}")
71
+ render json: { error: I18n.t("collavre_slack.errors.save_failed", errors: account.errors.full_messages.join(", ")) }, status: :unprocessable_entity
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def slack_client
78
+ @slack_client ||= SlackClient.new
79
+ end
80
+
81
+ def message_verifier
82
+ @message_verifier ||= Rails.application.message_verifier("slack_oauth")
83
+ end
84
+
85
+ def close_popup_html
86
+ title = I18n.t("collavre_slack.views.auth.success_title")
87
+ message = I18n.t("collavre_slack.views.auth.success_message")
88
+ <<~HTML
89
+ <!DOCTYPE html>
90
+ <html>
91
+ <head><title>#{title}</title></head>
92
+ <body>
93
+ <p>#{message}</p>
94
+ <script>
95
+ if (window.opener) {
96
+ window.opener.postMessage({ type: 'slack-auth-success' }, '*');
97
+ }
98
+ window.close();
99
+ </script>
100
+ </body>
101
+ </html>
102
+ HTML
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,63 @@
1
+ module CollavreSlack
2
+ class SlackEventsController < ApplicationController
3
+ allow_unauthenticated_access only: :create
4
+ skip_forgery_protection only: :create
5
+
6
+ def create
7
+ body = request.raw_post
8
+ payload = JSON.parse(body, symbolize_names: true)
9
+
10
+ # Handle URL verification challenge first (needed for initial Slack app setup)
11
+ # Slack sends a signed request, but we allow this through to complete setup
12
+ if payload[:type] == "url_verification"
13
+ render json: { challenge: payload[:challenge] }
14
+ return
15
+ end
16
+
17
+ # For all other events, validate the signature
18
+ unless valid_signature?(body)
19
+ Rails.logger.warn("[SlackEvents] Invalid signature for event type: #{payload[:type]}")
20
+ render json: { error: I18n.t("collavre_slack.errors.invalid_signature") }, status: :unauthorized
21
+ return
22
+ end
23
+
24
+ handler = SlackEventHandler.new(payload: payload)
25
+ normalized = handler.call
26
+
27
+ if normalized
28
+ case normalized[:type]
29
+ when :message
30
+ SlackInboundMessageJob.perform_later(normalized)
31
+ when :reaction_added, :reaction_removed
32
+ SlackInboundReactionJob.perform_later(normalized)
33
+ when :message_updated
34
+ SlackInboundMessageUpdateJob.perform_later(normalized)
35
+ when :message_deleted
36
+ SlackInboundMessageDeleteJob.perform_later(normalized)
37
+ end
38
+ end
39
+
40
+ head :ok
41
+ end
42
+
43
+ private
44
+
45
+ def valid_signature?(body)
46
+ return false if signing_secret.blank?
47
+
48
+ timestamp = request.headers["X-Slack-Request-Timestamp"].to_s
49
+ signature = request.headers["X-Slack-Signature"].to_s
50
+ return false if timestamp.blank? || signature.blank?
51
+
52
+ return false if (Time.now.to_i - timestamp.to_i).abs > 300
53
+
54
+ base = "v0:#{timestamp}:#{body}"
55
+ expected = "v0=" + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, base)
56
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature)
57
+ end
58
+
59
+ def signing_secret
60
+ CollavreSlack.config.signing_secret
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,24 @@
1
+ module CollavreSlack
2
+ class SlackMessagesController < ApplicationController
3
+ before_action :set_creative
4
+
5
+ def create
6
+ unless @creative.has_permission?(Current.user, :feedback)
7
+ render json: { error: I18n.t("collavre_slack.errors.forbidden") }, status: :forbidden
8
+ return
9
+ end
10
+
11
+ channel_link = SlackChannelLink.find_by!(creative: @creative)
12
+ dispatcher = SlackMessageDispatcher.new(channel_link: channel_link)
13
+ log = dispatcher.enqueue(message: params[:message], sender: Current.user)
14
+
15
+ render json: { status: "queued", message_log_id: log.id }, status: :accepted
16
+ end
17
+
18
+ private
19
+
20
+ def set_creative
21
+ @creative = Collavre::Creative.find(params[:creative_id])
22
+ end
23
+ end
24
+ end