slk 0.1.0 → 0.2.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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +5 -5
  4. data/bin/slk +3 -3
  5. data/lib/{slack_cli → slk}/api/activity.rb +10 -11
  6. data/lib/{slack_cli → slk}/api/bots.rb +5 -4
  7. data/lib/slk/api/client.rb +51 -0
  8. data/lib/{slack_cli → slk}/api/conversations.rb +14 -13
  9. data/lib/slk/api/dnd.rb +41 -0
  10. data/lib/{slack_cli → slk}/api/emoji.rb +4 -3
  11. data/lib/{slack_cli → slk}/api/threads.rb +13 -12
  12. data/lib/{slack_cli → slk}/api/usergroups.rb +2 -1
  13. data/lib/slk/api/users.rb +105 -0
  14. data/lib/slk/cli.rb +157 -0
  15. data/lib/slk/commands/activity.rb +152 -0
  16. data/lib/{slack_cli → slk}/commands/base.rb +67 -41
  17. data/lib/slk/commands/cache.rb +141 -0
  18. data/lib/slk/commands/catchup.rb +411 -0
  19. data/lib/slk/commands/config.rb +114 -0
  20. data/lib/slk/commands/dnd.rb +172 -0
  21. data/lib/slk/commands/emoji.rb +352 -0
  22. data/lib/slk/commands/help.rb +97 -0
  23. data/lib/slk/commands/messages.rb +299 -0
  24. data/lib/slk/commands/presence.rb +109 -0
  25. data/lib/slk/commands/preset.rb +231 -0
  26. data/lib/slk/commands/status.rb +223 -0
  27. data/lib/slk/commands/thread.rb +72 -0
  28. data/lib/slk/commands/unread.rb +305 -0
  29. data/lib/slk/commands/workspaces.rb +168 -0
  30. data/lib/slk/formatters/activity_formatter.rb +148 -0
  31. data/lib/slk/formatters/attachment_formatter.rb +65 -0
  32. data/lib/slk/formatters/block_formatter.rb +57 -0
  33. data/lib/{slack_cli → slk}/formatters/duration_formatter.rb +6 -5
  34. data/lib/slk/formatters/emoji_replacer.rb +141 -0
  35. data/lib/slk/formatters/json_message_formatter.rb +95 -0
  36. data/lib/slk/formatters/mention_replacer.rb +158 -0
  37. data/lib/slk/formatters/message_formatter.rb +174 -0
  38. data/lib/{slack_cli → slk}/formatters/output.rb +7 -6
  39. data/lib/slk/formatters/reaction_formatter.rb +87 -0
  40. data/lib/{slack_cli → slk}/models/channel.rb +12 -10
  41. data/lib/slk/models/duration.rb +94 -0
  42. data/lib/slk/models/message.rb +242 -0
  43. data/lib/slk/models/preset.rb +78 -0
  44. data/lib/{slack_cli → slk}/models/reaction.rb +6 -6
  45. data/lib/{slack_cli → slk}/models/status.rb +6 -6
  46. data/lib/slk/models/user.rb +55 -0
  47. data/lib/slk/models/workspace.rb +54 -0
  48. data/lib/{slack_cli → slk}/runner.rb +22 -19
  49. data/lib/slk/services/activity_enricher.rb +124 -0
  50. data/lib/slk/services/api_client.rb +145 -0
  51. data/lib/{slack_cli → slk}/services/cache_store.rb +20 -17
  52. data/lib/{slack_cli → slk}/services/configuration.rb +9 -8
  53. data/lib/slk/services/emoji_downloader.rb +103 -0
  54. data/lib/slk/services/emoji_searcher.rb +72 -0
  55. data/lib/{slack_cli → slk}/services/encryption.rb +11 -14
  56. data/lib/slk/services/gemoji_sync.rb +97 -0
  57. data/lib/{slack_cli → slk}/services/preset_store.rb +34 -33
  58. data/lib/slk/services/reaction_enricher.rb +82 -0
  59. data/lib/slk/services/setup_wizard.rb +131 -0
  60. data/lib/slk/services/target_resolver.rb +108 -0
  61. data/lib/{slack_cli → slk}/services/token_store.rb +11 -10
  62. data/lib/slk/services/unread_marker.rb +101 -0
  63. data/lib/{slack_cli → slk}/support/error_logger.rb +2 -1
  64. data/lib/{slack_cli → slk}/support/help_formatter.rb +36 -44
  65. data/lib/{slack_cli → slk}/support/inline_images.rb +28 -19
  66. data/lib/slk/support/interactive_prompt.rb +29 -0
  67. data/lib/{slack_cli → slk}/support/slack_url_parser.rb +15 -17
  68. data/lib/slk/support/text_wrapper.rb +57 -0
  69. data/lib/slk/support/user_resolver.rb +141 -0
  70. data/lib/{slack_cli → slk}/support/xdg_paths.rb +6 -5
  71. data/lib/slk/version.rb +5 -0
  72. data/lib/slk.rb +112 -0
  73. metadata +80 -59
  74. data/lib/slack_cli/api/client.rb +0 -49
  75. data/lib/slack_cli/api/dnd.rb +0 -40
  76. data/lib/slack_cli/api/users.rb +0 -101
  77. data/lib/slack_cli/cli.rb +0 -118
  78. data/lib/slack_cli/commands/activity.rb +0 -292
  79. data/lib/slack_cli/commands/cache.rb +0 -116
  80. data/lib/slack_cli/commands/catchup.rb +0 -484
  81. data/lib/slack_cli/commands/config.rb +0 -159
  82. data/lib/slack_cli/commands/dnd.rb +0 -143
  83. data/lib/slack_cli/commands/emoji.rb +0 -412
  84. data/lib/slack_cli/commands/help.rb +0 -76
  85. data/lib/slack_cli/commands/messages.rb +0 -317
  86. data/lib/slack_cli/commands/presence.rb +0 -107
  87. data/lib/slack_cli/commands/preset.rb +0 -239
  88. data/lib/slack_cli/commands/status.rb +0 -194
  89. data/lib/slack_cli/commands/thread.rb +0 -62
  90. data/lib/slack_cli/commands/unread.rb +0 -312
  91. data/lib/slack_cli/commands/workspaces.rb +0 -151
  92. data/lib/slack_cli/formatters/emoji_replacer.rb +0 -143
  93. data/lib/slack_cli/formatters/mention_replacer.rb +0 -154
  94. data/lib/slack_cli/formatters/message_formatter.rb +0 -429
  95. data/lib/slack_cli/models/duration.rb +0 -85
  96. data/lib/slack_cli/models/message.rb +0 -217
  97. data/lib/slack_cli/models/preset.rb +0 -73
  98. data/lib/slack_cli/models/user.rb +0 -56
  99. data/lib/slack_cli/models/workspace.rb +0 -52
  100. data/lib/slack_cli/services/api_client.rb +0 -149
  101. data/lib/slack_cli/services/reaction_enricher.rb +0 -87
  102. data/lib/slack_cli/support/user_resolver.rb +0 -114
  103. data/lib/slack_cli/version.rb +0 -5
  104. data/lib/slack_cli.rb +0 -91
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # Duration unit multipliers (seconds per unit)
6
+ DURATION_UNITS = { 'h' => 3600, 'm' => 60, 's' => 1 }.freeze
7
+
8
+ Duration = Data.define(:seconds) do
9
+ class << self
10
+ def parse(input)
11
+ return new(seconds: 0) if input.nil? || input.to_s.strip.empty?
12
+ return new(seconds: input.to_i) if input.to_s.match?(/^\d+$/)
13
+
14
+ parse_duration_string(input.to_s.downcase, input)
15
+ end
16
+
17
+ def zero = new(seconds: 0)
18
+
19
+ def from_minutes(minutes)
20
+ new(seconds: minutes.to_i * 60)
21
+ end
22
+
23
+ private
24
+
25
+ def parse_duration_string(str, original)
26
+ validate_no_duplicate_units(str, original)
27
+ total = calculate_total_seconds(str)
28
+ raise ArgumentError, "Invalid duration format: #{original}" if total.zero? && !str.match?(/^0/)
29
+
30
+ new(seconds: total)
31
+ end
32
+
33
+ def validate_no_duplicate_units(str, original)
34
+ DURATION_UNITS.each_key do |unit|
35
+ next unless str.scan(/\d+#{unit}/).length > 1
36
+
37
+ raise ArgumentError, "Duplicate '#{unit}' unit in duration: #{original}"
38
+ end
39
+ end
40
+
41
+ def calculate_total_seconds(str)
42
+ DURATION_UNITS.sum do |unit, multiplier|
43
+ (match = str.match(/(\d+)#{unit}/)) ? match[1].to_i * multiplier : 0
44
+ end
45
+ end
46
+ end
47
+
48
+ def zero? = seconds.zero?
49
+
50
+ def to_minutes = (seconds / 60.0).ceil
51
+
52
+ def to_expiration
53
+ return 0 if zero?
54
+
55
+ Time.now.to_i + seconds
56
+ end
57
+
58
+ def to_s
59
+ return '' if zero?
60
+
61
+ format_duration
62
+ end
63
+
64
+ def +(other)
65
+ Duration.new(seconds: seconds + other.seconds)
66
+ end
67
+
68
+ def -(other)
69
+ Duration.new(seconds: [seconds - other.seconds, 0].max)
70
+ end
71
+
72
+ private
73
+
74
+ def format_duration
75
+ parts = []
76
+ remaining = seconds
77
+
78
+ _hours, remaining = extract_unit(remaining, 3600, 'h', parts)
79
+ _minutes, remaining = extract_unit(remaining, 60, 'm', parts)
80
+ parts << "#{remaining}s" if remaining.positive? && parts.empty?
81
+
82
+ parts.join
83
+ end
84
+
85
+ def extract_unit(remaining, divisor, suffix, parts)
86
+ return [0, remaining] if remaining < divisor
87
+
88
+ value = remaining / divisor
89
+ parts << "#{value}#{suffix}"
90
+ [value, remaining % divisor]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ModuleLength
4
+ module Slk
5
+ module Models
6
+ # Minimum text length before we extract content from Block Kit blocks.
7
+ # Slack sometimes sends minimal text (like a link preview) with the full
8
+ # content in blocks. 20 chars catches most of these cases without
9
+ # unnecessarily processing blocks for normal messages.
10
+ MESSAGE_BLOCK_TEXT_THRESHOLD = 20
11
+
12
+ Message = Data.define(
13
+ :ts,
14
+ :user_id,
15
+ :text,
16
+ :reactions,
17
+ :reply_count,
18
+ :thread_ts,
19
+ :files,
20
+ :attachments,
21
+ :blocks,
22
+ :user_profile,
23
+ :bot_profile,
24
+ :username,
25
+ :subtype,
26
+ :channel_id
27
+ ) do
28
+ def self.from_api(data, channel_id: nil)
29
+ text = extract_message_text(data)
30
+ new(**build_attributes(data, text, channel_id))
31
+ end
32
+
33
+ def self.extract_message_text(data)
34
+ text = data['text'] || ''
35
+ blocks = data['blocks'] || []
36
+
37
+ return text if text.length >= MESSAGE_BLOCK_TEXT_THRESHOLD
38
+
39
+ blocks_text = extract_block_text(blocks)
40
+ blocks_text.empty? ? text : blocks_text
41
+ end
42
+
43
+ # rubocop:disable Metrics/MethodLength
44
+ def self.build_attributes(data, text, channel_id)
45
+ {
46
+ ts: data['ts'],
47
+ user_id: data['user'] || data['bot_id'] || data['username'],
48
+ text: text,
49
+ reactions: parse_reactions(data['reactions']),
50
+ reply_count: data['reply_count'] || 0,
51
+ thread_ts: data['thread_ts'],
52
+ files: data['files'] || [],
53
+ attachments: data['attachments'] || [],
54
+ blocks: data['blocks'] || [],
55
+ user_profile: data['user_profile'],
56
+ bot_profile: data['bot_profile'],
57
+ username: data['username'],
58
+ subtype: data['subtype'],
59
+ channel_id: channel_id
60
+ }
61
+ end
62
+ # rubocop:enable Metrics/MethodLength
63
+
64
+ def self.parse_reactions(reactions_data)
65
+ (reactions_data || []).map { |r| Reaction.from_api(r) }
66
+ end
67
+
68
+ def self.extract_block_text(blocks)
69
+ return '' unless blocks.is_a?(Array)
70
+
71
+ blocks.filter_map do |block|
72
+ case block['type']
73
+ when 'section'
74
+ block.dig('text', 'text')
75
+ when 'rich_text'
76
+ extract_rich_text_content(block['elements'])
77
+ end
78
+ end.join("\n")
79
+ end
80
+
81
+ def self.extract_rich_text_content(elements)
82
+ return '' unless elements.is_a?(Array)
83
+
84
+ elements.filter_map do |element|
85
+ next unless element['elements'].is_a?(Array)
86
+
87
+ element['elements'].filter_map do |item|
88
+ item['text'] if item['type'] == 'text'
89
+ end.join
90
+ end.join
91
+ end
92
+
93
+ # rubocop:disable Metrics/ParameterLists, Naming/MethodParameterName
94
+ def initialize(
95
+ ts:,
96
+ user_id:,
97
+ text: '',
98
+ reactions: [],
99
+ reply_count: 0,
100
+ thread_ts: nil,
101
+ files: [],
102
+ attachments: [],
103
+ blocks: [],
104
+ user_profile: nil,
105
+ bot_profile: nil,
106
+ username: nil,
107
+ subtype: nil,
108
+ channel_id: nil
109
+ )
110
+ validate_required_fields!(ts, user_id)
111
+ super(**freeze_attributes(
112
+ ts: ts, user_id: user_id, text: text, reactions: reactions,
113
+ reply_count: reply_count, thread_ts: thread_ts, files: files,
114
+ attachments: attachments, blocks: blocks, user_profile: user_profile,
115
+ bot_profile: bot_profile, username: username, subtype: subtype, channel_id: channel_id
116
+ ))
117
+ end
118
+ # rubocop:enable Metrics/ParameterLists, Naming/MethodParameterName
119
+
120
+ def validate_required_fields!(timestamp, user)
121
+ ts_str = timestamp.to_s.strip
122
+ user_id_str = user.to_s.strip
123
+
124
+ raise ArgumentError, 'ts cannot be empty' if ts_str.empty?
125
+ raise ArgumentError, 'user_id cannot be empty' if user_id_str.empty?
126
+ end
127
+
128
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
129
+ def freeze_attributes(attrs)
130
+ {
131
+ ts: attrs[:ts].to_s.strip.freeze,
132
+ user_id: attrs[:user_id].to_s.strip.freeze,
133
+ text: attrs[:text].to_s.freeze,
134
+ reactions: attrs[:reactions].freeze,
135
+ reply_count: attrs[:reply_count].to_i,
136
+ thread_ts: attrs[:thread_ts]&.freeze,
137
+ files: deep_freeze(attrs[:files]),
138
+ attachments: deep_freeze(attrs[:attachments]),
139
+ blocks: deep_freeze(attrs[:blocks]),
140
+ user_profile: deep_freeze(attrs[:user_profile]),
141
+ bot_profile: deep_freeze(attrs[:bot_profile]),
142
+ username: attrs[:username]&.freeze,
143
+ subtype: attrs[:subtype]&.freeze,
144
+ channel_id: attrs[:channel_id]&.freeze
145
+ }
146
+ end
147
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
148
+
149
+ # Recursively freeze nested structures (arrays and hashes)
150
+ def self.deep_freeze(obj)
151
+ case obj
152
+ when Hash then freeze_hash(obj)
153
+ when Array then freeze_array(obj)
154
+ else obj.freeze if obj.respond_to?(:freeze)
155
+ end
156
+ obj
157
+ end
158
+
159
+ def self.freeze_hash(hash)
160
+ hash.each_value { |v| deep_freeze(v) }
161
+ hash.freeze
162
+ end
163
+
164
+ def self.freeze_array(array)
165
+ array.each { |v| deep_freeze(v) }
166
+ array.freeze
167
+ end
168
+
169
+ private_class_method :deep_freeze, :freeze_hash, :freeze_array
170
+
171
+ # Instance method delegate to class method for use in initialize
172
+ def deep_freeze(obj)
173
+ self.class.send(:deep_freeze, obj)
174
+ end
175
+
176
+ def timestamp
177
+ Time.at(ts.to_f)
178
+ end
179
+
180
+ def thread?
181
+ reply_count.positive?
182
+ end
183
+
184
+ def reply?
185
+ thread_ts && thread_ts != ts
186
+ end
187
+
188
+ def reactions?
189
+ !reactions.empty?
190
+ end
191
+
192
+ def files?
193
+ !files.empty?
194
+ end
195
+
196
+ def blocks?
197
+ !blocks.empty?
198
+ end
199
+
200
+ def embedded_username
201
+ username_from_user_profile || username_from_bot_profile || fallback_username
202
+ end
203
+
204
+ def username_from_user_profile
205
+ return nil unless user_profile
206
+
207
+ display = user_profile['display_name']
208
+ return display unless display.to_s.empty?
209
+
210
+ real = user_profile['real_name']
211
+ return real unless real.to_s.empty?
212
+
213
+ nil
214
+ end
215
+
216
+ def username_from_bot_profile
217
+ return nil unless bot_profile
218
+
219
+ name = bot_profile['name']
220
+ name.to_s.empty? ? nil : name
221
+ end
222
+
223
+ def fallback_username
224
+ username.to_s.empty? ? nil : username
225
+ end
226
+
227
+ def bot?
228
+ user_id.start_with?('B') || subtype == 'bot_message'
229
+ end
230
+
231
+ def system_message?
232
+ %w[channel_join channel_leave channel_topic channel_purpose].include?(subtype)
233
+ end
234
+
235
+ # Create a copy of this message with updated reactions
236
+ def with_reactions(new_reactions)
237
+ Message.new(**to_h, reactions: new_reactions)
238
+ end
239
+ end
240
+ end
241
+ end
242
+ # rubocop:enable Metrics/ModuleLength
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ Preset = Data.define(:name, :text, :emoji, :duration, :presence, :dnd) do
6
+ def self.from_hash(name, data)
7
+ new(
8
+ name: name,
9
+ text: data['text'] || '',
10
+ emoji: data['emoji'] || '',
11
+ duration: data['duration'] || '0',
12
+ presence: data['presence'] || '',
13
+ dnd: data['dnd'] || ''
14
+ )
15
+ end
16
+
17
+ # rubocop:disable Metrics/ParameterLists
18
+ def initialize(name:, text: '', emoji: '', duration: '0', presence: '', dnd: '')
19
+ validate_name!(name)
20
+ validate_duration!(duration)
21
+
22
+ super(
23
+ name: name.to_s.strip.freeze,
24
+ text: text.to_s.freeze, emoji: emoji.to_s.freeze, duration: duration.to_s.freeze,
25
+ presence: presence.to_s.freeze, dnd: dnd.to_s.freeze
26
+ )
27
+ end
28
+ # rubocop:enable Metrics/ParameterLists
29
+
30
+ def to_h
31
+ { 'text' => text, 'emoji' => emoji, 'duration' => duration, 'presence' => presence, 'dnd' => dnd }
32
+ end
33
+
34
+ def duration_value
35
+ Duration.parse(duration)
36
+ end
37
+
38
+ def sets_presence?
39
+ !presence.empty?
40
+ end
41
+
42
+ def sets_dnd?
43
+ !dnd.empty?
44
+ end
45
+
46
+ def clears_status?
47
+ text.empty? && emoji.empty?
48
+ end
49
+
50
+ def to_s
51
+ "#{name}: #{build_parts.join(' ')}"
52
+ end
53
+
54
+ private
55
+
56
+ def validate_name!(name)
57
+ raise ArgumentError, 'preset name cannot be empty' if name.to_s.strip.empty?
58
+ end
59
+
60
+ def validate_duration!(duration)
61
+ duration_str = duration.to_s
62
+ Duration.parse(duration_str) unless duration_str.empty? || duration_str == '0'
63
+ end
64
+
65
+ # rubocop:disable Metrics/AbcSize
66
+ def build_parts
67
+ parts = []
68
+ parts << emoji unless emoji.empty?
69
+ parts << "\"#{text}\"" unless text.empty?
70
+ parts << "(#{duration})" unless duration == '0' || duration.empty?
71
+ parts << "[#{presence}]" if sets_presence?
72
+ parts << "{dnd: #{dnd}}" if sets_dnd?
73
+ parts
74
+ end
75
+ # rubocop:enable Metrics/AbcSize
76
+ end
77
+ end
78
+ end
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SlackCli
3
+ module Slk
4
4
  module Models
5
5
  Reaction = Data.define(:name, :count, :users, :timestamps) do
6
6
  def self.from_api(data)
7
7
  new(
8
- name: data["name"],
9
- count: data["count"] || 0,
10
- users: data["users"] || [],
11
- timestamps: nil # Will be populated by ReactionEnricher
8
+ name: data['name'],
9
+ count: data['count'] || 0,
10
+ users: data['users'] || [],
11
+ timestamps: nil # Will be populated by ReactionEnricher
12
12
  )
13
13
  end
14
14
 
@@ -34,7 +34,7 @@ module SlackCli
34
34
  )
35
35
  end
36
36
 
37
- def has_timestamps?
37
+ def timestamps?
38
38
  !timestamps.nil? && !timestamps.empty?
39
39
  end
40
40
 
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SlackCli
3
+ module Slk
4
4
  module Models
5
5
  Status = Data.define(:text, :emoji, :expiration) do
6
- def initialize(text: "", emoji: "", expiration: 0)
6
+ def initialize(text: '', emoji: '', expiration: 0)
7
7
  exp_val = expiration.to_i
8
8
  exp_val = 0 if exp_val.negative? # Normalize invalid negative expirations
9
9
 
@@ -19,7 +19,7 @@ module SlackCli
19
19
  end
20
20
 
21
21
  def expires?
22
- expiration > 0
22
+ expiration.positive?
23
23
  end
24
24
 
25
25
  def expired?
@@ -30,7 +30,7 @@ module SlackCli
30
30
  return nil unless expires?
31
31
 
32
32
  remaining = expiration - Time.now.to_i
33
- remaining > 0 ? Duration.new(seconds: remaining) : nil
33
+ remaining.positive? ? Duration.new(seconds: remaining) : nil
34
34
  end
35
35
 
36
36
  def expiration_time
@@ -40,7 +40,7 @@ module SlackCli
40
40
  end
41
41
 
42
42
  def to_s
43
- return "(no status)" if empty?
43
+ return '(no status)' if empty?
44
44
 
45
45
  parts = []
46
46
  parts << emoji unless emoji.empty?
@@ -50,7 +50,7 @@ module SlackCli
50
50
  parts << "(#{remaining})"
51
51
  end
52
52
 
53
- parts.join(" ")
53
+ parts.join(' ')
54
54
  end
55
55
  end
56
56
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # Slack user IDs start with U or W (enterprise grid)
6
+ USER_ID_PATTERN = /\A[UW][A-Z0-9]+\z/
7
+
8
+ User = Data.define(:id, :name, :real_name, :display_name, :is_bot) do
9
+ def self.from_api(data)
10
+ profile = data['profile'] || {}
11
+
12
+ new(
13
+ id: data['id'],
14
+ name: data['name'],
15
+ real_name: profile['real_name'] || data['real_name'],
16
+ display_name: profile['display_name'] || profile['display_name_normalized'],
17
+ is_bot: data['is_bot'] || false
18
+ )
19
+ end
20
+
21
+ def initialize(id:, name: nil, real_name: nil, display_name: nil, is_bot: false)
22
+ id_str = id.to_s.strip
23
+ validate_id!(id_str)
24
+
25
+ super(
26
+ id: id_str.freeze, name: name&.freeze, real_name: real_name&.freeze,
27
+ display_name: display_name&.freeze, is_bot: is_bot
28
+ )
29
+ end
30
+
31
+ def validate_id!(id_str)
32
+ raise ArgumentError, 'user id cannot be empty' if id_str.empty?
33
+ return if id_str.match?(USER_ID_PATTERN)
34
+
35
+ raise ArgumentError, "invalid user id format: #{id_str} (expected U or W prefix)"
36
+ end
37
+
38
+ def best_name
39
+ return display_name unless display_name.to_s.empty?
40
+ return real_name unless real_name.to_s.empty?
41
+ return name unless name.to_s.empty?
42
+
43
+ id
44
+ end
45
+
46
+ def mention
47
+ "@#{best_name}"
48
+ end
49
+
50
+ def to_s
51
+ best_name
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # Valid token prefixes for Slack tokens
6
+ VALID_TOKEN_PREFIXES = %w[xoxb- xoxc- xoxp-].freeze
7
+
8
+ Workspace = Data.define(:name, :token, :cookie) do
9
+ def initialize(name:, token:, cookie: nil)
10
+ name_str = name.to_s.strip
11
+ token_str = token.to_s
12
+ cookie_str = cookie&.to_s
13
+
14
+ validate_name!(name_str)
15
+ validate_token!(token_str)
16
+ validate_cookie!(token_str, cookie_str)
17
+
18
+ super(name: name_str.freeze, token: token_str.freeze, cookie: cookie_str&.freeze)
19
+ end
20
+
21
+ def xoxc? = token.start_with?('xoxc-')
22
+ def xoxb? = token.start_with?('xoxb-')
23
+ def xoxp? = token.start_with?('xoxp-')
24
+
25
+ def to_s = name
26
+
27
+ def headers
28
+ h = { 'Authorization' => "Bearer #{token}", 'Content-Type' => 'application/json; charset=utf-8' }
29
+ h['Cookie'] = "d=#{cookie}" if cookie
30
+ h
31
+ end
32
+
33
+ private
34
+
35
+ def validate_name!(name_str)
36
+ raise ArgumentError, 'workspace name cannot be empty' if name_str.empty?
37
+ raise ArgumentError, 'workspace name contains invalid characters' if name_str.match?(%r{[/\\]})
38
+ end
39
+
40
+ def validate_token!(token_str)
41
+ return if VALID_TOKEN_PREFIXES.any? { |prefix| token_str.start_with?(prefix) }
42
+
43
+ raise ArgumentError, 'invalid token format (must start with xoxb-, xoxc-, or xoxp-)'
44
+ end
45
+
46
+ def validate_cookie!(token_str, cookie_str)
47
+ if token_str.start_with?('xoxc-') && (cookie_str.nil? || cookie_str.strip.empty?)
48
+ raise ArgumentError, 'xoxc tokens require a cookie'
49
+ end
50
+ raise ArgumentError, 'cookie cannot contain newlines' if cookie_str&.match?(/[\r\n]/)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SlackCli
3
+ module Slk
4
+ # Dependency injection container providing services to commands
4
5
  class Runner
5
6
  attr_reader :output, :config, :token_store, :api_client, :cache_store, :preset_store
6
7
 
8
+ # rubocop:disable Metrics/ParameterLists
7
9
  def initialize(
8
10
  output: nil,
9
11
  config: nil,
@@ -22,11 +24,12 @@ module SlackCli
22
24
  # Wire up warning callbacks to show warnings to users
23
25
  wire_up_warnings
24
26
  end
27
+ # rubocop:enable Metrics/ParameterLists
25
28
 
26
29
  # Workspace helpers
27
30
  def workspace(name = nil)
28
31
  name ||= @config.primary_workspace
29
- raise ConfigError, "No workspace specified and no primary workspace configured" unless name
32
+ raise ConfigError, 'No workspace specified and no primary workspace configured' unless name
30
33
 
31
34
  @token_store.workspace(name)
32
35
  end
@@ -39,41 +42,41 @@ module SlackCli
39
42
  @token_store.workspace_names
40
43
  end
41
44
 
42
- def has_workspaces?
45
+ def workspaces?
43
46
  !@token_store.empty?
44
47
  end
45
48
 
46
49
  # API helpers - create API instances bound to workspace
47
- def users_api(ws = nil)
48
- Api::Users.new(@api_client, workspace(ws), on_debug: ->(msg) { @output.debug(msg) })
50
+ def users_api(workspace_name = nil)
51
+ Api::Users.new(@api_client, workspace(workspace_name), on_debug: ->(msg) { @output.debug(msg) })
49
52
  end
50
53
 
51
- def conversations_api(ws = nil)
52
- Api::Conversations.new(@api_client, workspace(ws))
54
+ def conversations_api(workspace_name = nil)
55
+ Api::Conversations.new(@api_client, workspace(workspace_name))
53
56
  end
54
57
 
55
- def dnd_api(ws = nil)
56
- Api::Dnd.new(@api_client, workspace(ws))
58
+ def dnd_api(workspace_name = nil)
59
+ Api::Dnd.new(@api_client, workspace(workspace_name))
57
60
  end
58
61
 
59
- def client_api(ws = nil)
60
- Api::Client.new(@api_client, workspace(ws))
62
+ def client_api(workspace_name = nil)
63
+ Api::Client.new(@api_client, workspace(workspace_name))
61
64
  end
62
65
 
63
- def emoji_api(ws = nil)
64
- Api::Emoji.new(@api_client, workspace(ws))
66
+ def emoji_api(workspace_name = nil)
67
+ Api::Emoji.new(@api_client, workspace(workspace_name))
65
68
  end
66
69
 
67
- def bots_api(ws = nil)
68
- Api::Bots.new(@api_client, workspace(ws), on_debug: ->(msg) { @output.debug(msg) })
70
+ def bots_api(workspace_name = nil)
71
+ Api::Bots.new(@api_client, workspace(workspace_name), on_debug: ->(msg) { @output.debug(msg) })
69
72
  end
70
73
 
71
- def threads_api(ws = nil)
72
- Api::Threads.new(@api_client, workspace(ws))
74
+ def threads_api(workspace_name = nil)
75
+ Api::Threads.new(@api_client, workspace(workspace_name))
73
76
  end
74
77
 
75
- def activity_api(ws = nil)
76
- Api::Activity.new(@api_client, workspace(ws))
78
+ def activity_api(workspace_name = nil)
79
+ Api::Activity.new(@api_client, workspace(workspace_name))
77
80
  end
78
81
 
79
82
  # Formatter helpers