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.
- checksums.yaml +4 -4
- data/lib/chat_sdk/slack/adapter.rb +300 -64
- data/lib/chat_sdk/slack/block_kit_renderer.rb +57 -4
- data/lib/chat_sdk/slack/chat_streamer.rb +124 -0
- data/lib/chat_sdk/slack/event_parser.rb +53 -0
- data/lib/chat_sdk/slack/modal_renderer.rb +56 -0
- data/lib/chat_sdk/slack/native_stream.rb +486 -0
- data/lib/chat_sdk/slack/socket_mode.rb +1 -1
- data/lib/chat_sdk/slack/token_encryption.rb +74 -0
- metadata +6 -3
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|