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.
- checksums.yaml +7 -0
- data/README.md +80 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/collavre_slack/slack_integration.css +15 -0
- data/app/controllers/collavre_slack/application_controller.rb +12 -0
- data/app/controllers/collavre_slack/creatives/slack_integrations_controller.rb +101 -0
- data/app/controllers/collavre_slack/slack_auth_controller.rb +105 -0
- data/app/controllers/collavre_slack/slack_events_controller.rb +63 -0
- data/app/controllers/collavre_slack/slack_messages_controller.rb +24 -0
- data/app/javascript/collavre_slack.js +509 -0
- data/app/jobs/collavre_slack/application_job.rb +4 -0
- data/app/jobs/collavre_slack/slack_channel_sync_job.rb +45 -0
- data/app/jobs/collavre_slack/slack_inbound_message_delete_job.rb +24 -0
- data/app/jobs/collavre_slack/slack_inbound_message_job.rb +129 -0
- data/app/jobs/collavre_slack/slack_inbound_message_update_job.rb +22 -0
- data/app/jobs/collavre_slack/slack_inbound_reaction_job.rb +47 -0
- data/app/jobs/collavre_slack/slack_message_delete_job.rb +29 -0
- data/app/jobs/collavre_slack/slack_message_job.rb +45 -0
- data/app/jobs/collavre_slack/slack_message_update_job.rb +32 -0
- data/app/jobs/collavre_slack/slack_reaction_job.rb +45 -0
- data/app/models/collavre_slack/application_record.rb +5 -0
- data/app/models/collavre_slack/slack_account.rb +13 -0
- data/app/models/collavre_slack/slack_channel_link.rb +16 -0
- data/app/models/collavre_slack/slack_comment_link.rb +18 -0
- data/app/models/collavre_slack/slack_message_log.rb +11 -0
- data/app/models/collavre_slack/slack_user_mapping.rb +10 -0
- data/app/models/concerns/collavre_slack/slack_dispatchable.rb +97 -0
- data/app/models/concerns/collavre_slack/slack_reaction_dispatchable.rb +55 -0
- data/app/services/collavre_slack/emoji_mapping.rb +90 -0
- data/app/services/collavre_slack/mention_mapping.rb +45 -0
- data/app/services/collavre_slack/slack_client.rb +141 -0
- data/app/services/collavre_slack/slack_event_handler.rb +259 -0
- data/app/services/collavre_slack/slack_integration_service.rb +26 -0
- data/app/services/collavre_slack/slack_message_dispatcher.rb +24 -0
- data/app/views/collavre_slack/creatives/slack_integrations/index.html.erb +18 -0
- data/app/views/collavre_slack/integrations/_modal.html.erb +66 -0
- data/config/initializers/collavre_slack.rb +7 -0
- data/config/locales/en.yml +57 -0
- data/config/locales/ko.yml +57 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20250201000001_create_slack_accounts.rb +17 -0
- data/db/migrate/20250201000002_create_slack_channel_links.rb +17 -0
- data/db/migrate/20250201000003_create_slack_user_mappings.rb +15 -0
- data/db/migrate/20250201000004_create_slack_message_logs.rb +14 -0
- data/db/migrate/20250201000005_add_last_synced_at_to_slack_channel_links.rb +5 -0
- data/db/migrate/20260130010000_create_slack_comment_links.rb +13 -0
- data/db/migrate/20260130010001_add_comment_id_to_slack_message_logs.rb +5 -0
- data/db/migrate/20260130020000_add_cascade_delete_to_slack_comment_foreign_keys.rb +11 -0
- data/db/migrate/20260131000000_remove_is_active_from_slack_channel_links.rb +5 -0
- data/lib/collavre_slack/configuration.rb +13 -0
- data/lib/collavre_slack/engine.rb +97 -0
- data/lib/collavre_slack/version.rb +3 -0
- data/lib/collavre_slack.rb +13 -0
- 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">×</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)"
|