slackblocks 2.5.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,6 @@
1
+ # Generated from spec/model.json. Do not edit by hand.
2
+ module Slackblocks
3
+ module StyleFlags
4
+ NAMES = Value.deep_freeze(["bold", "client_highlight", "code", "highlight", "italic", "strike", "unlink"])
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ # Generated from spec/vocabulary.json. Do not edit by hand.
2
+ module Slackblocks
3
+ module Vocabulary
4
+ ICON_NAMES = Value.deep_freeze(["archive", "book", "bookmark", "bot", "bug", "calendar", "call", "caret-left", "caret-right", "check", "clipboard", "code", "comment", "compass", "copy", "cube", "download", "edit", "email", "eye-closed", "eye-open", "file", "flag", "folder", "gear", "globe", "heart", "help", "image", "info", "key", "lightbulb", "link", "map", "mobile", "new-window", "pin", "plus", "refine", "refresh", "rocket", "save", "screen", "share", "sparkle", "star", "star-filled", "tag", "thumbs-down", "thumbs-up", "trash", "upload", "user", "warning"])
5
+ SURFACE_BLOCK_TYPES = Value.deep_freeze({"home" => ["actions", "card", "carousel", "container", "context", "data_table", "data_visualization", "divider", "header", "image", "input", "rich_text", "section", "table", "video"], "message" => ["actions", "card", "carousel", "container", "context", "context_actions", "data_table", "data_visualization", "divider", "file", "header", "image", "input", "markdown", "plan", "rich_text", "section", "table", "task_card", "video"], "modal" => ["actions", "alert", "card", "context", "divider", "header", "image", "input", "rich_text", "section", "video"]})
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ module Slackblocks
2
+ class RichTextStyle
3
+ include Value
4
+
5
+ attr_reader(*StyleFlags::NAMES.map(&:to_sym), :additional_fields)
6
+ end
7
+ RichTextStyle.const_set(:WIRE_TYPE, "")
8
+ RichTextStyle.const_set(:FIELD_SPECS, Value.deep_freeze(StyleFlags::NAMES.map do |name|
9
+ {"name" => name, "wire" => name, "kind" => "boolean", "required" => false}
10
+ end))
11
+ RichTextStyle.const_set(:DEFAULTS, Value.deep_freeze({}))
12
+ RichTextStyle.singleton_class.prepend(KeywordOnly)
13
+ end
@@ -0,0 +1,18 @@
1
+ module Slackblocks
2
+ class ValidationError < StandardError
3
+ CATEGORIES = %w[
4
+ length-exceeded out-of-range mutually-exclusive
5
+ type-mismatch missing-required invalid-usage
6
+ ].freeze
7
+
8
+ attr_reader :category, :path
9
+
10
+ def initialize(category, path, message)
11
+ raise ArgumentError, "unknown validation category: #{category}" unless CATEGORIES.include?(category)
12
+
13
+ @category = category
14
+ @path = path
15
+ super("#{path}: #{message}")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,284 @@
1
+ module Slackblocks
2
+ module Validator
3
+ def self.validate(klass, values)
4
+ path = klass.name.split("::").last
5
+ wire = Value.wire(klass, values)
6
+ check_limits(wire["text"], "text", "#{path}.text") if klass == PlainText || klass == MarkdownText
7
+ klass::FIELD_SPECS.each do |field|
8
+ next if values[field.fetch("name").to_sym].nil?
9
+
10
+ check_limits(wire[field.fetch("wire")], field["limits"], "#{path}.#{field.fetch("wire")}")
11
+ end
12
+ special(klass, wire, path)
13
+ end
14
+
15
+ def self.limit(key)
16
+ Limits::VALUES.fetch(key)
17
+ end
18
+
19
+ def self.fail!(category, path, message)
20
+ Value.fail!(category, path, message)
21
+ end
22
+
23
+ def self.string_limit(value, key, path)
24
+ return unless value
25
+
26
+ fail!("length-exceeded", path, "exceeds #{key}") if value.length > limit(key)
27
+ end
28
+
29
+ def self.text_limit(value, key, path)
30
+ return unless value
31
+
32
+ string_limit(value.fetch("text"), key, path)
33
+ end
34
+
35
+ def self.max_items(items, key, path)
36
+ return unless items
37
+
38
+ fail!("length-exceeded", path, "exceeds #{key}") if items.length > limit(key)
39
+ end
40
+
41
+ def self.special(klass, wire, path)
42
+ case klass.name.split("::").last
43
+ when "SlackIcon"
44
+ unless Vocabulary::ICON_NAMES.include?(wire["name"])
45
+ fail!("type-mismatch", "#{path}.name", "unknown Slack icon")
46
+ end
47
+ when "Attachment"
48
+ if (color = wire["color"]) && !%w[good warning danger].include?(color) &&
49
+ !color.match?(/\A#[0-9a-fA-F]{6}\z/)
50
+ fail!("type-mismatch", "#{path}.color", "expected a six-digit hex color or alias")
51
+ end
52
+ when "SectionBlock"
53
+ fields = wire["fields"] || []
54
+ fail!("missing-required", path, "expected text or fields") if !wire.key?("text") && fields.empty?
55
+ fail!("missing-required", "#{path}.fields", "fields cannot be empty") if wire.key?("fields") && fields.empty?
56
+ fields.each_with_index do |field, index|
57
+ text_limit(field, "section.fields.item_max_length", "#{path}.fields[#{index}].text")
58
+ end
59
+ when "StaticSelectElement", "MultiStaticSelectElement"
60
+ if wire.key?("options") && wire.key?("option_groups")
61
+ fail!("mutually-exclusive", path, "options and option_groups cannot both be present")
62
+ end
63
+ when "CardBlock"
64
+ if !%w[hero_image title actions body].any? { |key| wire.key?(key) }
65
+ fail!("missing-required", path, "card needs content")
66
+ end
67
+ if wire.key?("icon") && wire.key?("slack_icon")
68
+ fail!("mutually-exclusive", path, "icon and slack_icon cannot both be present")
69
+ end
70
+ when "ContainerBlock"
71
+ unless wire.key?("title") || wire.key?("rich_text_title")
72
+ fail!("missing-required", path, "expected title")
73
+ end
74
+ if wire["default_collapsed"] && !wire["is_collapsible"]
75
+ fail!("invalid-usage", "#{path}.default_collapsed", "requires is_collapsible")
76
+ end
77
+ if wire["has_header_divider"] && wire["is_collapsible"]
78
+ fail!("invalid-usage", "#{path}.has_header_divider", "requires non-collapsible container")
79
+ end
80
+ when "ImageBlock", "ImageElement"
81
+ has_url, has_file = wire.key?("image_url"), wire.key?("slack_file")
82
+ fail!("missing-required", path, "expected an image source") unless has_url || has_file
83
+ fail!("mutually-exclusive", path, "expected one image source") if has_url && has_file
84
+ when "SlackFile"
85
+ if wire.key?("id") == wire.key?("url")
86
+ fail!("mutually-exclusive", path, "expected exactly one of id or url")
87
+ end
88
+ if wire["id"] && !wire["id"].match?(/\AF[A-Z0-9]{8,}\z/)
89
+ fail!("type-mismatch", "#{path}.id", "invalid Slack file ID")
90
+ end
91
+ when "ConversationFilter"
92
+ if wire.empty?
93
+ fail!("missing-required", path, "expected at least one filter field")
94
+ end
95
+ (wire["include"] || []).each_with_index do |item, index|
96
+ unless %w[im mpim private public].include?(item)
97
+ fail!("type-mismatch", "#{path}.include[#{index}]", "unknown conversation type")
98
+ end
99
+ end
100
+ when "DispatchActionConfiguration"
101
+ fail!("missing-required", "#{path}.trigger_actions_on", "required") unless wire.key?("trigger_actions_on")
102
+ when "NumberInputElement"
103
+ if wire["min_value"] && wire["max_value"] && wire["min_value"] > wire["max_value"]
104
+ fail!("out-of-range", path, "min_value exceeds max_value")
105
+ end
106
+ when "DataTableBlock", "TableBlock"
107
+ table(wire, path, klass == DataTableBlock)
108
+ when "AxisConfig"
109
+ (wire["categories"] || []).each_with_index do |category, index|
110
+ string_limit(category, "data_visualization.category_label.max_length", "#{path}.categories[#{index}]")
111
+ end
112
+ if wire["categories"] && wire["categories"].uniq.length != wire["categories"].length
113
+ fail!("invalid-usage", "#{path}.categories", "duplicate category")
114
+ end
115
+ when "ChartSegment"
116
+ if wire["value"] <= limit("data_visualization.segment.value.exclusive_min")
117
+ fail!("out-of-range", "#{path}.value", "expected positive value")
118
+ end
119
+ when "LineChart", "BarChart", "AreaChart"
120
+ chart(wire, path)
121
+ when "PlanBlock"
122
+ ids = wire.fetch("tasks").map { |task| task["task_id"] }
123
+ fail!("invalid-usage", "#{path}.tasks", "duplicate task ID") if ids.uniq.length != ids.length
124
+ when "MessagePayload", "MessageResponse", "WebhookMessage"
125
+ message(wire, path)
126
+ when "ModalView", "HomeTabView"
127
+ surface(wire.fetch("blocks"), (klass == ModalView) ? "modal" : "home", "#{path}.blocks")
128
+ if klass == ModalView && !wire.key?("submit") && wire.fetch("blocks").any? { |block| block["type"] == "input" }
129
+ fail!("missing-required", "#{path}.submit", "required when modal contains an input")
130
+ end
131
+ end
132
+ end
133
+
134
+ def self.surface(blocks, name, path)
135
+ allowed = Vocabulary::SURFACE_BLOCK_TYPES.fetch(name)
136
+ blocks.each_with_index do |block, index|
137
+ type = block["type"]
138
+ fail!("type-mismatch", "#{path}[#{index}].type", "unsupported #{name} block") unless allowed.include?(type)
139
+ standalone_task(block, "#{path}[#{index}]")
140
+ end
141
+ end
142
+
143
+ def self.standalone_task(value, path)
144
+ case value
145
+ when Hash
146
+ if value["type"] == "task_card" && value["status"] == "pending"
147
+ fail!("type-mismatch", "#{path}.status", "pending is plan-only")
148
+ end
149
+ value.each do |key, nested|
150
+ next if key == "tasks" && value["type"] == "plan"
151
+
152
+ standalone_task(nested, "#{path}.#{key}")
153
+ end
154
+ when Array
155
+ value.each_with_index { |item, index| standalone_task(item, "#{path}[#{index}]") }
156
+ end
157
+ end
158
+
159
+ def self.message(wire, path)
160
+ surface(wire["blocks"] || [], "message", "#{path}.blocks")
161
+ totals = {"markdown" => 0, "data_table" => 0}
162
+ tally(wire, totals)
163
+ if totals["markdown"] > limit("markdown.total_text.max_length")
164
+ fail!("length-exceeded", path, "message markdown total exceeded")
165
+ end
166
+ if totals["data_table"] > limit("data_table.total_content.max_length")
167
+ fail!("length-exceeded", path, "message data table total exceeded")
168
+ end
169
+ end
170
+
171
+ def self.tally(node, totals)
172
+ case node
173
+ when Hash
174
+ if node["type"] == "markdown"
175
+ totals["markdown"] += node.fetch("text").length
176
+ elsif node["type"] == "data_table"
177
+ totals["data_table"] += characters(node.fetch("rows"))
178
+ else
179
+ node.each_value { |child| tally(child, totals) }
180
+ end
181
+ when Array
182
+ node.each { |child| tally(child, totals) }
183
+ end
184
+ end
185
+
186
+ def self.characters(node)
187
+ case node
188
+ when Array
189
+ node.sum { |child| characters(child) }
190
+ when Hash
191
+ node.sum do |key, child|
192
+ (key == "text" && child.is_a?(String)) ? child.length : characters(child)
193
+ end
194
+ else
195
+ 0
196
+ end
197
+ end
198
+
199
+ def self.table(wire, path, data_table)
200
+ rows = wire.fetch("rows")
201
+ maximum = data_table ? "data_table.columns.max_items" : "table.columns.max_items"
202
+ rows.each_with_index do |row, index|
203
+ row_path = "#{path}.rows[#{index}]"
204
+ max_items(row, maximum, row_path)
205
+ if data_table
206
+ fail!("length-exceeded", row_path, "row cannot be empty") if row.empty?
207
+ if row.length != rows.first.length
208
+ fail!("invalid-usage", row_path, "data table rows must be rectangular")
209
+ end
210
+ row.each_with_index do |cell, column|
211
+ cell_path = "#{row_path}[#{column}]"
212
+ if index.zero? && cell["type"] == "rich_text"
213
+ fail!("type-mismatch", cell_path, "header must be raw")
214
+ end
215
+ if %w[raw_text raw_number].include?(cell["type"]) && cell.fetch("text").empty?
216
+ fail!("length-exceeded", "#{cell_path}.text", "empty cell")
217
+ end
218
+ end
219
+ end
220
+ end
221
+ if data_table
222
+ fail!("invalid-usage", "#{path}.column_settings", "unsupported") if wire.key?("column_settings")
223
+ if characters(rows) > limit("data_table.content.max_length")
224
+ fail!("length-exceeded", "#{path}.rows", "table content exceeded")
225
+ end
226
+ end
227
+ end
228
+
229
+ def self.chart(wire, path)
230
+ axis = wire.fetch("axis_config").fetch("categories")
231
+ # @type var names: Array[String]
232
+ names = []
233
+ wire.fetch("series").each_with_index do |series, index|
234
+ item_path = "#{path}.series[#{index}]"
235
+ names << series["name"]
236
+ labels = series.fetch("data").map { |point| point["label"] }
237
+ unless labels.length == axis.length && labels.sort == axis.sort
238
+ fail!("invalid-usage", "#{item_path}.data", "points must cover every category exactly once")
239
+ end
240
+ end
241
+ fail!("invalid-usage", "#{path}.series", "duplicate series name") if names.uniq.length != names.length
242
+ end
243
+
244
+ def self.check_limits(value, prefix, path)
245
+ return unless prefix
246
+
247
+ limits = Limits::VALUES
248
+ length = if value.is_a?(String)
249
+ value.length
250
+ elsif value.is_a?(Array)
251
+ value.length
252
+ elsif value.is_a?(Hash) && value["text"].is_a?(String)
253
+ value["text"].length
254
+ end
255
+ suffixes = value.is_a?(Array) ? %w[min_items max_items] : %w[min_length max_length]
256
+ if length
257
+ suffixes.each do |suffix|
258
+ bound = limits["#{prefix}.#{suffix}"]
259
+ next unless bound
260
+
261
+ if suffix.start_with?("min") && length < bound
262
+ fail!("length-exceeded", path, "expected at least #{bound}")
263
+ elsif suffix.start_with?("max") && length > bound
264
+ fail!("length-exceeded", path, "expected at most #{bound}")
265
+ end
266
+ end
267
+ end
268
+ if value.is_a?(Numeric)
269
+ %w[min max exclusive_min exclusive_max].each do |suffix|
270
+ bound = limits["#{prefix}.#{suffix}"]
271
+ next unless bound
272
+
273
+ invalid = case suffix
274
+ when "min" then value < bound
275
+ when "max" then value > bound
276
+ when "exclusive_min" then value <= bound
277
+ when "exclusive_max" then value >= bound
278
+ end
279
+ fail!("out-of-range", path, "outside #{suffix} #{bound}") if invalid
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,274 @@
1
+ require "json"
2
+
3
+ module Slackblocks
4
+ module KeywordOnly
5
+ def new(*args, **keywords)
6
+ raise ArgumentError, "use keyword arguments" unless args.empty?
7
+
8
+ super(**keywords)
9
+ end
10
+
11
+ def [](*args, **keywords)
12
+ new(*args, **keywords)
13
+ end
14
+ end
15
+
16
+ module Value
17
+ def self.deep_freeze(value)
18
+ case value
19
+ when String
20
+ value.freeze
21
+ when Array
22
+ value.each { |item| deep_freeze(item) }
23
+ value.freeze
24
+ when Hash
25
+ value.each { |key, item|
26
+ deep_freeze(key)
27
+ deep_freeze(item)
28
+ }
29
+ value.freeze
30
+ else
31
+ value
32
+ end
33
+ end
34
+
35
+ def self.fail!(category, path, message)
36
+ raise ValidationError.new(category, path, message)
37
+ end
38
+
39
+ def self.string(value, path)
40
+ fail!("type-mismatch", path, "expected a String") unless value.is_a?(String)
41
+ fail!("type-mismatch", path, "invalid string encoding") unless value.valid_encoding?
42
+
43
+ value.encode(Encoding::UTF_8).freeze
44
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
45
+ fail!("type-mismatch", path, "cannot transcode to UTF-8")
46
+ end
47
+
48
+ def self.json_value(value, path, seen = {})
49
+ case value
50
+ when nil, true, false, Integer
51
+ value
52
+ when String
53
+ string(value, path)
54
+ when Float
55
+ fail!("out-of-range", path, "number must be finite") unless value.finite?
56
+
57
+ value
58
+ when Value
59
+ value
60
+ when Array
61
+ fail!("type-mismatch", path, "cyclic array") if seen[value.object_id]
62
+
63
+ seen[value.object_id] = true
64
+ copy = value.each_with_index.map { |item, index| json_value(item, "#{path}[#{index}]", seen) }.freeze
65
+ seen.delete(value.object_id)
66
+ copy
67
+ when Hash
68
+ fail!("type-mismatch", path, "cyclic map") if seen[value.object_id]
69
+
70
+ seen[value.object_id] = true
71
+ # @type var copy: Hash[String, untyped]
72
+ copy = {}
73
+ value.each do |key, item|
74
+ fail!("type-mismatch", path, "map key must be a string or symbol") unless key.is_a?(String) || key.is_a?(Symbol)
75
+
76
+ normalized = string(key.to_s, path)
77
+ fail!("invalid-usage", "#{path}.#{normalized}", "duplicate key") if copy.key?(normalized)
78
+
79
+ copy[normalized] = json_value(item, "#{path}.#{normalized}", seen)
80
+ end
81
+ seen.delete(value.object_id)
82
+ copy.freeze
83
+ else
84
+ fail!("type-mismatch", path, "value cannot be represented as JSON")
85
+ end
86
+ end
87
+
88
+ def self.normalize_field(field, value, path)
89
+ kind = field.fetch("kind")
90
+ target = field["target"] && Slackblocks.const_get(field["target"], false)
91
+ case kind
92
+ when "string"
93
+ string(value, path)
94
+ when "boolean"
95
+ fail!("type-mismatch", path, "expected a boolean") unless value == true || value == false
96
+
97
+ value
98
+ when "int", "long"
99
+ fail!("type-mismatch", path, "expected an Integer") unless value.is_a?(Integer)
100
+
101
+ value
102
+ when "double", "number"
103
+ fail!("type-mismatch", path, "expected an Integer or Float") unless value.is_a?(Integer) || value.instance_of?(Float)
104
+ fail!("out-of-range", path, "number must be finite") if value.is_a?(Float) && !value.finite?
105
+
106
+ value
107
+ when "enum"
108
+ allowed = target.constants(false).map { |name| target.const_get(name, false) }
109
+ fail!("type-mismatch", path, "unknown enum value") unless value.is_a?(Symbol) && allowed.include?(value)
110
+
111
+ value
112
+ when "map"
113
+ fail!("type-mismatch", path, "expected a Hash") unless value.is_a?(Hash)
114
+
115
+ json_value(value, path)
116
+ when "style"
117
+ fail!("type-mismatch", path, "expected RichTextStyle") unless value.is_a?(RichTextStyle)
118
+
119
+ used = value.to_h.select { |name, flag| name != :additional_fields && !flag.nil? }.keys.map(&:to_s) + value.additional_fields.keys
120
+ fail!("type-mismatch", path, "unsupported style flag") unless (used - field.fetch("flags")).empty?
121
+
122
+ value
123
+ when "object"
124
+ fail!("type-mismatch", path, "expected #{target}") unless value.is_a?(target)
125
+
126
+ value
127
+ when "text"
128
+ return Slackblocks.const_get((field["coerce"] == "plain_text") ? "PlainText" : "MarkdownText", false).new(text: value) if value.is_a?(String)
129
+
130
+ fail!("type-mismatch", path, "expected text") unless value.is_a?(target)
131
+
132
+ value
133
+ when "list", "rows", "textList", "stringList"
134
+ fail!("type-mismatch", path, "expected an Array") unless value.is_a?(Array)
135
+
136
+ value.each_with_index.map do |item, index|
137
+ item_path = "#{path}[#{index}]"
138
+ if kind == "rows"
139
+ fail!("type-mismatch", item_path, "expected a row Array") unless item.is_a?(Array)
140
+
141
+ item.each_with_index.map do |cell, column|
142
+ fail!("type-mismatch", "#{item_path}[#{column}]", "wrong cell type") unless cell.is_a?(target)
143
+
144
+ cell
145
+ end.freeze
146
+ elsif kind == "textList"
147
+ normalize_field({"kind" => "text", "target" => field["target"], "coerce" => field["coerce"]}, item, item_path)
148
+ elsif kind == "stringList"
149
+ string(item, item_path)
150
+ else
151
+ fail!("type-mismatch", item_path, "wrong item type") unless item.is_a?(target)
152
+
153
+ item
154
+ end
155
+ end.freeze
156
+ else
157
+ raise "unhandled model field kind: #{kind}"
158
+ end
159
+ end
160
+
161
+ def self.construct(klass, keywords)
162
+ fields = klass::FIELD_SPECS
163
+ names = fields.map { |field| field.fetch("name").to_sym }
164
+ unknown = keywords.keys - names - [:additional_fields]
165
+ raise ArgumentError, "unknown keywords: #{unknown.inspect}" unless unknown.empty?
166
+
167
+ # @type var values: Hash[Symbol, untyped]
168
+ values = {}
169
+ fields.each do |field|
170
+ name = field.fetch("name")
171
+ symbol = name.to_sym
172
+ path = "#{klass.name.split("::").last}.#{field.fetch("wire")}"
173
+ value = if keywords.key?(symbol)
174
+ keywords[symbol]
175
+ else
176
+ default = klass::DEFAULTS.fetch(name, nil)
177
+ (field["kind"] == "enum" && default) ? default.to_sym : default
178
+ end
179
+ if value.nil?
180
+ fail!("missing-required", path, "required field is missing") if field["required"]
181
+ values[symbol] = nil
182
+ else
183
+ values[symbol] = normalize_field(field, value, path)
184
+ end
185
+ end
186
+ raw_extra = keywords.fetch(:additional_fields, nil)
187
+ if raw_extra.nil?
188
+ values[:additional_fields] = deep_freeze({})
189
+ else
190
+ fail!("type-mismatch", "#{klass.name}.additional_fields", "expected a Hash") unless raw_extra.is_a?(Hash)
191
+
192
+ normalized = json_value(raw_extra, "#{klass.name}.additional_fields")
193
+ reserved = fields.map { |field| field.fetch("wire") } + ["type"]
194
+ normalized.each_key do |key|
195
+ fail!("invalid-usage", "#{klass.name}.#{key}", "field is reserved") if key.empty? || reserved.include?(key)
196
+ end
197
+ values[:additional_fields] = normalized
198
+ end
199
+ Validator.validate(klass, values)
200
+ values
201
+ end
202
+
203
+ def self.detach(value)
204
+ case value
205
+ when Value
206
+ value.as_json
207
+ when Array
208
+ value.map { |item| detach(item) }
209
+ when Hash
210
+ value.to_h { |key, item| [key.dup, detach(item)] }
211
+ when String
212
+ value.dup
213
+ when Symbol
214
+ value.to_s
215
+ else
216
+ value
217
+ end
218
+ end
219
+
220
+ def self.wire(klass, values)
221
+ # @type var wire: Hash[String, untyped]
222
+ wire = {}
223
+ wire["type"] = klass::WIRE_TYPE.dup unless klass::WIRE_TYPE.empty?
224
+ klass::FIELD_SPECS.each do |field|
225
+ value = values[field.fetch("name").to_sym]
226
+ wire[field.fetch("wire").dup] = detach(value) unless value.nil?
227
+ end
228
+ values.fetch(:additional_fields).each { |key, value| wire[key.dup] = detach(value) }
229
+ if klass.name == "Slackblocks::PlanBlock" && wire.key?("tasks")
230
+ wire["tasks"].each { |task| task.delete("type") }
231
+ elsif klass.name == "Slackblocks::Attachment" && wire["color"].is_a?(String)
232
+ color = wire["color"]
233
+ wire["color"] = "##{color}" if color.match?(/\A[0-9a-fA-F]{6}\z/)
234
+ end
235
+ wire
236
+ end
237
+
238
+ def initialize(**keywords)
239
+ Value.construct(self.class, keywords).each do |name, value|
240
+ instance_variable_set("@#{name}", value)
241
+ end
242
+ freeze
243
+ end
244
+
245
+ def to_h
246
+ self.class.const_get(:FIELD_SPECS).to_h do |field|
247
+ name = field.fetch("name").to_sym
248
+ [name, public_send(name)]
249
+ end.merge(additional_fields: additional_fields)
250
+ end
251
+
252
+ def with(**changes)
253
+ changes.empty? ? self : self.class.public_send(:new, **to_h.merge(changes))
254
+ end
255
+
256
+ def ==(other)
257
+ other.instance_of?(self.class) && to_h == other.to_h
258
+ end
259
+
260
+ alias_method :eql?, :==
261
+
262
+ def hash
263
+ [self.class, to_h].hash
264
+ end
265
+
266
+ def as_json(_options = nil)
267
+ Value.wire(self.class, to_h)
268
+ end
269
+
270
+ def to_json(options = nil)
271
+ options.nil? ? JSON.generate(as_json) : JSON.generate(as_json, options)
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,4 @@
1
+ module Slackblocks
2
+ VERSION = "2.5.0"
3
+ SPEC_VERSION = "1.2.0"
4
+ end
@@ -0,0 +1,11 @@
1
+ require "json"
2
+ require_relative "slackblocks/version"
3
+ require_relative "slackblocks/validation_error"
4
+ require_relative "slackblocks/value"
5
+ require_relative "slackblocks/validator"
6
+ require_relative "slackblocks/generated/style_flags"
7
+ require_relative "slackblocks/rich_text_style"
8
+ require_relative "slackblocks/generated/limits"
9
+ require_relative "slackblocks/generated/vocabulary"
10
+ require_relative "slackblocks/generated/models"
11
+ require_relative "slackblocks/components"