collavre_slack 0.2.7 → 0.2.9
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 +4 -4
- data/app/controllers/collavre_slack/slack_events_controller.rb +1 -1
- data/app/javascript/collavre_slack.js +5 -5
- data/app/jobs/collavre_slack/slack_inbound_message_delete_job.rb +17 -1
- data/app/jobs/collavre_slack/slack_inbound_message_job.rb +164 -37
- data/app/jobs/collavre_slack/slack_inbound_message_update_job.rb +16 -1
- data/app/jobs/collavre_slack/slack_inbound_reaction_job.rb +14 -0
- data/app/models/collavre_slack/slack_channel_link.rb +1 -0
- data/app/models/collavre_slack/slack_inbound_reservation.rb +14 -0
- data/db/migrate/20260712000000_create_slack_inbound_reservations.rb +17 -0
- data/lib/collavre_slack/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5f9dfe6379fdea85a145479729b2fa87d0d6c731476ba9899a9f3f015d86a2e
|
|
4
|
+
data.tar.gz: 4fff38911333b72c7da5f6ade9f3b898e83e45bef388754d918960aac8f14135
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb8a7c91ce37da68f9b20b1478a14c002f5f864f69cb37c98cd35877c179274aa251e39a69cb3233ed8600449eb2d88cbf7d7c695ea9febbbcbcc84157150fc4
|
|
7
|
+
data.tar.gz: 315a01087854304bcbde759d9374ed5c9cd6bdede2f7f3040bfaa5fe5a7328de4bc6c5a14a8cf1905cbf55e6a3d1b740a6baf313498eb4757c017d268df79933
|
|
@@ -49,7 +49,7 @@ module CollavreSlack
|
|
|
49
49
|
signature = request.headers["X-Slack-Signature"].to_s
|
|
50
50
|
return false if timestamp.blank? || signature.blank?
|
|
51
51
|
|
|
52
|
-
return false if (Time.
|
|
52
|
+
return false if (Time.current.to_i - timestamp.to_i).abs > 300
|
|
53
53
|
|
|
54
54
|
base = "v0:#{timestamp}:#{body}"
|
|
55
55
|
expected = "v0=" + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, base)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { filterChannels, reconcileSelection, buildChannelViewModels } from './slack_channel_list.js';
|
|
2
2
|
import { csrfToken, showError, clearError, updateStepVisibility, openOAuthPopup, fetchWithCsrf, setupModalClose } from 'collavre/modules/integration_wizard';
|
|
3
|
+
import { alertDialog, confirmDialog } from 'collavre/lib/utils/dialog';
|
|
3
4
|
|
|
4
5
|
let slackIntegrationInitialized = false;
|
|
5
6
|
|
|
@@ -152,7 +153,7 @@ if (!slackIntegrationInitialized) {
|
|
|
152
153
|
deleteBtn.textContent = modal.dataset.deleteButtonLabel || 'Remove';
|
|
153
154
|
deleteBtn.style.cssText = 'padding:0.25em 0.5em;font-size:0.8em;';
|
|
154
155
|
deleteBtn.addEventListener('click', function () {
|
|
155
|
-
performDeleteLink(link);
|
|
156
|
+
performDeleteLink(link, deleteBtn);
|
|
156
157
|
});
|
|
157
158
|
|
|
158
159
|
li.appendChild(channelInfo);
|
|
@@ -189,10 +190,9 @@ if (!slackIntegrationInitialized) {
|
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
|
|
192
|
-
function performDeleteLink(link) {
|
|
193
|
-
if (!
|
|
193
|
+
async function performDeleteLink(link, btn) {
|
|
194
|
+
if (!(await confirmDialog(modal.dataset.deleteConfirm, { danger: true }))) return;
|
|
194
195
|
|
|
195
|
-
const btn = event.target;
|
|
196
196
|
btn.disabled = true;
|
|
197
197
|
btn.textContent = '...';
|
|
198
198
|
clearError(errorEl);
|
|
@@ -334,7 +334,7 @@ if (!slackIntegrationInitialized) {
|
|
|
334
334
|
openBtn.addEventListener('click', function () {
|
|
335
335
|
creativeId = this.dataset.creativeId;
|
|
336
336
|
if (!creativeId) {
|
|
337
|
-
|
|
337
|
+
alertDialog(modal.dataset.noCreative);
|
|
338
338
|
return;
|
|
339
339
|
}
|
|
340
340
|
modal.style.display = 'flex';
|
|
@@ -4,8 +4,24 @@ module CollavreSlack
|
|
|
4
4
|
|
|
5
5
|
queue_as :default
|
|
6
6
|
|
|
7
|
+
# SlackEventsController acks Slack with 200 the instant it enqueues this job
|
|
8
|
+
# (ack-before-apply), so Slack never re-delivers the event once accepted.
|
|
9
|
+
# Without a retry, a transient DB contention error would park the job and the
|
|
10
|
+
# inbound deletion would be lost permanently. Only transient contention
|
|
11
|
+
# retries.
|
|
12
|
+
retry_on ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout,
|
|
13
|
+
wait: 2.seconds, attempts: 5
|
|
14
|
+
|
|
15
|
+
# Permanent/unprocessable failures: retrying cannot help. Discard so the
|
|
16
|
+
# queue self-heals rather than looping forever.
|
|
17
|
+
discard_on ActiveJob::DeserializationError
|
|
18
|
+
discard_on ActiveRecord::RecordNotFound
|
|
19
|
+
discard_on ActiveRecord::RecordInvalid
|
|
20
|
+
|
|
7
21
|
def perform(payload)
|
|
8
|
-
|
|
22
|
+
# reraise so transient errors reach retry_on and permanent ones reach
|
|
23
|
+
# discard_on; the concern still logs before re-raising.
|
|
24
|
+
with_slack_error_handling("SlackInboundMessageDeleteJob", reraise: true) do
|
|
9
25
|
data = payload.with_indifferent_access
|
|
10
26
|
comment = Collavre::Comment.find_by(id: data[:comment_id])
|
|
11
27
|
|
|
@@ -2,72 +2,171 @@ module CollavreSlack
|
|
|
2
2
|
class SlackInboundMessageJob < ApplicationJob
|
|
3
3
|
queue_as :default
|
|
4
4
|
|
|
5
|
+
# Slack is acked (200) the instant this job is enqueued, so a lost job means a
|
|
6
|
+
# lost message. We can't use a job-level `retry_on`: re-running `perform` would
|
|
7
|
+
# re-run CommandProcessor's non-idempotent commands (/calendar, /work, MCP). So
|
|
8
|
+
# CommandProcessor runs exactly once and each DB write around it is retried in
|
|
9
|
+
# place via #with_transient_retry.
|
|
10
|
+
|
|
11
|
+
# Retrying can't help these — the record was deleted, the payload can't
|
|
12
|
+
# deserialize, or it's structurally invalid — so discard to let the queue self-heal.
|
|
13
|
+
discard_on ActiveJob::DeserializationError
|
|
14
|
+
discard_on ActiveRecord::RecordNotFound
|
|
15
|
+
discard_on ActiveRecord::RecordInvalid
|
|
16
|
+
|
|
5
17
|
def perform(payload)
|
|
6
18
|
data = payload.with_indifferent_access
|
|
19
|
+
|
|
7
20
|
creative = Collavre::Creative.find(data[:creative_id])
|
|
8
21
|
user = Collavre.user_class.find_by(id: data[:user_id])
|
|
9
22
|
channel_link = SlackChannelLink.find_by(id: data[:slack_channel_link_id])
|
|
10
23
|
|
|
11
24
|
return unless creative && channel_link
|
|
12
25
|
|
|
13
|
-
comment_user = user
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
comment_user = user.presence || channel_link.created_by
|
|
27
|
+
|
|
28
|
+
# Idempotent permission grant / invite writes. Run BEFORE the exactly-once claim so
|
|
29
|
+
# a transient failure here (e.g. a Solid Queue enqueue deadlock that exhausts the
|
|
30
|
+
# in-place retries) commits no reservation — a failed-job retry can still reprocess
|
|
31
|
+
# the message. Each re-checks the existing share/invitation, so a sequential re-run
|
|
32
|
+
# is a no-op. Retried in place on transient contention — otherwise a Deadlocked here
|
|
33
|
+
# loses the acked message.
|
|
34
|
+
with_transient_retry do
|
|
35
|
+
# Case 1: User exists in Collavre but lacks permission
|
|
36
|
+
if user && !creative.has_permission?(user, :feedback)
|
|
37
|
+
grant_feedback_permission(creative: creative, user: user, granter: channel_link.created_by)
|
|
38
|
+
Rails.logger.info("[CollavreSlack] Granted feedback permission to user #{user.id} on creative #{creative.id}")
|
|
39
|
+
end
|
|
22
40
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if slack_email.present?
|
|
41
|
+
# Case 2: User not in Collavre - invite by email
|
|
42
|
+
if user.nil? && data[:slack_email].present?
|
|
26
43
|
invite_user_by_email(
|
|
27
44
|
creative: creative,
|
|
28
|
-
email: slack_email,
|
|
45
|
+
email: data[:slack_email],
|
|
29
46
|
inviter: channel_link.created_by
|
|
30
47
|
)
|
|
31
|
-
Rails.logger.info("[CollavreSlack] Sent invitation to #{slack_email} for creative #{creative.id}")
|
|
48
|
+
Rails.logger.info("[CollavreSlack] Sent invitation to #{data[:slack_email]} for creative #{creative.id}")
|
|
32
49
|
end
|
|
33
|
-
|
|
34
|
-
# Use channel creator as fallback for comment
|
|
35
|
-
comment_user = channel_link.created_by
|
|
36
50
|
end
|
|
37
51
|
|
|
52
|
+
# Exactly-once guard, claimed here: after the idempotent writes above, immediately
|
|
53
|
+
# before the non-idempotent CommandProcessor (/calendar, /work, MCP). Slack is acked
|
|
54
|
+
# before this job runs, so a message can arrive twice (sequentially or concurrently);
|
|
55
|
+
# a read-only "already applied?" check can't cover concurrency — both jobs read "not
|
|
56
|
+
# applied" and both run the commands. We atomically claim (channel_link, message_ts)
|
|
57
|
+
# via a unique index, so only the winner runs CommandProcessor and persists.
|
|
58
|
+
#
|
|
59
|
+
# Placed after the reads and the idempotent writes so no recoverable failure leaves a
|
|
60
|
+
# reservation behind: every pre-claim error is either a deterministic RecordNotFound
|
|
61
|
+
# (deleted creative) or a transient grant/invite failure a retry can redo. Only past
|
|
62
|
+
# the claim do we reach the non-idempotent commands, so the sole loss window is a
|
|
63
|
+
# discard/crash between the claim and the comment persisting — intentional, since a
|
|
64
|
+
# retry there would duplicate the commands. No time-lease (expiry reclaim is fragile).
|
|
65
|
+
# channel_link is verified present, so a RecordInvalid can only mean "already claimed".
|
|
66
|
+
return unless claim_inbound_message(data)
|
|
67
|
+
|
|
38
68
|
# Create comment with appropriate user
|
|
39
69
|
comment = Collavre::Comment.new(
|
|
40
70
|
creative: creative,
|
|
41
71
|
user: comment_user,
|
|
42
|
-
content: format_comment_content(data[:content], user, slack_display_name)
|
|
72
|
+
content: format_comment_content(data[:content], user, data[:slack_display_name])
|
|
43
73
|
)
|
|
44
74
|
|
|
45
75
|
# Mark this comment as coming from Slack to prevent loop
|
|
46
76
|
comment.instance_variable_set(:@from_slack, true)
|
|
47
77
|
|
|
78
|
+
# Non-idempotent side effects: run once, outside the retryable write below.
|
|
48
79
|
response = Collavre::Comments::CommandProcessor.new(comment: comment, user: user).call
|
|
49
80
|
if response.present?
|
|
50
81
|
comment.content = "#{comment.content}\n\n#{response}"
|
|
51
82
|
comment.skip_dispatch = true # slash command responses should not trigger AI
|
|
52
83
|
end
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# Create link between Slack message and comment for reaction sync
|
|
56
|
-
if data[:slack_channel_link_id].present? && data[:slack_message_ts].present?
|
|
57
|
-
SlackCommentLink.create!(
|
|
58
|
-
comment: comment,
|
|
59
|
-
slack_channel_link_id: data[:slack_channel_link_id],
|
|
60
|
-
message_ts: data[:slack_message_ts]
|
|
61
|
-
)
|
|
62
|
-
end
|
|
84
|
+
|
|
85
|
+
persist_comment_with_link!(comment, data)
|
|
63
86
|
end
|
|
64
87
|
|
|
65
88
|
private
|
|
66
89
|
|
|
90
|
+
# Persist comment + Slack link atomically, retrying only this write (not the whole
|
|
91
|
+
# job) so CommandProcessor isn't re-applied. `comment` is a fresh unsaved record,
|
|
92
|
+
# so a rolled-back retry re-saves the same instance — exactly one comment and link.
|
|
93
|
+
def persist_comment_with_link!(comment, data)
|
|
94
|
+
with_transient_retry do
|
|
95
|
+
ActiveRecord::Base.transaction do
|
|
96
|
+
comment.save!
|
|
97
|
+
|
|
98
|
+
# Create link between Slack message and comment for reaction sync
|
|
99
|
+
if data[:slack_channel_link_id].present? && data[:slack_message_ts].present?
|
|
100
|
+
SlackCommentLink.create!(
|
|
101
|
+
comment: comment,
|
|
102
|
+
slack_channel_link_id: data[:slack_channel_link_id],
|
|
103
|
+
message_ts: data[:slack_message_ts]
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Retry an idempotent block on transient DB contention. Only wrap idempotent work
|
|
111
|
+
# — the deliberate substitute for a job-level `retry_on`, which would also re-run
|
|
112
|
+
# the non-idempotent CommandProcessor.
|
|
113
|
+
def with_transient_retry(max_attempts: 5)
|
|
114
|
+
attempts = 0
|
|
115
|
+
begin
|
|
116
|
+
yield
|
|
117
|
+
rescue => e
|
|
118
|
+
raise unless transient_contention?(e)
|
|
119
|
+
|
|
120
|
+
attempts += 1
|
|
121
|
+
raise if attempts >= max_attempts
|
|
122
|
+
|
|
123
|
+
sleep(0.1 * (2**(attempts - 1)))
|
|
124
|
+
retry
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Whether an error is retryable transient contention, raised directly or wrapped.
|
|
129
|
+
# Solid Queue re-raises a failed `deliver_later` enqueue INSERT as its own
|
|
130
|
+
# EnqueueError with the original as #cause, so we match the cause chain — retrying
|
|
131
|
+
# a wrapped Deadlocked/LockWaitTimeout while re-raising a wrapped permanent failure.
|
|
132
|
+
def transient_contention?(error)
|
|
133
|
+
seen = []
|
|
134
|
+
while error && !seen.include?(error)
|
|
135
|
+
return true if error.is_a?(ActiveRecord::Deadlocked) || error.is_a?(ActiveRecord::LockWaitTimeout)
|
|
136
|
+
|
|
137
|
+
seen << error
|
|
138
|
+
error = error.cause
|
|
139
|
+
end
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Atomically claim this message before the non-idempotent commands. True if we won the claim
|
|
144
|
+
# (or there's no dedup key — same as prior behaviour), false if another job owns it.
|
|
145
|
+
# Transient contention on the INSERT is retried in place, not a "claimed" signal.
|
|
146
|
+
# Only uniqueness means already-claimed: RecordNotUnique is the authoritative gate
|
|
147
|
+
# under concurrency, RecordInvalid covers the committed sequential case. channel_link
|
|
148
|
+
# and message_ts are verified present, so uniqueness is the only validation that can
|
|
149
|
+
# fail — RecordInvalid here unambiguously means "already claimed".
|
|
150
|
+
def claim_inbound_message(data)
|
|
151
|
+
channel_link_id = data[:slack_channel_link_id]
|
|
152
|
+
message_ts = data[:slack_message_ts]
|
|
153
|
+
return true unless channel_link_id.present? && message_ts.present?
|
|
154
|
+
|
|
155
|
+
with_transient_retry do
|
|
156
|
+
SlackInboundReservation.create!(
|
|
157
|
+
slack_channel_link_id: channel_link_id,
|
|
158
|
+
message_ts: message_ts
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
true
|
|
162
|
+
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
67
166
|
def grant_feedback_permission(creative:, user:, granter:)
|
|
68
167
|
# Check if share already exists
|
|
69
168
|
existing_share = Collavre::CreativeShare.find_by(creative: creative, user: user)
|
|
70
|
-
return if existing_share
|
|
169
|
+
return if feedback_or_higher?(existing_share)
|
|
71
170
|
|
|
72
171
|
if existing_share
|
|
73
172
|
existing_share.update!(permission: :feedback)
|
|
@@ -79,20 +178,48 @@ module CollavreSlack
|
|
|
79
178
|
shared_by: granter
|
|
80
179
|
)
|
|
81
180
|
end
|
|
181
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
|
182
|
+
# A concurrent message for the same user can create the share between the find_by
|
|
183
|
+
# above and this write, tripping the (creative_id, user_id) uniqueness. The grant
|
|
184
|
+
# is already satisfied, so re-check and treat as success — otherwise RecordInvalid
|
|
185
|
+
# would hit the job's `discard_on` and drop this still-unposted message.
|
|
186
|
+
raise unless feedback_or_higher?(Collavre::CreativeShare.find_by(creative: creative, user: user))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Whether a CreativeShare grants feedback access or higher. `permission` is an enum
|
|
190
|
+
# returning the string label, so rank it via the enum mapping like Permissible does.
|
|
191
|
+
def feedback_or_higher?(share)
|
|
192
|
+
return false unless share
|
|
193
|
+
|
|
194
|
+
Collavre::CreativeShare.permissions.fetch(share.permission) >=
|
|
195
|
+
Collavre::CreativeShare.permissions.fetch("feedback")
|
|
82
196
|
end
|
|
83
197
|
|
|
84
198
|
def invite_user_by_email(creative:, email:, inviter:)
|
|
85
|
-
#
|
|
199
|
+
# A pre-existing invitation was created AND had its email enqueued in the same
|
|
200
|
+
# transaction below, so there's nothing left to do. This dedups sequential
|
|
201
|
+
# redelivery; a true concurrent double-delivery can still create two invitations
|
|
202
|
+
# (no unique index on (creative_id, email)) — accepted, since a duplicate invite
|
|
203
|
+
# email is far less harmful than a lost message. If such an index is ever added,
|
|
204
|
+
# rescue RecordNotUnique + re-check here like #grant_feedback_permission does,
|
|
205
|
+
# otherwise the concurrent loser's create! would escape as a hard job failure.
|
|
86
206
|
existing_invitation = Collavre::Invitation.find_by(creative: creative, email: email)
|
|
87
207
|
return if existing_invitation
|
|
88
208
|
|
|
89
|
-
invitation
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
209
|
+
# Create invitation + enqueue email atomically. `deliver_later` enqueues
|
|
210
|
+
# synchronously, so its INSERT joins this transaction; a transient EnqueueError
|
|
211
|
+
# rolls both back and #with_transient_retry redoes them together. Without the
|
|
212
|
+
# transaction, a committed invitation with a failed enqueue would, on retry, hit
|
|
213
|
+
# the guard above and silently never send the email.
|
|
214
|
+
ActiveRecord::Base.transaction do
|
|
215
|
+
invitation = Collavre::Invitation.create!(
|
|
216
|
+
email: email,
|
|
217
|
+
inviter: inviter,
|
|
218
|
+
creative: creative,
|
|
219
|
+
permission: :feedback
|
|
220
|
+
)
|
|
221
|
+
Collavre::InvitationMailer.with(invitation: invitation).invite.deliver_later
|
|
222
|
+
end
|
|
96
223
|
end
|
|
97
224
|
|
|
98
225
|
def format_comment_content(content, user, slack_display_name)
|
|
@@ -4,8 +4,23 @@ module CollavreSlack
|
|
|
4
4
|
|
|
5
5
|
queue_as :default
|
|
6
6
|
|
|
7
|
+
# SlackEventsController acks Slack with 200 the instant it enqueues this job
|
|
8
|
+
# (ack-before-apply), so Slack never re-delivers the event once accepted.
|
|
9
|
+
# Without a retry, a transient DB contention error would park the job and the
|
|
10
|
+
# inbound edit would be lost permanently. Only transient contention retries.
|
|
11
|
+
retry_on ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout,
|
|
12
|
+
wait: 2.seconds, attempts: 5
|
|
13
|
+
|
|
14
|
+
# Permanent/unprocessable failures: retrying cannot help. Discard so the
|
|
15
|
+
# queue self-heals rather than looping forever.
|
|
16
|
+
discard_on ActiveJob::DeserializationError
|
|
17
|
+
discard_on ActiveRecord::RecordNotFound
|
|
18
|
+
discard_on ActiveRecord::RecordInvalid
|
|
19
|
+
|
|
7
20
|
def perform(payload)
|
|
8
|
-
|
|
21
|
+
# reraise so transient errors reach retry_on and permanent ones reach
|
|
22
|
+
# discard_on; the concern still logs before re-raising.
|
|
23
|
+
with_slack_error_handling("SlackInboundMessageUpdateJob", reraise: true) do
|
|
9
24
|
data = payload.with_indifferent_access
|
|
10
25
|
comment = Collavre::Comment.find_by(id: data[:comment_id])
|
|
11
26
|
return unless comment
|
|
@@ -2,6 +2,20 @@ module CollavreSlack
|
|
|
2
2
|
class SlackInboundReactionJob < ApplicationJob
|
|
3
3
|
queue_as :default
|
|
4
4
|
|
|
5
|
+
# SlackEventsController acks Slack with 200 the instant it enqueues this job
|
|
6
|
+
# (ack-before-apply), so Slack never re-delivers the event once accepted.
|
|
7
|
+
# Without a retry, a transient DB contention error would park the job and the
|
|
8
|
+
# inbound reaction would be lost permanently. Only transient contention
|
|
9
|
+
# retries; a real bug still raises and parks.
|
|
10
|
+
retry_on ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout,
|
|
11
|
+
wait: 2.seconds, attempts: 5
|
|
12
|
+
|
|
13
|
+
# Permanent/unprocessable failures: retrying cannot help. Discard so the
|
|
14
|
+
# queue self-heals rather than looping forever.
|
|
15
|
+
discard_on ActiveJob::DeserializationError
|
|
16
|
+
discard_on ActiveRecord::RecordNotFound
|
|
17
|
+
discard_on ActiveRecord::RecordInvalid
|
|
18
|
+
|
|
5
19
|
def perform(payload)
|
|
6
20
|
data = payload.with_indifferent_access
|
|
7
21
|
comment = Collavre::Comment.find_by(id: data[:comment_id])
|
|
@@ -8,6 +8,7 @@ module CollavreSlack
|
|
|
8
8
|
|
|
9
9
|
has_many :slack_message_logs, class_name: "CollavreSlack::SlackMessageLog", dependent: :destroy
|
|
10
10
|
has_many :slack_comment_links, class_name: "CollavreSlack::SlackCommentLink", dependent: :destroy
|
|
11
|
+
has_many :slack_inbound_reservations, class_name: "CollavreSlack::SlackInboundReservation", dependent: :destroy
|
|
11
12
|
|
|
12
13
|
validates :channel_id, :channel_name, presence: true
|
|
13
14
|
# 1:1 relationship: one creative can only be linked to one Slack channel
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module CollavreSlack
|
|
2
|
+
# Pre-flight claim on an inbound Slack message so its non-idempotent side effects
|
|
3
|
+
# run once. The unique (slack_channel_link_id, message_ts) index makes the claim
|
|
4
|
+
# atomic: of two concurrent jobs, only one inserts and proceeds. See the job for
|
|
5
|
+
# the crash-window trade-off.
|
|
6
|
+
class SlackInboundReservation < ApplicationRecord
|
|
7
|
+
self.table_name = "slack_inbound_reservations"
|
|
8
|
+
|
|
9
|
+
belongs_to :slack_channel_link, class_name: "CollavreSlack::SlackChannelLink"
|
|
10
|
+
|
|
11
|
+
validates :message_ts, presence: true
|
|
12
|
+
validates :message_ts, uniqueness: { scope: :slack_channel_link_id }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class CreateSlackInboundReservations < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# Pre-flight claim so an inbound Slack message's non-idempotent side effects run
|
|
4
|
+
# once under concurrent/redelivered delivery. The unique (slack_channel_link_id,
|
|
5
|
+
# message_ts) index is the atomic gate, claimed up front rather than after the
|
|
6
|
+
# side effects.
|
|
7
|
+
create_table :slack_inbound_reservations do |t|
|
|
8
|
+
t.references :slack_channel_link, null: false, foreign_key: { on_delete: :cascade }
|
|
9
|
+
t.string :message_ts, null: false
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :slack_inbound_reservations, [ :slack_channel_link_id, :message_ts ],
|
|
15
|
+
unique: true, name: "idx_slack_inbound_reservations_on_channel_and_ts"
|
|
16
|
+
end
|
|
17
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: collavre_slack
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Collavre
|
|
@@ -98,6 +98,7 @@ files:
|
|
|
98
98
|
- app/models/collavre_slack/slack_account.rb
|
|
99
99
|
- app/models/collavre_slack/slack_channel_link.rb
|
|
100
100
|
- app/models/collavre_slack/slack_comment_link.rb
|
|
101
|
+
- app/models/collavre_slack/slack_inbound_reservation.rb
|
|
101
102
|
- app/models/collavre_slack/slack_message_log.rb
|
|
102
103
|
- app/models/collavre_slack/slack_user_mapping.rb
|
|
103
104
|
- app/models/concerns/collavre_slack/error_loggable.rb
|
|
@@ -125,6 +126,7 @@ files:
|
|
|
125
126
|
- db/migrate/20260130010001_add_comment_id_to_slack_message_logs.rb
|
|
126
127
|
- db/migrate/20260130020000_add_cascade_delete_to_slack_comment_foreign_keys.rb
|
|
127
128
|
- db/migrate/20260131000000_remove_is_active_from_slack_channel_links.rb
|
|
129
|
+
- db/migrate/20260712000000_create_slack_inbound_reservations.rb
|
|
128
130
|
- lib/collavre_slack.rb
|
|
129
131
|
- lib/collavre_slack/configuration.rb
|
|
130
132
|
- lib/collavre_slack/engine.rb
|