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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/inline_images"
4
+ require_relative "../support/help_formatter"
5
+
6
+ module SlackCli
7
+ module Commands
8
+ class Status < Base
9
+ include Support::InlineImages
10
+ def execute
11
+ result = validate_options
12
+ return result if result
13
+
14
+ case positional_args
15
+ in ["clear", *]
16
+ clear_status
17
+ in [text, *rest]
18
+ set_status(text, rest)
19
+ in []
20
+ get_status
21
+ end
22
+ rescue ApiError => e
23
+ error("Failed: #{e.message}")
24
+ 1
25
+ end
26
+
27
+ protected
28
+
29
+ def default_options
30
+ super.merge(presence: nil, dnd: nil)
31
+ end
32
+
33
+ def handle_option(arg, args, remaining)
34
+ case arg
35
+ when "-p", "--presence"
36
+ @options[:presence] = args.shift
37
+ when "-d", "--dnd"
38
+ @options[:dnd] = args.shift
39
+ else
40
+ super
41
+ end
42
+ end
43
+
44
+ def help_text
45
+ help = Support::HelpFormatter.new("slk status [text] [emoji] [duration] [options]")
46
+ help.description("Get or set your Slack status.")
47
+ help.note("GET shows all workspaces by default. SET applies to primary only.")
48
+
49
+ help.section("EXAMPLES") do |s|
50
+ s.example("slk status", "Show status (all workspaces)")
51
+ s.example("slk status clear", "Clear status")
52
+ s.example("slk status \"Working\" :laptop:", "Set status with emoji")
53
+ s.example("slk status \"Meeting\" :calendar: 1h", "Set status for 1 hour")
54
+ s.example("slk status \"Focus\" :headphones: 2h -p away -d 2h")
55
+ end
56
+
57
+ help.section("OPTIONS") do |s|
58
+ s.option("-p, --presence VALUE", "Also set presence (away/auto/active)")
59
+ s.option("-d, --dnd DURATION", "Also set DND (or 'off')")
60
+ s.option("-w, --workspace", "Limit to specific workspace")
61
+ s.option("--all", "Set across all workspaces")
62
+ s.option("-v, --verbose", "Show debug information")
63
+ s.option("-q, --quiet", "Suppress output")
64
+ end
65
+
66
+ help.render
67
+ end
68
+
69
+ private
70
+
71
+ def get_status
72
+ # GET defaults to all workspaces unless -w specified
73
+ workspaces = @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
74
+
75
+ workspaces.each do |workspace|
76
+ status = runner.users_api(workspace.name).get_status
77
+
78
+ if workspaces.size > 1
79
+ puts output.bold(workspace.name)
80
+ end
81
+
82
+ if status.empty?
83
+ puts " (no status set)"
84
+ else
85
+ display_status(workspace, status)
86
+ end
87
+ end
88
+
89
+ 0
90
+ end
91
+
92
+ def display_status(workspace, status)
93
+ # Check if emoji is a custom workspace emoji with an image
94
+ emoji_name = status.emoji.delete_prefix(":").delete_suffix(":")
95
+ emoji_path = find_workspace_emoji(workspace.name, emoji_name)
96
+
97
+ if emoji_path && inline_images_supported?
98
+ # Build status text without emoji (we'll display it as image)
99
+ parts = []
100
+ parts << status.text unless status.text.empty?
101
+ if (remaining = status.time_remaining)
102
+ parts << "(#{remaining})"
103
+ end
104
+ text = " #{parts.join(" ")}"
105
+
106
+ print_inline_image_with_text(emoji_path, text)
107
+ else
108
+ puts " #{status}"
109
+ end
110
+ end
111
+
112
+ def find_workspace_emoji(workspace_name, emoji_name)
113
+ return nil if emoji_name.empty?
114
+
115
+ paths = Support::XdgPaths.new
116
+ emoji_dir = config.emoji_dir || paths.cache_dir
117
+ workspace_dir = File.join(emoji_dir, workspace_name)
118
+ return nil unless Dir.exist?(workspace_dir)
119
+
120
+ # Look for emoji file with any extension
121
+ Dir.glob(File.join(workspace_dir, "#{emoji_name}.*")).first
122
+ end
123
+
124
+ def set_status(text, rest)
125
+ # Parse emoji and duration from rest
126
+ emoji = rest.find { |arg| arg.start_with?(":") && arg.end_with?(":") } || ":speech_balloon:"
127
+ duration_str = rest.find { |arg| arg.match?(/^\d+[hms]?$/) }
128
+ duration = duration_str ? Models::Duration.parse(duration_str) : Models::Duration.zero
129
+
130
+ target_workspaces.each do |workspace|
131
+ api = runner.users_api(workspace.name)
132
+ api.set_status(text: text, emoji: emoji, duration: duration)
133
+
134
+ success("Status set on #{workspace.name}")
135
+ debug(" Text: #{text}")
136
+ debug(" Emoji: #{emoji}")
137
+ debug(" Duration: #{duration}") unless duration.zero?
138
+
139
+ # Handle combo options
140
+ apply_presence(workspace) if @options[:presence]
141
+ apply_dnd(workspace) if @options[:dnd]
142
+ end
143
+
144
+ show_all_workspaces_hint
145
+
146
+ 0
147
+ end
148
+
149
+ def apply_presence(workspace)
150
+ value = @options[:presence]
151
+ value = "auto" if value == "active"
152
+
153
+ api = runner.users_api(workspace.name)
154
+ api.set_presence(value)
155
+ success("Presence set to #{value} on #{workspace.name}")
156
+ end
157
+
158
+ def apply_dnd(workspace)
159
+ value = @options[:dnd]
160
+ dnd_api = runner.dnd_api(workspace.name)
161
+
162
+ if value == "off"
163
+ dnd_api.end_snooze
164
+ success("DND disabled on #{workspace.name}")
165
+ else
166
+ duration = Models::Duration.parse(value)
167
+ dnd_api.set_snooze(duration)
168
+ success("DND enabled for #{value} on #{workspace.name}")
169
+ end
170
+ end
171
+
172
+ def clear_status
173
+ target_workspaces.each do |workspace|
174
+ api = runner.users_api(workspace.name)
175
+ api.clear_status
176
+
177
+ success("Status cleared on #{workspace.name}")
178
+ end
179
+
180
+ show_all_workspaces_hint
181
+
182
+ 0
183
+ end
184
+
185
+ def show_all_workspaces_hint
186
+ # Show hint if user has multiple workspaces and didn't use --all or -w
187
+ return if @options[:all] || @options[:workspace]
188
+ return if runner.all_workspaces.size <= 1
189
+
190
+ info("Tip: Use --all to set across all workspaces")
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'messages'
4
+
5
+ module SlackCli
6
+ module Commands
7
+ class Thread < Messages
8
+ def execute
9
+ result = validate_options
10
+ return result if result
11
+
12
+ target = positional_args.first
13
+ unless target
14
+ error("Usage: slk thread <url>")
15
+ return 1
16
+ end
17
+
18
+ # Thread command requires a URL
19
+ url_parser = Support::SlackUrlParser.new
20
+ unless url_parser.slack_url?(target)
21
+ error("thread command requires a Slack URL")
22
+ return 1
23
+ end
24
+
25
+ super
26
+ end
27
+
28
+ protected
29
+
30
+ def default_options
31
+ super.merge(
32
+ limit: 1,
33
+ limit_set: true, # Prevent apply_default_limit from overriding
34
+ threads: true
35
+ )
36
+ end
37
+
38
+ def help_text
39
+ help = Support::HelpFormatter.new("slk thread <url> [options]")
40
+ help.description("View a message thread from a Slack URL.")
41
+
42
+ help.section("USAGE") do |s|
43
+ s.item("<slack_url>", "Slack message URL")
44
+ end
45
+
46
+ help.section("OPTIONS") do |s|
47
+ s.option("--no-emoji", "Show :emoji: codes instead of unicode")
48
+ s.option("--no-reactions", "Hide reactions")
49
+ s.option("--no-names", "Skip user name lookups (faster)")
50
+ s.option("--json", "Output as JSON")
51
+ s.option("-v, --verbose", "Show debug information")
52
+ end
53
+
54
+ help.section("EXAMPLES") do |s|
55
+ s.item("slk thread https://work.slack.com/archives/C123/p1234567890", "View thread")
56
+ end
57
+
58
+ help.render
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/help_formatter"
4
+
5
+ module SlackCli
6
+ module Commands
7
+ class Unread < Base
8
+ include Support::UserResolver
9
+
10
+ def execute
11
+ result = validate_options
12
+ return result if result
13
+
14
+ case positional_args
15
+ in ["clear", *rest]
16
+ clear_unread(rest.first)
17
+ in []
18
+ show_unread
19
+ else
20
+ error("Unknown action: #{positional_args.first}")
21
+ 1
22
+ end
23
+ rescue ApiError => e
24
+ error("Failed: #{e.message}")
25
+ 1
26
+ end
27
+
28
+ protected
29
+
30
+ def default_options
31
+ super.merge(
32
+ all: true, # Default to all workspaces
33
+ muted: false,
34
+ limit: 10,
35
+ no_emoji: false,
36
+ no_reactions: false,
37
+ workspace_emoji: true, # Default to showing workspace emoji as images
38
+ reaction_names: false,
39
+ reaction_timestamps: false
40
+ )
41
+ end
42
+
43
+ def handle_option(arg, args, remaining)
44
+ case arg
45
+ when "--muted"
46
+ @options[:muted] = true
47
+ when "-n", "--limit"
48
+ @options[:limit] = args.shift.to_i
49
+ when "--no-emoji"
50
+ @options[:no_emoji] = true
51
+ when "--no-reactions"
52
+ @options[:no_reactions] = true
53
+ when "--no-workspace-emoji"
54
+ @options[:workspace_emoji] = false
55
+ when "--reaction-names"
56
+ @options[:reaction_names] = true
57
+ when "--reaction-timestamps"
58
+ @options[:reaction_timestamps] = true
59
+ else
60
+ super
61
+ end
62
+ end
63
+
64
+ def help_text
65
+ help = Support::HelpFormatter.new("slk unread [action] [options]")
66
+ help.description("View and manage unread messages (all workspaces by default).")
67
+
68
+ help.section("ACTIONS") do |s|
69
+ s.action("(none)", "Show unread messages")
70
+ s.action("clear", "Mark all as read")
71
+ s.action("clear #channel", "Mark specific channel as read")
72
+ end
73
+
74
+ help.section("OPTIONS") do |s|
75
+ s.option("-n, --limit N", "Messages per channel (default: 10)")
76
+ s.option("--muted", "Include/clear muted channels")
77
+ s.option("--no-emoji", "Show :emoji: codes instead of unicode")
78
+ s.option("--no-reactions", "Hide reactions")
79
+ s.option("--no-workspace-emoji", "Disable workspace emoji images")
80
+ s.option("--reaction-names", "Show reactions with user names")
81
+ s.option("--reaction-timestamps", "Show when each person reacted")
82
+ s.option("-w, --workspace", "Limit to specific workspace")
83
+ s.option("--json", "Output as JSON")
84
+ s.option("-q, --quiet", "Suppress output")
85
+ end
86
+
87
+ help.render
88
+ end
89
+
90
+ private
91
+
92
+ def show_unread
93
+ target_workspaces.each do |workspace|
94
+ client = runner.client_api(workspace.name)
95
+ conversations_api = runner.conversations_api(workspace.name)
96
+ formatter = runner.message_formatter
97
+
98
+ if @options[:all] || target_workspaces.size > 1
99
+ puts output.bold(workspace.name)
100
+ end
101
+
102
+ counts = client.counts
103
+
104
+ # Get muted channels from user prefs unless --muted flag is set
105
+ muted_ids = @options[:muted] ? [] : runner.users_api(workspace.name).muted_channels
106
+
107
+ # DMs first
108
+ ims = counts["ims"] || []
109
+ unread_ims = ims.select { |i| i["has_unreads"] }
110
+
111
+ unread_ims.each do |im|
112
+ mention_count = im["mention_count"] || 0
113
+ user_name = resolve_dm_user_name(workspace, im["id"], conversations_api)
114
+ puts
115
+ puts output.bold("@#{user_name}") + (mention_count > 0 ? " (#{mention_count} mentions)" : "")
116
+ puts
117
+ show_channel_messages(workspace, im["id"], @options[:limit], conversations_api, formatter)
118
+ end
119
+
120
+ # Channels
121
+ channels = counts["channels"] || []
122
+ unreads = channels
123
+ .select { |c| c["has_unreads"] || (c["mention_count"] || 0) > 0 }
124
+ .reject { |c| muted_ids.include?(c["id"]) }
125
+
126
+ if @options[:json]
127
+ output_json({
128
+ channels: unreads.map { |c| { id: c["id"], mentions: c["mention_count"] } },
129
+ dms: unread_ims.map { |i| { id: i["id"], mentions: i["mention_count"] } }
130
+ })
131
+ else
132
+ if unreads.empty? && unread_ims.empty?
133
+ puts "No unread messages"
134
+ else
135
+ unreads.each do |channel|
136
+ name = cache_store.get_channel_name(workspace.name, channel["id"]) || channel["id"]
137
+ limit = @options[:limit]
138
+
139
+ puts
140
+ puts output.bold("##{name}") + " (showing last #{limit})"
141
+ puts
142
+ show_channel_messages(workspace, channel["id"], limit, conversations_api, formatter)
143
+ end
144
+ end
145
+
146
+ # Show threads
147
+ show_threads(workspace, formatter)
148
+ end
149
+ end
150
+
151
+ 0
152
+ end
153
+
154
+ def show_threads(workspace, formatter)
155
+ threads_api = runner.threads_api(workspace.name)
156
+ threads_response = threads_api.get_view(limit: 20)
157
+
158
+ return unless threads_response["ok"]
159
+
160
+ total_unreads = threads_response["total_unread_replies"] || 0
161
+ return if total_unreads == 0
162
+
163
+ threads = threads_response["threads"] || []
164
+
165
+ puts
166
+ puts output.bold("🧵 Threads") + " (#{total_unreads} unread replies)"
167
+ puts
168
+
169
+ format_options = {
170
+ no_emoji: @options[:no_emoji],
171
+ no_reactions: @options[:no_reactions],
172
+ workspace_emoji: @options[:workspace_emoji],
173
+ reaction_names: @options[:reaction_names]
174
+ }
175
+
176
+ threads.each do |thread|
177
+ unread_replies = thread["unread_replies"] || []
178
+ next if unread_replies.empty?
179
+
180
+ root_msg = thread["root_msg"] || {}
181
+ channel_id = root_msg["channel"]
182
+ conversation_label = resolve_conversation_label(workspace, channel_id)
183
+
184
+ # Get root user name
185
+ root_user = extract_user_from_message(root_msg, workspace)
186
+
187
+ puts output.blue(" #{conversation_label}") + " - thread by " + output.bold(root_user)
188
+
189
+ # Display unread replies (limit to @options[:limit])
190
+ unread_replies.first(@options[:limit]).each do |reply|
191
+ message = Models::Message.from_api(reply, channel_id: channel_id)
192
+ puts " #{formatter.format_simple(message, workspace: workspace, options: format_options)}"
193
+ end
194
+
195
+ puts
196
+ end
197
+ end
198
+
199
+ def show_channel_messages(workspace, channel_id, limit, api, formatter)
200
+ history = api.history(channel: channel_id, limit: limit)
201
+ raw_messages = (history["messages"] || []).reverse
202
+
203
+ # Convert to model objects
204
+ messages = raw_messages.map { |msg| Models::Message.from_api(msg, channel_id: channel_id) }
205
+
206
+ # Enrich with reaction timestamps if requested
207
+ if @options[:reaction_timestamps]
208
+ enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
209
+ messages = enricher.enrich_messages(messages, channel_id)
210
+ end
211
+
212
+ format_options = {
213
+ no_emoji: @options[:no_emoji],
214
+ no_reactions: @options[:no_reactions],
215
+ workspace_emoji: @options[:workspace_emoji],
216
+ reaction_names: @options[:reaction_names],
217
+ reaction_timestamps: @options[:reaction_timestamps]
218
+ }
219
+
220
+ messages.each do |message|
221
+ puts formatter.format_simple(message, workspace: workspace, options: format_options)
222
+ end
223
+ rescue ApiError => e
224
+ puts output.dim(" (Could not fetch messages: #{e.message})")
225
+ end
226
+
227
+ def clear_unread(channel_name)
228
+ target_workspaces.each do |workspace|
229
+ if channel_name
230
+ # Clear specific channel
231
+ channel_id = if channel_name.match?(/^[CDG][A-Z0-9]+$/)
232
+ channel_name
233
+ else
234
+ name = channel_name.delete_prefix("#")
235
+ cache_store.get_channel_id(workspace.name, name) ||
236
+ resolve_channel(workspace, name)
237
+ end
238
+
239
+ api = runner.conversations_api(workspace.name)
240
+ # Get latest message timestamp
241
+ history = api.history(channel: channel_id, limit: 1)
242
+ if (messages = history["messages"]) && messages.any?
243
+ api.mark(channel: channel_id, ts: messages.first["ts"])
244
+ success("Marked ##{channel_name} as read on #{workspace.name}")
245
+ end
246
+ else
247
+ # Clear all
248
+ client = runner.client_api(workspace.name)
249
+ counts = client.counts
250
+
251
+ # Get muted channels from user prefs unless --muted flag is set
252
+ muted_ids = @options[:muted] ? [] : runner.users_api(workspace.name).muted_channels
253
+
254
+ channels = counts["channels"] || []
255
+ channels_cleared = 0
256
+ channels.each do |channel|
257
+ next unless channel["has_unreads"]
258
+ next if muted_ids.include?(channel["id"])
259
+
260
+ api = runner.conversations_api(workspace.name)
261
+ begin
262
+ history = api.history(channel: channel["id"], limit: 1)
263
+ if (messages = history["messages"]) && messages.any?
264
+ api.mark(channel: channel["id"], ts: messages.first["ts"])
265
+ channels_cleared += 1
266
+ end
267
+ rescue ApiError => e
268
+ debug("Could not clear channel #{channel["id"]}: #{e.message}")
269
+ end
270
+ end
271
+
272
+ # Also clear threads
273
+ threads_api = runner.threads_api(workspace.name)
274
+ threads_response = threads_api.get_view(limit: 50)
275
+ threads_cleared = 0
276
+
277
+ if threads_response["ok"]
278
+ (threads_response["threads"] || []).each do |thread|
279
+ unread_replies = thread["unread_replies"] || []
280
+ next if unread_replies.empty?
281
+
282
+ root_msg = thread["root_msg"] || {}
283
+ channel_id = root_msg["channel"]
284
+ thread_ts = root_msg["thread_ts"]
285
+ latest_ts = unread_replies.map { |r| r["ts"] }.max
286
+
287
+ begin
288
+ threads_api.mark(channel: channel_id, thread_ts: thread_ts, ts: latest_ts)
289
+ threads_cleared += 1
290
+ rescue ApiError => e
291
+ debug("Could not mark thread #{thread_ts} in #{channel_id}: #{e.message}")
292
+ end
293
+ end
294
+ end
295
+
296
+ success("Cleared #{channels_cleared} channels and #{threads_cleared} threads on #{workspace.name}")
297
+ end
298
+ end
299
+
300
+ 0
301
+ end
302
+
303
+ def resolve_channel(workspace, name)
304
+ api = runner.conversations_api(workspace.name)
305
+ response = api.list
306
+ channels = response["channels"] || []
307
+ channel = channels.find { |c| c["name"] == name }
308
+ channel&.dig("id") || raise(ConfigError, "Channel not found: ##{name}")
309
+ end
310
+ end
311
+ end
312
+ end