slk 0.1.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE +21 -0
  4. data/README.md +190 -0
  5. data/bin/slk +7 -0
  6. data/lib/slack_cli/api/activity.rb +28 -0
  7. data/lib/slack_cli/api/bots.rb +32 -0
  8. data/lib/slack_cli/api/client.rb +49 -0
  9. data/lib/slack_cli/api/conversations.rb +52 -0
  10. data/lib/slack_cli/api/dnd.rb +40 -0
  11. data/lib/slack_cli/api/emoji.rb +21 -0
  12. data/lib/slack_cli/api/threads.rb +44 -0
  13. data/lib/slack_cli/api/usergroups.rb +25 -0
  14. data/lib/slack_cli/api/users.rb +101 -0
  15. data/lib/slack_cli/cli.rb +118 -0
  16. data/lib/slack_cli/commands/activity.rb +292 -0
  17. data/lib/slack_cli/commands/base.rb +175 -0
  18. data/lib/slack_cli/commands/cache.rb +116 -0
  19. data/lib/slack_cli/commands/catchup.rb +484 -0
  20. data/lib/slack_cli/commands/config.rb +159 -0
  21. data/lib/slack_cli/commands/dnd.rb +143 -0
  22. data/lib/slack_cli/commands/emoji.rb +412 -0
  23. data/lib/slack_cli/commands/help.rb +76 -0
  24. data/lib/slack_cli/commands/messages.rb +317 -0
  25. data/lib/slack_cli/commands/presence.rb +107 -0
  26. data/lib/slack_cli/commands/preset.rb +239 -0
  27. data/lib/slack_cli/commands/status.rb +194 -0
  28. data/lib/slack_cli/commands/thread.rb +62 -0
  29. data/lib/slack_cli/commands/unread.rb +312 -0
  30. data/lib/slack_cli/commands/workspaces.rb +151 -0
  31. data/lib/slack_cli/formatters/duration_formatter.rb +28 -0
  32. data/lib/slack_cli/formatters/emoji_replacer.rb +143 -0
  33. data/lib/slack_cli/formatters/mention_replacer.rb +154 -0
  34. data/lib/slack_cli/formatters/message_formatter.rb +429 -0
  35. data/lib/slack_cli/formatters/output.rb +89 -0
  36. data/lib/slack_cli/models/channel.rb +52 -0
  37. data/lib/slack_cli/models/duration.rb +85 -0
  38. data/lib/slack_cli/models/message.rb +217 -0
  39. data/lib/slack_cli/models/preset.rb +73 -0
  40. data/lib/slack_cli/models/reaction.rb +54 -0
  41. data/lib/slack_cli/models/status.rb +57 -0
  42. data/lib/slack_cli/models/user.rb +56 -0
  43. data/lib/slack_cli/models/workspace.rb +52 -0
  44. data/lib/slack_cli/runner.rb +123 -0
  45. data/lib/slack_cli/services/api_client.rb +149 -0
  46. data/lib/slack_cli/services/cache_store.rb +198 -0
  47. data/lib/slack_cli/services/configuration.rb +74 -0
  48. data/lib/slack_cli/services/encryption.rb +51 -0
  49. data/lib/slack_cli/services/preset_store.rb +112 -0
  50. data/lib/slack_cli/services/reaction_enricher.rb +87 -0
  51. data/lib/slack_cli/services/token_store.rb +117 -0
  52. data/lib/slack_cli/support/error_logger.rb +28 -0
  53. data/lib/slack_cli/support/help_formatter.rb +139 -0
  54. data/lib/slack_cli/support/inline_images.rb +62 -0
  55. data/lib/slack_cli/support/slack_url_parser.rb +78 -0
  56. data/lib/slack_cli/support/user_resolver.rb +114 -0
  57. data/lib/slack_cli/support/xdg_paths.rb +37 -0
  58. data/lib/slack_cli/version.rb +5 -0
  59. data/lib/slack_cli.rb +91 -0
  60. metadata +103 -0
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Models
5
+ Message = Data.define(
6
+ :ts,
7
+ :user_id,
8
+ :text,
9
+ :reactions,
10
+ :reply_count,
11
+ :thread_ts,
12
+ :files,
13
+ :attachments,
14
+ :blocks,
15
+ :user_profile,
16
+ :bot_profile,
17
+ :username,
18
+ :subtype,
19
+ :channel_id
20
+ ) do
21
+ # Minimum text length before we extract content from Block Kit blocks.
22
+ # Slack sometimes sends minimal text (like a link preview) with the full
23
+ # content in blocks. 20 chars catches most of these cases without
24
+ # unnecessarily processing blocks for normal messages.
25
+ BLOCK_TEXT_THRESHOLD = 20
26
+
27
+ def self.from_api(data, channel_id: nil)
28
+ text = data["text"] || ""
29
+ blocks = data["blocks"] || []
30
+
31
+ # Extract text from Block Kit blocks if text is empty or minimal
32
+ if text.length < BLOCK_TEXT_THRESHOLD
33
+ blocks_text = extract_block_text(blocks)
34
+ text = blocks_text unless blocks_text.empty?
35
+ end
36
+
37
+ new(
38
+ ts: data["ts"],
39
+ user_id: data["user"] || data["bot_id"] || data["username"],
40
+ text: text,
41
+ reactions: (data["reactions"] || []).map { |r| Reaction.from_api(r) },
42
+ reply_count: data["reply_count"] || 0,
43
+ thread_ts: data["thread_ts"],
44
+ files: data["files"] || [],
45
+ attachments: data["attachments"] || [],
46
+ blocks: blocks,
47
+ user_profile: data["user_profile"],
48
+ bot_profile: data["bot_profile"],
49
+ username: data["username"],
50
+ subtype: data["subtype"],
51
+ channel_id: channel_id
52
+ )
53
+ end
54
+
55
+ def self.extract_block_text(blocks)
56
+ return "" unless blocks.is_a?(Array)
57
+
58
+ blocks.filter_map do |block|
59
+ case block["type"]
60
+ when "section"
61
+ block.dig("text", "text")
62
+ when "rich_text"
63
+ extract_rich_text_content(block["elements"])
64
+ end
65
+ end.join("\n")
66
+ end
67
+
68
+ def self.extract_rich_text_content(elements)
69
+ return "" unless elements.is_a?(Array)
70
+
71
+ elements.filter_map do |element|
72
+ next unless element["elements"].is_a?(Array)
73
+
74
+ element["elements"].filter_map do |item|
75
+ item["text"] if item["type"] == "text"
76
+ end.join
77
+ end.join
78
+ end
79
+
80
+ def initialize(
81
+ ts:,
82
+ user_id:,
83
+ text: "",
84
+ reactions: [],
85
+ reply_count: 0,
86
+ thread_ts: nil,
87
+ files: [],
88
+ attachments: [],
89
+ blocks: [],
90
+ user_profile: nil,
91
+ bot_profile: nil,
92
+ username: nil,
93
+ subtype: nil,
94
+ channel_id: nil
95
+ )
96
+ ts_str = ts.to_s.strip
97
+ user_id_str = user_id.to_s.strip
98
+
99
+ raise ArgumentError, "ts cannot be empty" if ts_str.empty?
100
+ raise ArgumentError, "user_id cannot be empty" if user_id_str.empty?
101
+
102
+ super(
103
+ ts: ts_str.freeze,
104
+ user_id: user_id_str.freeze,
105
+ text: text.to_s.freeze,
106
+ reactions: reactions.freeze,
107
+ reply_count: reply_count.to_i,
108
+ thread_ts: thread_ts&.freeze,
109
+ files: deep_freeze(files),
110
+ attachments: deep_freeze(attachments),
111
+ blocks: deep_freeze(blocks),
112
+ user_profile: deep_freeze(user_profile),
113
+ bot_profile: deep_freeze(bot_profile),
114
+ username: username&.freeze,
115
+ subtype: subtype&.freeze,
116
+ channel_id: channel_id&.freeze
117
+ )
118
+ end
119
+
120
+ # Recursively freeze nested structures (arrays and hashes)
121
+ def self.deep_freeze(obj)
122
+ case obj
123
+ when Hash
124
+ obj.each_value { |v| deep_freeze(v) }
125
+ obj.freeze
126
+ when Array
127
+ obj.each { |v| deep_freeze(v) }
128
+ obj.freeze
129
+ else
130
+ obj.freeze if obj.respond_to?(:freeze)
131
+ end
132
+ obj
133
+ end
134
+
135
+ private_class_method :deep_freeze
136
+
137
+ # Instance method delegate to class method for use in initialize
138
+ def deep_freeze(obj)
139
+ self.class.send(:deep_freeze, obj)
140
+ end
141
+
142
+ def timestamp
143
+ Time.at(ts.to_f)
144
+ end
145
+
146
+ def has_thread?
147
+ reply_count > 0
148
+ end
149
+
150
+ def is_reply?
151
+ thread_ts && thread_ts != ts
152
+ end
153
+
154
+ def has_reactions?
155
+ !reactions.empty?
156
+ end
157
+
158
+ def has_files?
159
+ !files.empty?
160
+ end
161
+
162
+ def has_blocks?
163
+ !blocks.empty?
164
+ end
165
+
166
+ def embedded_username
167
+ # Try user_profile first (regular users)
168
+ if user_profile
169
+ display = user_profile["display_name"]
170
+ real = user_profile["real_name"]
171
+
172
+ return display unless display.to_s.empty?
173
+ return real unless real.to_s.empty?
174
+ end
175
+
176
+ # Try bot_profile (bot messages)
177
+ if bot_profile
178
+ name = bot_profile["name"]
179
+ return name unless name.to_s.empty?
180
+ end
181
+
182
+ # Fall back to username field (some bots/integrations)
183
+ return username unless username.to_s.empty?
184
+
185
+ nil
186
+ end
187
+
188
+ def bot?
189
+ user_id.start_with?("B") || subtype == "bot_message"
190
+ end
191
+
192
+ def system_message?
193
+ %w[channel_join channel_leave channel_topic channel_purpose].include?(subtype)
194
+ end
195
+
196
+ # Create a copy of this message with updated reactions
197
+ def with_reactions(new_reactions)
198
+ Message.new(
199
+ ts: ts,
200
+ user_id: user_id,
201
+ text: text,
202
+ reactions: new_reactions,
203
+ reply_count: reply_count,
204
+ thread_ts: thread_ts,
205
+ files: files,
206
+ attachments: attachments,
207
+ blocks: blocks,
208
+ user_profile: user_profile,
209
+ bot_profile: bot_profile,
210
+ username: username,
211
+ subtype: subtype,
212
+ channel_id: channel_id
213
+ )
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
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
+ def initialize(name:, text: "", emoji: "", duration: "0", presence: "", dnd: "")
18
+ name_str = name.to_s.strip
19
+ raise ArgumentError, "preset name cannot be empty" if name_str.empty?
20
+
21
+ duration_str = duration.to_s
22
+ # Validate duration at construction time (will raise ArgumentError if invalid)
23
+ Duration.parse(duration_str) unless duration_str.empty? || duration_str == "0"
24
+
25
+ super(
26
+ name: name_str.freeze,
27
+ text: text.to_s.freeze,
28
+ emoji: emoji.to_s.freeze,
29
+ duration: duration_str.freeze,
30
+ presence: presence.to_s.freeze,
31
+ dnd: dnd.to_s.freeze
32
+ )
33
+ end
34
+
35
+ def to_h
36
+ {
37
+ "text" => text,
38
+ "emoji" => emoji,
39
+ "duration" => duration,
40
+ "presence" => presence,
41
+ "dnd" => dnd
42
+ }
43
+ end
44
+
45
+ def duration_value
46
+ Duration.parse(duration)
47
+ end
48
+
49
+ def sets_presence?
50
+ !presence.empty?
51
+ end
52
+
53
+ def sets_dnd?
54
+ !dnd.empty?
55
+ end
56
+
57
+ def clears_status?
58
+ text.empty? && emoji.empty?
59
+ end
60
+
61
+ def to_s
62
+ parts = []
63
+ parts << emoji unless emoji.empty?
64
+ parts << "\"#{text}\"" unless text.empty?
65
+ parts << "(#{duration})" unless duration == "0" || duration.empty?
66
+ parts << "[#{presence}]" if sets_presence?
67
+ parts << "{dnd: #{dnd}}" if sets_dnd?
68
+
69
+ "#{name}: #{parts.join(" ")}"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Models
5
+ Reaction = Data.define(:name, :count, :users, :timestamps) do
6
+ def self.from_api(data)
7
+ new(
8
+ name: data["name"],
9
+ count: data["count"] || 0,
10
+ users: data["users"] || [],
11
+ timestamps: nil # Will be populated by ReactionEnricher
12
+ )
13
+ end
14
+
15
+ def initialize(name:, count: 0, users: [], timestamps: nil)
16
+ count_val = count.to_i
17
+ count_val = 0 if count_val.negative? # Normalize invalid negative counts
18
+
19
+ super(
20
+ name: name.to_s.freeze,
21
+ count: count_val,
22
+ users: users.freeze,
23
+ timestamps: timestamps&.freeze
24
+ )
25
+ end
26
+
27
+ # Create a new Reaction with timestamps added
28
+ def with_timestamps(timestamp_map)
29
+ Reaction.new(
30
+ name: name,
31
+ count: count,
32
+ users: users,
33
+ timestamps: timestamp_map
34
+ )
35
+ end
36
+
37
+ def has_timestamps?
38
+ !timestamps.nil? && !timestamps.empty?
39
+ end
40
+
41
+ def timestamp_for(user_id)
42
+ timestamps&.dig(user_id)
43
+ end
44
+
45
+ def emoji_code
46
+ ":#{name}:"
47
+ end
48
+
49
+ def to_s
50
+ "#{count} #{emoji_code}"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Models
5
+ Status = Data.define(:text, :emoji, :expiration) do
6
+ def initialize(text: "", emoji: "", expiration: 0)
7
+ exp_val = expiration.to_i
8
+ exp_val = 0 if exp_val.negative? # Normalize invalid negative expirations
9
+
10
+ super(
11
+ text: text.to_s.freeze,
12
+ emoji: emoji.to_s.freeze,
13
+ expiration: exp_val
14
+ )
15
+ end
16
+
17
+ def empty?
18
+ text.empty? && emoji.empty?
19
+ end
20
+
21
+ def expires?
22
+ expiration > 0
23
+ end
24
+
25
+ def expired?
26
+ expires? && expiration < Time.now.to_i
27
+ end
28
+
29
+ def time_remaining
30
+ return nil unless expires?
31
+
32
+ remaining = expiration - Time.now.to_i
33
+ remaining > 0 ? Duration.new(seconds: remaining) : nil
34
+ end
35
+
36
+ def expiration_time
37
+ return nil unless expires?
38
+
39
+ Time.at(expiration)
40
+ end
41
+
42
+ def to_s
43
+ return "(no status)" if empty?
44
+
45
+ parts = []
46
+ parts << emoji unless emoji.empty?
47
+ parts << text unless text.empty?
48
+
49
+ if (remaining = time_remaining)
50
+ parts << "(#{remaining})"
51
+ end
52
+
53
+ parts.join(" ")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Models
5
+ User = Data.define(:id, :name, :real_name, :display_name, :is_bot) do
6
+ def self.from_api(data)
7
+ profile = data["profile"] || {}
8
+
9
+ new(
10
+ id: data["id"],
11
+ name: data["name"],
12
+ real_name: profile["real_name"] || data["real_name"],
13
+ display_name: profile["display_name"] || profile["display_name_normalized"],
14
+ is_bot: data["is_bot"] || false
15
+ )
16
+ end
17
+
18
+ # Slack user IDs start with U or W (enterprise grid)
19
+ USER_ID_PATTERN = /\A[UW][A-Z0-9]+\z/
20
+
21
+ def initialize(id:, name: nil, real_name: nil, display_name: nil, is_bot: false)
22
+ id_str = id.to_s.strip
23
+ raise ArgumentError, "user id cannot be empty" if id_str.empty?
24
+
25
+ # Validate user ID format (starts with U or W followed by alphanumeric)
26
+ unless id_str.match?(USER_ID_PATTERN)
27
+ raise ArgumentError, "invalid user id format: #{id_str} (expected U or W prefix)"
28
+ end
29
+
30
+ super(
31
+ id: id_str.freeze,
32
+ name: name&.freeze,
33
+ real_name: real_name&.freeze,
34
+ display_name: display_name&.freeze,
35
+ is_bot: is_bot
36
+ )
37
+ end
38
+
39
+ def best_name
40
+ return display_name unless display_name.to_s.empty?
41
+ return real_name unless real_name.to_s.empty?
42
+ return name unless name.to_s.empty?
43
+
44
+ id
45
+ end
46
+
47
+ def mention
48
+ "@#{best_name}"
49
+ end
50
+
51
+ def to_s
52
+ best_name
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Models
5
+ Workspace = Data.define(:name, :token, :cookie) do
6
+ # Valid token prefixes for Slack tokens
7
+ VALID_TOKEN_PREFIXES = %w[xoxb- xoxc- xoxp-].freeze
8
+
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 is not empty and doesn't contain path separators
15
+ raise ArgumentError, "workspace name cannot be empty" if name_str.empty?
16
+ raise ArgumentError, "workspace name contains invalid characters" if name_str.match?(%r{[/\\]})
17
+
18
+ # Validate token format
19
+ unless VALID_TOKEN_PREFIXES.any? { |prefix| token_str.start_with?(prefix) }
20
+ raise ArgumentError, "invalid token format (must start with xoxb-, xoxc-, or xoxp-)"
21
+ end
22
+
23
+ # xoxc tokens require a cookie
24
+ if token_str.start_with?("xoxc-") && (cookie_str.nil? || cookie_str.strip.empty?)
25
+ raise ArgumentError, "xoxc tokens require a cookie"
26
+ end
27
+
28
+ # Validate cookie doesn't contain newlines (HTTP header injection prevention)
29
+ if cookie_str && cookie_str.match?(/[\r\n]/)
30
+ raise ArgumentError, "cookie cannot contain newlines"
31
+ end
32
+
33
+ super(name: name_str.freeze, token: token_str.freeze, cookie: cookie_str&.freeze)
34
+ end
35
+
36
+ def xoxc? = token.start_with?("xoxc-")
37
+ def xoxb? = token.start_with?("xoxb-")
38
+ def xoxp? = token.start_with?("xoxp-")
39
+
40
+ def to_s = name
41
+
42
+ def headers
43
+ h = {
44
+ "Authorization" => "Bearer #{token}",
45
+ "Content-Type" => "application/json; charset=utf-8"
46
+ }
47
+ h["Cookie"] = "d=#{cookie}" if cookie
48
+ h
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ class Runner
5
+ attr_reader :output, :config, :token_store, :api_client, :cache_store, :preset_store
6
+
7
+ def initialize(
8
+ output: nil,
9
+ config: nil,
10
+ token_store: nil,
11
+ api_client: nil,
12
+ cache_store: nil,
13
+ preset_store: nil
14
+ )
15
+ @output = output || Formatters::Output.new
16
+ @config = config || Services::Configuration.new
17
+ @token_store = token_store || Services::TokenStore.new(config: @config)
18
+ @api_client = api_client || Services::ApiClient.new
19
+ @cache_store = cache_store || Services::CacheStore.new
20
+ @preset_store = preset_store || Services::PresetStore.new
21
+
22
+ # Wire up warning callbacks to show warnings to users
23
+ wire_up_warnings
24
+ end
25
+
26
+ # Workspace helpers
27
+ def workspace(name = nil)
28
+ name ||= @config.primary_workspace
29
+ raise ConfigError, "No workspace specified and no primary workspace configured" unless name
30
+
31
+ @token_store.workspace(name)
32
+ end
33
+
34
+ def all_workspaces
35
+ @token_store.all_workspaces
36
+ end
37
+
38
+ def workspace_names
39
+ @token_store.workspace_names
40
+ end
41
+
42
+ def has_workspaces?
43
+ !@token_store.empty?
44
+ end
45
+
46
+ # 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) })
49
+ end
50
+
51
+ def conversations_api(ws = nil)
52
+ Api::Conversations.new(@api_client, workspace(ws))
53
+ end
54
+
55
+ def dnd_api(ws = nil)
56
+ Api::Dnd.new(@api_client, workspace(ws))
57
+ end
58
+
59
+ def client_api(ws = nil)
60
+ Api::Client.new(@api_client, workspace(ws))
61
+ end
62
+
63
+ def emoji_api(ws = nil)
64
+ Api::Emoji.new(@api_client, workspace(ws))
65
+ end
66
+
67
+ def bots_api(ws = nil)
68
+ Api::Bots.new(@api_client, workspace(ws), on_debug: ->(msg) { @output.debug(msg) })
69
+ end
70
+
71
+ def threads_api(ws = nil)
72
+ Api::Threads.new(@api_client, workspace(ws))
73
+ end
74
+
75
+ def activity_api(ws = nil)
76
+ Api::Activity.new(@api_client, workspace(ws))
77
+ end
78
+
79
+ # Formatter helpers
80
+ def message_formatter
81
+ @message_formatter ||= Formatters::MessageFormatter.new(
82
+ output: @output,
83
+ mention_replacer: mention_replacer,
84
+ emoji_replacer: emoji_replacer,
85
+ cache_store: @cache_store,
86
+ api_client: @api_client,
87
+ on_debug: ->(msg) { @output.debug(msg) }
88
+ )
89
+ end
90
+
91
+ def mention_replacer
92
+ @mention_replacer ||= Formatters::MentionReplacer.new(
93
+ cache_store: @cache_store,
94
+ api_client: @api_client,
95
+ on_debug: ->(msg) { @output.debug(msg) }
96
+ )
97
+ end
98
+
99
+ def emoji_replacer
100
+ @emoji_replacer ||= Formatters::EmojiReplacer.new
101
+ end
102
+
103
+ def duration_formatter
104
+ @duration_formatter ||= Formatters::DurationFormatter.new
105
+ end
106
+
107
+ # Logging
108
+ def log_error(error)
109
+ Support::ErrorLogger.log(error)
110
+ end
111
+
112
+ private
113
+
114
+ def wire_up_warnings
115
+ warning_handler = ->(message) { @output.warn(message) }
116
+
117
+ @config.on_warning = warning_handler
118
+ @token_store.on_warning = warning_handler
119
+ @preset_store.on_warning = warning_handler
120
+ @cache_store.on_warning = warning_handler
121
+ end
122
+ end
123
+ end