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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c3b4139e84c2e9f97e305dda87c4c54982b111c1e7eaa72538b586e3d36b57b
4
- data.tar.gz: d9fd80c81173f332e5af67a07d0d51dfcff8264bc3a3d202ac4b253fa8cab182
3
+ metadata.gz: d5f9dfe6379fdea85a145479729b2fa87d0d6c731476ba9899a9f3f015d86a2e
4
+ data.tar.gz: 4fff38911333b72c7da5f6ade9f3b898e83e45bef388754d918960aac8f14135
5
5
  SHA512:
6
- metadata.gz: 7762c23a74cb167f65ee346a820bd6b6267f5aad64a7093e853a73d0241b3e78e05721889e1d4ee9e1f00f890f167bf4bde7129e7d9cf563e75a213bd07df9b1
7
- data.tar.gz: fa1e173ca870efe960aab55abb6e03c9451a4cc0860345c470dfd41922ad38195f1d2f4abd78bb5f9a5dc04ed61207e62e9675e29a6a1ddb079558095dcf6fe2
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.now.to_i - timestamp.to_i).abs > 300
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 (!confirm(modal.dataset.deleteConfirm)) return;
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
- alert(modal.dataset.noCreative);
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
- with_slack_error_handling("SlackInboundMessageDeleteJob") do
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
- slack_email = data[:slack_email]
15
- slack_display_name = data[:slack_display_name]
16
-
17
- # Case 1: User exists in Collavre but lacks permission
18
- if user && !creative.has_permission?(user, :feedback)
19
- grant_feedback_permission(creative: creative, user: user, granter: channel_link.created_by)
20
- Rails.logger.info("[CollavreSlack] Granted feedback permission to user #{user.id} on creative #{creative.id}")
21
- end
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
- # Case 2: User not in Collavre - invite by email
24
- unless user
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
- comment.save!
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 && existing_share.permission_level >= Collavre::CreativeShare.permissions[:feedback]
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
- # Check if invitation already exists
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 = Collavre::Invitation.create!(
90
- email: email,
91
- inviter: inviter,
92
- creative: creative,
93
- permission: :feedback
94
- )
95
- Collavre::InvitationMailer.with(invitation: invitation).invite.deliver_later
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
- with_slack_error_handling("SlackInboundMessageUpdateJob") do
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
@@ -1,3 +1,3 @@
1
1
  module CollavreSlack
2
- VERSION = "0.2.7"
2
+ VERSION = "0.2.9"
3
3
  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.7
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