chat_sdk-slack 1.2.0 → 1.4.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.
@@ -0,0 +1,486 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "timeout"
5
+
6
+ module ChatSDK
7
+ module Slack
8
+ class NativeStream
9
+ STREAM_EXPIRED_ERROR = "message_not_in_streaming_state"
10
+ ROTATION_GRACE = 30
11
+ MAYBE_APPLIED_ERRORS = %w[fatal_error internal_error request_timeout service_unavailable].freeze
12
+ UNSUPPORTED_ERRORS = %w[feature_not_enabled method_deprecated unknown_method].freeze
13
+ FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/
14
+ TABLE_ROW_PATTERN = /^\|.*\|$/
15
+ TABLE_SEPARATOR_PATTERN = /^\|[\s:]*-+[\s:]*(?:\|[\s:]*-+[\s:]*)*\|$/
16
+
17
+ Segment = Struct.new(:streamer, :started_at, :flush_next_append, :reopen_fence, :table_header)
18
+
19
+ attr_reader :adapter, :channel_id, :thread_id, :stream, :options, :max_age
20
+
21
+ def initialize(adapter:, client:, channel_id:, thread_id:, stream:, options:, max_age:)
22
+ @adapter = adapter
23
+ @client = client
24
+ @channel_id = channel_id
25
+ @thread_id = thread_id
26
+ @stream = stream
27
+ @options = options
28
+ @max_age = max_age
29
+ @segment = build_segment
30
+ @rotations = 0
31
+ @finalized_message_ids = []
32
+ @accumulated = +""
33
+ @last_appended = +""
34
+ @last_flushed = +""
35
+ @fallback_message = nil
36
+ @fallback_sent = +""
37
+ @last_fallback_edit_at = nil
38
+ @native_rendered = false
39
+ @mode = :native
40
+ @structured_chunks_supported = true
41
+ @structured_warning_sent = false
42
+ @open_tasks = {}
43
+ @current_plan = nil
44
+ end
45
+
46
+ def run
47
+ stream.each do |chunk|
48
+ case chunk
49
+ when String
50
+ append_text(chunk)
51
+ when ChatSDK::Streaming::MarkdownTextChunk
52
+ append_text(chunk.text)
53
+ when ChatSDK::Streaming::TaskUpdateChunk, ChatSDK::Streaming::PlanUpdateChunk
54
+ send_structured_chunk(chunk)
55
+ end
56
+ end
57
+
58
+ flush_committed(force: true)
59
+ return finish_fallback if @mode == :fallback
60
+
61
+ stop_native_stream
62
+ end
63
+
64
+ private
65
+
66
+ def build_segment
67
+ Segment.new(
68
+ streamer: ChatStreamer.new(
69
+ @client,
70
+ channel: channel_id,
71
+ thread_ts: thread_id,
72
+ recipient_user_id: options[:recipient_user_id],
73
+ recipient_team_id: options[:recipient_team_id],
74
+ task_display_mode: options[:task_display_mode]
75
+ ),
76
+ started_at: nil,
77
+ flush_next_append: false,
78
+ reopen_fence: "",
79
+ table_header: ""
80
+ )
81
+ end
82
+
83
+ def append_text(text)
84
+ return if text.empty?
85
+
86
+ @accumulated << text
87
+ flush_committed
88
+ end
89
+
90
+ def flush_committed(force: false)
91
+ if @mode == :fallback
92
+ flush_fallback(force: force)
93
+ return
94
+ end
95
+
96
+ delta = @accumulated[@last_appended.length..] || ""
97
+ return if delta.empty?
98
+
99
+ send_delta(delta, at_block_boundary: false)
100
+ rescue *stream_error_classes => error
101
+ raise if @native_rendered
102
+
103
+ switch_to_fallback(error)
104
+ flush_fallback(force: force)
105
+ end
106
+
107
+ def send_structured_chunk(chunk)
108
+ flush_committed
109
+ return unless @mode == :native && @structured_chunks_supported
110
+
111
+ send_delta("", at_block_boundary: true)
112
+ begin
113
+ @segment.streamer.append(chunks: [chunk])
114
+ mark_segment_started
115
+ @last_flushed = @last_appended.dup
116
+ remember_structured_chunk(chunk)
117
+ rescue *slack_error_classes => error
118
+ disable_structured_chunks(chunk.type, error)
119
+ end
120
+ end
121
+
122
+ def stop_native_stream
123
+ stop_blocks = options[:stop_blocks]
124
+ result = begin
125
+ @segment.streamer.stop(blocks: stop_blocks)
126
+ rescue *stream_error_classes => error
127
+ if !@native_rendered
128
+ switch_to_fallback(error)
129
+ flush_fallback(force: true)
130
+ return finish_fallback
131
+ end
132
+
133
+ raise unless stream_expired?(error)
134
+
135
+ unconfirmed = @last_appended[@last_flushed.length..] || ""
136
+ expired_ts = @segment.streamer.ts
137
+ if unconfirmed.empty?
138
+ raise error unless expired_ts
139
+
140
+ return build_message(
141
+ id: expired_ts,
142
+ response: nil,
143
+ message_ids: @finalized_message_ids + [expired_ts]
144
+ )
145
+ end
146
+
147
+ start_next_segment(@last_flushed)
148
+ @segment.streamer.stop(
149
+ markdown_text: with_segment_prefix(unconfirmed),
150
+ blocks: stop_blocks
151
+ )
152
+ end
153
+ message_id = response_value(result, :ts) || @segment.streamer.ts
154
+ build_message(
155
+ id: message_id,
156
+ response: result,
157
+ message_ids: @finalized_message_ids + [message_id]
158
+ )
159
+ end
160
+
161
+ def send_delta(delta, at_block_boundary:)
162
+ text = if rotation_due?(at_block_boundary || delta.include?("\n\n"))
163
+ rotate_segment(delta)
164
+ else
165
+ delta
166
+ end
167
+
168
+ unless text.empty?
169
+ response = @segment.streamer.append(
170
+ markdown_text: with_segment_prefix(text),
171
+ **(@segment.flush_next_append ? {chunks: []} : {})
172
+ )
173
+ if response
174
+ mark_segment_started
175
+ @last_flushed = @accumulated.dup
176
+ end
177
+ end
178
+ @last_appended = @accumulated.dup
179
+ end
180
+
181
+ def rotation_due?(at_block_boundary)
182
+ return false unless @segment.started_at
183
+
184
+ age = monotonic_now - @segment.started_at
185
+ age >= max_age && (at_block_boundary || age >= max_age + ROTATION_GRACE)
186
+ end
187
+
188
+ def rotate_segment(delta)
189
+ cut_at = segment_cut_index(delta)
190
+ head = delta[0...cut_at]
191
+ tail = delta[cut_at..] || ""
192
+ sent = @last_appended + head
193
+ fence = open_fence_in(sent)
194
+ if fence && sent.end_with?("\n") && closes_fence?(fence, tail)
195
+ head << tail
196
+ sent << tail
197
+ tail = ""
198
+ fence = nil
199
+ end
200
+ closer = if fence
201
+ prefix = sent.end_with?("\n") ? "" : "\n"
202
+ "#{prefix}#{fence[:marker]}"
203
+ else
204
+ ""
205
+ end
206
+ final_text = (head.empty? ? "" : with_segment_prefix(head)) + closer
207
+ age = @segment.started_at ? monotonic_now - @segment.started_at : 0
208
+ closing_tasks = if @structured_chunks_supported && !@open_tasks.empty?
209
+ @open_tasks.values.map { |task| task.with(status: "complete") }
210
+ end
211
+
212
+ begin
213
+ begin
214
+ response = @segment.streamer.stop(
215
+ markdown_text: final_text.empty? ? nil : final_text,
216
+ chunks: closing_tasks
217
+ )
218
+ rescue *slack_error_classes => error
219
+ code = platform_error_code(error)
220
+ raise if closing_tasks.nil? || code.nil? || code == STREAM_EXPIRED_ERROR || MAYBE_APPLIED_ERRORS.include?(code)
221
+
222
+ response = @segment.streamer.stop
223
+ disable_structured_chunks("task_update", error)
224
+ end
225
+ @last_flushed = sent
226
+ ChatSDK::Log.debug("Slack: rotated stream segment #{response_value(response, :ts)} after #{age}s")
227
+ rescue *stream_error_classes => error
228
+ raise unless stream_expired?(error)
229
+
230
+ ChatSDK::Log.warn("Slack: stream segment expired before rotation; continuing in a new message")
231
+ tail = (@last_appended[@last_flushed.length..] || "") + delta
232
+ sent = @last_flushed.dup
233
+ end
234
+
235
+ start_next_segment(sent)
236
+ tail
237
+ end
238
+
239
+ def start_next_segment(sent)
240
+ old_ts = @segment.streamer.ts
241
+ @finalized_message_ids << old_ts if old_ts
242
+ @segment = build_segment
243
+ @segment.flush_next_append = true
244
+ fence = open_fence_in(sent)
245
+ @segment.reopen_fence = "#{fence[:opening]}\n" if fence
246
+ @segment.table_header = table_continuation(sent) unless fence
247
+ @rotations += 1
248
+
249
+ replay = []
250
+ replay << @current_plan if @current_plan
251
+ replay.concat(@open_tasks.values)
252
+ return if replay.empty? || !@structured_chunks_supported
253
+
254
+ @segment.streamer.append(chunks: replay)
255
+ mark_segment_started
256
+ rescue *slack_error_classes => error
257
+ disable_structured_chunks("replay", error)
258
+ end
259
+
260
+ def with_segment_prefix(text)
261
+ first_line = text.split("\n", 2).first.to_s
262
+ header = TABLE_ROW_PATTERN.match?(first_line.strip) ? @segment.table_header : ""
263
+ prefixed = @segment.reopen_fence + header + text
264
+ @segment.reopen_fence = ""
265
+ @segment.table_header = ""
266
+ prefixed
267
+ end
268
+
269
+ def remember_structured_chunk(chunk)
270
+ case chunk
271
+ when ChatSDK::Streaming::PlanUpdateChunk
272
+ @current_plan = chunk
273
+ when ChatSDK::Streaming::TaskUpdateChunk
274
+ if %w[complete error].include?(chunk.status)
275
+ @open_tasks.delete(chunk.id)
276
+ else
277
+ @open_tasks[chunk.id] = chunk
278
+ end
279
+ end
280
+ end
281
+
282
+ def disable_structured_chunks(chunk_type, error)
283
+ @structured_chunks_supported = false
284
+ return if @structured_warning_sent
285
+
286
+ @structured_warning_sent = true
287
+ ChatSDK::Log.warn(
288
+ "Structured Slack streaming chunks are unavailable; continuing with text only. " \
289
+ "The Slack app requires the assistant:write scope (#{chunk_type}: #{error.message})"
290
+ )
291
+ end
292
+
293
+ def switch_to_fallback(error)
294
+ @mode = :fallback
295
+ code = platform_error_code(error)
296
+ if UNSUPPORTED_ERRORS.include?(code)
297
+ adapter.instance_variable_set(:@native_streaming_broken, true)
298
+ end
299
+ ChatSDK::Log.warn("Slack native streaming failed; using post-and-edit fallback: #{error.message}")
300
+ end
301
+
302
+ def flush_fallback(force:)
303
+ return if @accumulated.empty? || @accumulated == @fallback_sent
304
+
305
+ now = monotonic_now
306
+ interval = options.fetch(:update_interval, 0.5)
307
+ return unless force || @last_fallback_edit_at.nil? || now - @last_fallback_edit_at >= interval
308
+
309
+ if @fallback_message
310
+ adapter.edit_message(
311
+ channel_id: channel_id,
312
+ message_id: @fallback_message.id,
313
+ message: fallback_postable
314
+ )
315
+ elsif options[:placeholder]
316
+ @fallback_message = adapter.post_message(
317
+ channel_id: channel_id,
318
+ thread_id: thread_id,
319
+ message: ChatSDK::PostableMessage.new(
320
+ text: options[:placeholder],
321
+ ai_generated: options[:ai_generated] || false
322
+ )
323
+ )
324
+ adapter.edit_message(
325
+ channel_id: channel_id,
326
+ message_id: @fallback_message.id,
327
+ message: fallback_postable
328
+ )
329
+ else
330
+ @fallback_message = adapter.post_message(
331
+ channel_id: channel_id,
332
+ thread_id: thread_id,
333
+ message: fallback_postable
334
+ )
335
+ end
336
+ @fallback_sent = @accumulated.dup
337
+ @last_fallback_edit_at = now
338
+ end
339
+
340
+ def fallback_postable
341
+ ChatSDK::PostableMessage.new(text: @accumulated.dup, ai_generated: options[:ai_generated] || false)
342
+ end
343
+
344
+ def finish_fallback
345
+ warn_skipped_stop_blocks if options[:stop_blocks]
346
+ message_with_text(@fallback_message, @accumulated)
347
+ end
348
+
349
+ def message_with_text(message, text)
350
+ return unless message
351
+
352
+ ChatSDK::Message.new(
353
+ id: message.id,
354
+ text: text.dup,
355
+ author: message.author,
356
+ thread_id: message.thread_id,
357
+ channel_id: message.channel_id,
358
+ platform: message.platform,
359
+ attachments: message.attachments,
360
+ links: message.links,
361
+ reply_to: message.reply_to,
362
+ subject: message.subject,
363
+ raw: message.raw,
364
+ timestamp: message.timestamp,
365
+ is_mention: message.is_mention
366
+ )
367
+ end
368
+
369
+ def warn_skipped_stop_blocks
370
+ ChatSDK::Log.warn("Slack stream-end blocks were skipped by the post-and-edit fallback")
371
+ end
372
+
373
+ def build_message(id:, response:, message_ids:)
374
+ ChatSDK::Message.new(
375
+ id: id,
376
+ text: @accumulated.dup,
377
+ author: ChatSDK::Author.new(id: "bot", name: "bot", platform: :slack, bot: true),
378
+ thread_id: thread_id,
379
+ channel_id: channel_id,
380
+ platform: :slack,
381
+ raw: {
382
+ "ts" => id,
383
+ "message_ids" => message_ids,
384
+ "response" => response
385
+ }
386
+ )
387
+ end
388
+
389
+ def mark_segment_started
390
+ @segment.started_at ||= monotonic_now
391
+ @segment.flush_next_append = false
392
+ @native_rendered = true
393
+ end
394
+
395
+ def monotonic_now
396
+ adapter.send(:monotonic_now)
397
+ end
398
+
399
+ def stream_error_classes
400
+ slack_error_classes + [Faraday::Error, IOError, SocketError, Timeout::Error, ChatSDK::Error]
401
+ end
402
+
403
+ def slack_error_classes
404
+ [::Slack::Web::Api::Errors::SlackError]
405
+ end
406
+
407
+ def platform_error_code(error)
408
+ return unless error.is_a?(::Slack::Web::Api::Errors::SlackError)
409
+
410
+ error.error
411
+ rescue NoMethodError
412
+ nil
413
+ end
414
+
415
+ def stream_expired?(error)
416
+ platform_error_code(error) == STREAM_EXPIRED_ERROR
417
+ end
418
+
419
+ def response_value(response, key)
420
+ return unless response.respond_to?(:[])
421
+
422
+ response[key.to_s] || response[key] ||
423
+ (response["message"] || response[:message])&.then { |message| message[key.to_s] || message[key] }
424
+ end
425
+
426
+ def segment_cut_index(text)
427
+ paragraph_break = text.rindex("\n\n")
428
+ return paragraph_break + 2 if paragraph_break
429
+
430
+ (text.rindex("\n") || -1) + 1
431
+ end
432
+
433
+ def fence_tracker
434
+ {open: nil}
435
+ end
436
+
437
+ def feed_fence(tracker, line)
438
+ match = FENCE_PATTERN.match(line)
439
+ return false unless match
440
+
441
+ marker = match[1]
442
+ info = match[2]
443
+ if tracker[:open]
444
+ current = tracker[:open]
445
+ if marker[0] == current[:marker][0] && marker.length >= current[:marker].length && info.strip.empty?
446
+ tracker[:open] = nil
447
+ return true
448
+ end
449
+ return false
450
+ end
451
+ return false if marker.start_with?("`") && info.include?("`")
452
+
453
+ tracker[:open] = {marker: marker, opening: line.rstrip}
454
+ true
455
+ end
456
+
457
+ def open_fence_in(text)
458
+ tracker = fence_tracker
459
+ text.split("\n").each { |line| feed_fence(tracker, line) }
460
+ tracker[:open]
461
+ end
462
+
463
+ def closes_fence?(fence, line)
464
+ tracker = {open: fence}
465
+ feed_fence(tracker, line)
466
+ end
467
+
468
+ def table_continuation(text)
469
+ return "" unless text.end_with?("\n")
470
+
471
+ lines = text.split("\n", -1)
472
+ lines.pop
473
+ rows = []
474
+ lines.reverse_each do |line|
475
+ break unless TABLE_ROW_PATTERN.match?(line.strip)
476
+
477
+ rows.unshift(line)
478
+ end
479
+ separator_at = rows.index { |row| TABLE_SEPARATOR_PATTERN.match?(row.strip) }
480
+ return "" unless separator_at&.positive?
481
+
482
+ "#{rows[separator_at - 1]}\n#{rows[separator_at]}\n"
483
+ end
484
+ end
485
+ end
486
+ end
@@ -92,7 +92,7 @@ module ChatSDK
92
92
  # "interactive" -> payload is a block_actions / view_submission body
93
93
  # "slash_commands" -> payload is a slash command body
94
94
  events = EventParser.parse(payload)
95
- events.each { |event| block.call(event) }
95
+ events.each { |event| block.call(event, payload) }
96
96
  rescue JSON::ParserError
97
97
  # Ignore malformed frames (e.g. pong/control frames)
98
98
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ module ChatSDK
7
+ module Slack
8
+ module TokenEncryption
9
+ KEY_LENGTH = 32
10
+ IV_LENGTH = 12
11
+ TAG_LENGTH = 16
12
+ HEX_KEY_PATTERN = /\A[0-9a-fA-F]{64}\z/
13
+
14
+ module_function
15
+
16
+ def decode_key(raw)
17
+ trimmed = raw.to_s.strip
18
+ decoded = if HEX_KEY_PATTERN.match?(trimmed)
19
+ [trimmed].pack("H*")
20
+ else
21
+ begin
22
+ trimmed.unpack1("m0")
23
+ rescue ArgumentError
24
+ trimmed.unpack1("m")
25
+ end
26
+ end
27
+ return decoded if decoded.bytesize == KEY_LENGTH
28
+
29
+ raise ChatSDK::ConfigurationError,
30
+ "Encryption key must decode to exactly 32 bytes (received #{decoded.bytesize}). Use a 64-char hex string or 44-char base64 string."
31
+ end
32
+
33
+ def encrypt(plaintext, key)
34
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
35
+ cipher.encrypt
36
+ iv = SecureRandom.random_bytes(IV_LENGTH)
37
+ cipher.key = key
38
+ cipher.iv = iv
39
+ encrypted = cipher.update(plaintext) + cipher.final
40
+
41
+ {
42
+ "iv" => [iv].pack("m0"),
43
+ "data" => [encrypted].pack("m0"),
44
+ "tag" => [cipher.auth_tag(TAG_LENGTH)].pack("m0")
45
+ }
46
+ end
47
+
48
+ def decrypt(encrypted, key)
49
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
50
+ cipher.decrypt
51
+ cipher.key = key
52
+ iv = field(encrypted, "iv").unpack1("m0")
53
+ tag = field(encrypted, "tag").unpack1("m0")
54
+ raise ArgumentError, "Invalid AES-256-GCM token fields" unless iv.bytesize == IV_LENGTH && tag.bytesize == TAG_LENGTH
55
+
56
+ cipher.iv = iv
57
+ cipher.auth_tag = tag
58
+ (cipher.update(field(encrypted, "data").unpack1("m0")) + cipher.final).force_encoding(Encoding::UTF_8)
59
+ end
60
+
61
+ def encrypted?(value)
62
+ value.is_a?(Hash) &&
63
+ field(value, "iv").is_a?(String) &&
64
+ field(value, "data").is_a?(String) &&
65
+ field(value, "tag").is_a?(String)
66
+ end
67
+
68
+ def field(hash, key)
69
+ hash.key?(key) ? hash[key] : hash[key.to_sym]
70
+ end
71
+ private_class_method :field
72
+ end
73
+ end
74
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chat_sdk-slack
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quentin Rousseau
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '1.2'
18
+ version: '1.3'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '1.2'
25
+ version: '1.3'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: slack-ruby-client
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -47,10 +47,13 @@ files:
47
47
  - lib/chat_sdk/slack.rb
48
48
  - lib/chat_sdk/slack/adapter.rb
49
49
  - lib/chat_sdk/slack/block_kit_renderer.rb
50
+ - lib/chat_sdk/slack/chat_streamer.rb
50
51
  - lib/chat_sdk/slack/event_parser.rb
51
52
  - lib/chat_sdk/slack/format_converter.rb
52
53
  - lib/chat_sdk/slack/modal_renderer.rb
54
+ - lib/chat_sdk/slack/native_stream.rb
53
55
  - lib/chat_sdk/slack/socket_mode.rb
56
+ - lib/chat_sdk/slack/token_encryption.rb
54
57
  homepage: https://github.com/rootlyhq/chat-sdk
55
58
  licenses:
56
59
  - MIT