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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE +21 -0
- data/README.md +190 -0
- data/bin/slk +7 -0
- data/lib/slack_cli/api/activity.rb +28 -0
- data/lib/slack_cli/api/bots.rb +32 -0
- data/lib/slack_cli/api/client.rb +49 -0
- data/lib/slack_cli/api/conversations.rb +52 -0
- data/lib/slack_cli/api/dnd.rb +40 -0
- data/lib/slack_cli/api/emoji.rb +21 -0
- data/lib/slack_cli/api/threads.rb +44 -0
- data/lib/slack_cli/api/usergroups.rb +25 -0
- data/lib/slack_cli/api/users.rb +101 -0
- data/lib/slack_cli/cli.rb +118 -0
- data/lib/slack_cli/commands/activity.rb +292 -0
- data/lib/slack_cli/commands/base.rb +175 -0
- data/lib/slack_cli/commands/cache.rb +116 -0
- data/lib/slack_cli/commands/catchup.rb +484 -0
- data/lib/slack_cli/commands/config.rb +159 -0
- data/lib/slack_cli/commands/dnd.rb +143 -0
- data/lib/slack_cli/commands/emoji.rb +412 -0
- data/lib/slack_cli/commands/help.rb +76 -0
- data/lib/slack_cli/commands/messages.rb +317 -0
- data/lib/slack_cli/commands/presence.rb +107 -0
- data/lib/slack_cli/commands/preset.rb +239 -0
- data/lib/slack_cli/commands/status.rb +194 -0
- data/lib/slack_cli/commands/thread.rb +62 -0
- data/lib/slack_cli/commands/unread.rb +312 -0
- data/lib/slack_cli/commands/workspaces.rb +151 -0
- data/lib/slack_cli/formatters/duration_formatter.rb +28 -0
- data/lib/slack_cli/formatters/emoji_replacer.rb +143 -0
- data/lib/slack_cli/formatters/mention_replacer.rb +154 -0
- data/lib/slack_cli/formatters/message_formatter.rb +429 -0
- data/lib/slack_cli/formatters/output.rb +89 -0
- data/lib/slack_cli/models/channel.rb +52 -0
- data/lib/slack_cli/models/duration.rb +85 -0
- data/lib/slack_cli/models/message.rb +217 -0
- data/lib/slack_cli/models/preset.rb +73 -0
- data/lib/slack_cli/models/reaction.rb +54 -0
- data/lib/slack_cli/models/status.rb +57 -0
- data/lib/slack_cli/models/user.rb +56 -0
- data/lib/slack_cli/models/workspace.rb +52 -0
- data/lib/slack_cli/runner.rb +123 -0
- data/lib/slack_cli/services/api_client.rb +149 -0
- data/lib/slack_cli/services/cache_store.rb +198 -0
- data/lib/slack_cli/services/configuration.rb +74 -0
- data/lib/slack_cli/services/encryption.rb +51 -0
- data/lib/slack_cli/services/preset_store.rb +112 -0
- data/lib/slack_cli/services/reaction_enricher.rb +87 -0
- data/lib/slack_cli/services/token_store.rb +117 -0
- data/lib/slack_cli/support/error_logger.rb +28 -0
- data/lib/slack_cli/support/help_formatter.rb +139 -0
- data/lib/slack_cli/support/inline_images.rb +62 -0
- data/lib/slack_cli/support/slack_url_parser.rb +78 -0
- data/lib/slack_cli/support/user_resolver.rb +114 -0
- data/lib/slack_cli/support/xdg_paths.rb +37 -0
- data/lib/slack_cli/version.rb +5 -0
- data/lib/slack_cli.rb +91 -0
- metadata +103 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/help_formatter"
|
|
4
|
+
|
|
5
|
+
module SlackCli
|
|
6
|
+
module Commands
|
|
7
|
+
class Catchup < Base
|
|
8
|
+
include Support::UserResolver
|
|
9
|
+
|
|
10
|
+
def execute
|
|
11
|
+
result = validate_options
|
|
12
|
+
return result if result
|
|
13
|
+
|
|
14
|
+
if @options[:batch]
|
|
15
|
+
batch_catchup
|
|
16
|
+
else
|
|
17
|
+
interactive_catchup
|
|
18
|
+
end
|
|
19
|
+
rescue ApiError => e
|
|
20
|
+
error("Failed: #{e.message}")
|
|
21
|
+
1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
def default_options
|
|
27
|
+
super.merge(
|
|
28
|
+
all: true, # Default to all workspaces
|
|
29
|
+
batch: false,
|
|
30
|
+
muted: false,
|
|
31
|
+
limit: 5,
|
|
32
|
+
no_emoji: false,
|
|
33
|
+
no_reactions: false,
|
|
34
|
+
workspace_emoji: true, # Default to showing workspace emoji as images
|
|
35
|
+
reaction_names: false,
|
|
36
|
+
reaction_timestamps: false
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def handle_option(arg, args, remaining)
|
|
41
|
+
case arg
|
|
42
|
+
when "--batch"
|
|
43
|
+
@options[:batch] = true
|
|
44
|
+
when "--muted"
|
|
45
|
+
@options[:muted] = true
|
|
46
|
+
when "-n", "--limit"
|
|
47
|
+
@options[:limit] = args.shift.to_i
|
|
48
|
+
when "--no-emoji"
|
|
49
|
+
@options[:no_emoji] = true
|
|
50
|
+
when "--no-reactions"
|
|
51
|
+
@options[:no_reactions] = true
|
|
52
|
+
when "--no-workspace-emoji"
|
|
53
|
+
@options[:workspace_emoji] = false
|
|
54
|
+
when "--reaction-names"
|
|
55
|
+
@options[:reaction_names] = true
|
|
56
|
+
when "--reaction-timestamps"
|
|
57
|
+
@options[:reaction_timestamps] = true
|
|
58
|
+
else
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def help_text
|
|
64
|
+
help = Support::HelpFormatter.new("slk catchup [options]")
|
|
65
|
+
help.description("Interactively review and dismiss unread messages (all workspaces by default).")
|
|
66
|
+
|
|
67
|
+
help.section("OPTIONS") do |s|
|
|
68
|
+
s.option("--batch", "Non-interactive mode (mark all as read)")
|
|
69
|
+
s.option("--muted", "Include muted channels")
|
|
70
|
+
s.option("-n, --limit N", "Messages per channel (default: 5)")
|
|
71
|
+
s.option("--no-emoji", "Show :emoji: codes instead of unicode")
|
|
72
|
+
s.option("--no-reactions", "Hide reactions")
|
|
73
|
+
s.option("--no-workspace-emoji", "Disable workspace emoji images")
|
|
74
|
+
s.option("--reaction-names", "Show reactions with user names")
|
|
75
|
+
s.option("--reaction-timestamps", "Show when each person reacted")
|
|
76
|
+
s.option("-w, --workspace", "Limit to specific workspace")
|
|
77
|
+
s.option("-q, --quiet", "Suppress output")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
help.section("INTERACTIVE KEYS") do |s|
|
|
81
|
+
s.item("s / Enter", "Skip channel")
|
|
82
|
+
s.item("r", "Mark as read and continue")
|
|
83
|
+
s.item("o", "Open in Slack")
|
|
84
|
+
s.item("q", "Quit")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
help.render
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def batch_catchup
|
|
93
|
+
target_workspaces.each do |workspace|
|
|
94
|
+
client = runner.client_api(workspace.name)
|
|
95
|
+
counts = client.counts
|
|
96
|
+
conversations = runner.conversations_api(workspace.name)
|
|
97
|
+
|
|
98
|
+
# Mark DMs as read
|
|
99
|
+
ims = counts["ims"] || []
|
|
100
|
+
dms_marked = 0
|
|
101
|
+
|
|
102
|
+
ims.each do |im|
|
|
103
|
+
next unless im["has_unreads"]
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
history = conversations.history(channel: im["id"], limit: 1)
|
|
107
|
+
if (messages = history["messages"]) && messages.any?
|
|
108
|
+
conversations.mark(channel: im["id"], ts: messages.first["ts"])
|
|
109
|
+
dms_marked += 1
|
|
110
|
+
end
|
|
111
|
+
rescue ApiError => e
|
|
112
|
+
debug("Could not mark DM #{im["id"]}: #{e.message}")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Mark channels as read
|
|
117
|
+
channels = counts["channels"] || []
|
|
118
|
+
channels_marked = 0
|
|
119
|
+
|
|
120
|
+
channels.each do |channel|
|
|
121
|
+
next unless channel["has_unreads"]
|
|
122
|
+
next if !@options[:muted] && channel["is_muted"]
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
history = conversations.history(channel: channel["id"], limit: 1)
|
|
126
|
+
if (messages = history["messages"]) && messages.any?
|
|
127
|
+
conversations.mark(channel: channel["id"], ts: messages.first["ts"])
|
|
128
|
+
channels_marked += 1
|
|
129
|
+
end
|
|
130
|
+
rescue ApiError => e
|
|
131
|
+
debug("Could not mark channel #{channel["id"]}: #{e.message}")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Mark threads as read
|
|
136
|
+
threads_api = runner.threads_api(workspace.name)
|
|
137
|
+
threads_response = threads_api.get_view(limit: 50)
|
|
138
|
+
threads_marked = 0
|
|
139
|
+
|
|
140
|
+
if threads_response["ok"]
|
|
141
|
+
(threads_response["threads"] || []).each do |thread|
|
|
142
|
+
unread_replies = thread["unread_replies"] || []
|
|
143
|
+
next if unread_replies.empty?
|
|
144
|
+
|
|
145
|
+
root_msg = thread["root_msg"] || {}
|
|
146
|
+
channel_id = root_msg["channel"]
|
|
147
|
+
thread_ts = root_msg["thread_ts"]
|
|
148
|
+
latest_ts = unread_replies.map { |r| r["ts"] }.max
|
|
149
|
+
|
|
150
|
+
begin
|
|
151
|
+
threads_api.mark(channel: channel_id, thread_ts: thread_ts, ts: latest_ts)
|
|
152
|
+
threads_marked += 1
|
|
153
|
+
rescue ApiError => e
|
|
154
|
+
debug("Could not mark thread #{thread_ts} in #{channel_id}: #{e.message}")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
success("Marked #{dms_marked} DMs, #{channels_marked} channels, and #{threads_marked} threads as read on #{workspace.name}")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
0
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def interactive_catchup
|
|
166
|
+
target_workspaces.each do |workspace|
|
|
167
|
+
result = process_workspace(workspace)
|
|
168
|
+
return 0 if result == :quit
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
puts
|
|
172
|
+
success("Catchup complete!")
|
|
173
|
+
0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def process_workspace(workspace)
|
|
177
|
+
client = runner.client_api(workspace.name)
|
|
178
|
+
counts = client.counts
|
|
179
|
+
|
|
180
|
+
# Get muted channels from user prefs unless --muted flag is set
|
|
181
|
+
muted_ids = @options[:muted] ? [] : runner.users_api(workspace.name).muted_channels
|
|
182
|
+
|
|
183
|
+
# Get unread DMs
|
|
184
|
+
ims = (counts["ims"] || [])
|
|
185
|
+
.select { |i| i["has_unreads"] }
|
|
186
|
+
|
|
187
|
+
# Get unread channels
|
|
188
|
+
channels = (counts["channels"] || [])
|
|
189
|
+
.select { |c| c["has_unreads"] || (c["mention_count"] || 0) > 0 }
|
|
190
|
+
.reject { |c| muted_ids.include?(c["id"]) }
|
|
191
|
+
|
|
192
|
+
# Check for unread threads
|
|
193
|
+
threads_api = runner.threads_api(workspace.name)
|
|
194
|
+
threads_response = threads_api.get_view(limit: 20)
|
|
195
|
+
has_threads = threads_response["ok"] && (threads_response["total_unread_replies"] || 0) > 0
|
|
196
|
+
|
|
197
|
+
total_items = ims.size + channels.size + (has_threads ? 1 : 0)
|
|
198
|
+
|
|
199
|
+
if ims.empty? && channels.empty? && !has_threads
|
|
200
|
+
puts "No unread messages in #{workspace.name}"
|
|
201
|
+
return :continue
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
puts output.bold("\n#{workspace.name}: #{total_items} items with unreads\n")
|
|
205
|
+
|
|
206
|
+
current_index = 0
|
|
207
|
+
|
|
208
|
+
# Process DMs first
|
|
209
|
+
ims.each do |im|
|
|
210
|
+
result = process_dm(workspace, im, current_index, total_items)
|
|
211
|
+
return :quit if result == :quit
|
|
212
|
+
current_index += 1
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Process threads
|
|
216
|
+
if has_threads
|
|
217
|
+
result = process_threads(workspace, threads_response, current_index, total_items)
|
|
218
|
+
return :quit if result == :quit
|
|
219
|
+
current_index += 1
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Process channels
|
|
223
|
+
channels.each do |channel|
|
|
224
|
+
result = process_channel(workspace, channel, current_index, total_items)
|
|
225
|
+
return :quit if result == :quit
|
|
226
|
+
current_index += 1
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
:continue
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def process_channel(workspace, channel, index, total)
|
|
233
|
+
channel_id = channel["id"]
|
|
234
|
+
channel_name = cache_store.get_channel_name(workspace.name, channel_id) || channel_id
|
|
235
|
+
mentions = channel["mention_count"] || 0
|
|
236
|
+
last_read = channel["last_read"]
|
|
237
|
+
latest_ts = channel["latest"] # Latest message timestamp for marking as read
|
|
238
|
+
|
|
239
|
+
# Fetch only unread messages (after last_read timestamp)
|
|
240
|
+
conversations = runner.conversations_api(workspace.name)
|
|
241
|
+
history_opts = { channel: channel_id, limit: @options[:limit] }
|
|
242
|
+
history_opts[:oldest] = last_read if last_read
|
|
243
|
+
history = conversations.history(**history_opts)
|
|
244
|
+
messages = (history["messages"] || []).reverse
|
|
245
|
+
|
|
246
|
+
# Display header
|
|
247
|
+
puts
|
|
248
|
+
puts output.bold("[#{index + 1}/#{total}] ##{channel_name}")
|
|
249
|
+
puts output.yellow("#{mentions} mentions") if mentions > 0
|
|
250
|
+
|
|
251
|
+
# Convert to model objects
|
|
252
|
+
message_objects = messages.map { |msg| Models::Message.from_api(msg, channel_id: channel_id) }
|
|
253
|
+
|
|
254
|
+
# Enrich with reaction timestamps if requested
|
|
255
|
+
if @options[:reaction_timestamps]
|
|
256
|
+
enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
|
|
257
|
+
message_objects = enricher.enrich_messages(message_objects, channel_id)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Display messages
|
|
261
|
+
format_options = {
|
|
262
|
+
no_emoji: @options[:no_emoji],
|
|
263
|
+
no_reactions: @options[:no_reactions],
|
|
264
|
+
workspace_emoji: @options[:workspace_emoji],
|
|
265
|
+
reaction_names: @options[:reaction_names],
|
|
266
|
+
reaction_timestamps: @options[:reaction_timestamps]
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
message_objects.each do |message|
|
|
270
|
+
formatted = runner.message_formatter.format_simple(message, workspace: workspace, options: format_options)
|
|
271
|
+
puts " #{formatted}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Prompt for action (loop until valid key)
|
|
275
|
+
prompt = output.cyan("[s]kip [r]ead [o]pen [q]uit")
|
|
276
|
+
loop do
|
|
277
|
+
input = prompt_for_action(prompt)
|
|
278
|
+
result = handle_channel_action(input, workspace, channel_id, latest_ts, conversations)
|
|
279
|
+
return result if result
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def process_dm(workspace, im, index, total)
|
|
284
|
+
channel_id = im["id"]
|
|
285
|
+
last_read = im["last_read"]
|
|
286
|
+
latest_ts = im["latest"] # Latest message timestamp for marking as read
|
|
287
|
+
mention_count = im["mention_count"] || 0
|
|
288
|
+
|
|
289
|
+
# Get user info from conversation
|
|
290
|
+
conversations = runner.conversations_api(workspace.name)
|
|
291
|
+
user_name = resolve_dm_user_name(workspace, channel_id, conversations)
|
|
292
|
+
|
|
293
|
+
# Fetch only unread messages (after last_read timestamp)
|
|
294
|
+
history_opts = { channel: channel_id, limit: @options[:limit] }
|
|
295
|
+
history_opts[:oldest] = last_read if last_read
|
|
296
|
+
history = conversations.history(**history_opts)
|
|
297
|
+
messages = (history["messages"] || []).reverse
|
|
298
|
+
|
|
299
|
+
# Display header
|
|
300
|
+
puts
|
|
301
|
+
puts output.bold("[#{index + 1}/#{total}] @#{user_name}")
|
|
302
|
+
puts output.yellow("#{mention_count} mentions") if mention_count > 0
|
|
303
|
+
|
|
304
|
+
# Convert to model objects
|
|
305
|
+
message_objects = messages.map { |msg| Models::Message.from_api(msg, channel_id: channel_id) }
|
|
306
|
+
|
|
307
|
+
# Enrich with reaction timestamps if requested
|
|
308
|
+
if @options[:reaction_timestamps]
|
|
309
|
+
enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
|
|
310
|
+
message_objects = enricher.enrich_messages(message_objects, channel_id)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Display messages
|
|
314
|
+
format_options = {
|
|
315
|
+
no_emoji: @options[:no_emoji],
|
|
316
|
+
no_reactions: @options[:no_reactions],
|
|
317
|
+
workspace_emoji: @options[:workspace_emoji],
|
|
318
|
+
reaction_names: @options[:reaction_names],
|
|
319
|
+
reaction_timestamps: @options[:reaction_timestamps]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
message_objects.each do |message|
|
|
323
|
+
formatted = runner.message_formatter.format_simple(message, workspace: workspace, options: format_options)
|
|
324
|
+
puts " #{formatted}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Prompt for action (loop until valid key)
|
|
328
|
+
prompt = output.cyan("[s]kip [r]ead [o]pen [q]uit")
|
|
329
|
+
loop do
|
|
330
|
+
input = prompt_for_action(prompt)
|
|
331
|
+
result = handle_channel_action(input, workspace, channel_id, latest_ts, conversations)
|
|
332
|
+
return result if result
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def prompt_for_action(prompt)
|
|
337
|
+
print "\n#{prompt} > "
|
|
338
|
+
input = read_single_char
|
|
339
|
+
puts
|
|
340
|
+
input
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def handle_channel_action(input, workspace, channel_id, latest_ts, conversations)
|
|
344
|
+
case input&.downcase
|
|
345
|
+
when "s", "\r", "\n", nil
|
|
346
|
+
:next
|
|
347
|
+
when "\u0003", "\u0004" # Ctrl-C, Ctrl-D
|
|
348
|
+
:quit
|
|
349
|
+
when "r"
|
|
350
|
+
# Mark as read using the latest message timestamp
|
|
351
|
+
if latest_ts
|
|
352
|
+
conversations.mark(channel: channel_id, ts: latest_ts)
|
|
353
|
+
success("Marked as read")
|
|
354
|
+
end
|
|
355
|
+
:next
|
|
356
|
+
when "o"
|
|
357
|
+
# Open in Slack (macOS)
|
|
358
|
+
team_id = runner.client_api(workspace.name).team_id
|
|
359
|
+
url = "slack://channel?team=#{team_id}&id=#{channel_id}"
|
|
360
|
+
system("open", url)
|
|
361
|
+
success("Opened in Slack")
|
|
362
|
+
:next
|
|
363
|
+
when "q"
|
|
364
|
+
:quit
|
|
365
|
+
else
|
|
366
|
+
print "\r#{output.red("Invalid key")} - #{output.cyan("[s]kip [r]ead [o]pen [q]uit")}"
|
|
367
|
+
nil # Return nil to continue loop
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def process_threads(workspace, threads_response, index, total)
|
|
372
|
+
total_unreads = threads_response["total_unread_replies"] || 0
|
|
373
|
+
threads = threads_response["threads"] || []
|
|
374
|
+
|
|
375
|
+
format_options = {
|
|
376
|
+
no_emoji: @options[:no_emoji],
|
|
377
|
+
no_reactions: @options[:no_reactions],
|
|
378
|
+
workspace_emoji: @options[:workspace_emoji],
|
|
379
|
+
reaction_names: @options[:reaction_names],
|
|
380
|
+
reaction_timestamps: @options[:reaction_timestamps]
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
# Display header
|
|
384
|
+
puts
|
|
385
|
+
puts output.bold("[#{index + 1}/#{total}] 🧵 Threads (#{total_unreads} unread replies)")
|
|
386
|
+
|
|
387
|
+
# Display threads and track for marking
|
|
388
|
+
thread_mark_data = []
|
|
389
|
+
|
|
390
|
+
threads.each do |thread|
|
|
391
|
+
unread_replies = thread["unread_replies"] || []
|
|
392
|
+
next if unread_replies.empty?
|
|
393
|
+
|
|
394
|
+
root_msg = thread["root_msg"] || {}
|
|
395
|
+
channel_id = root_msg["channel"]
|
|
396
|
+
thread_ts = root_msg["thread_ts"]
|
|
397
|
+
conversation_label = resolve_conversation_label(workspace, channel_id)
|
|
398
|
+
|
|
399
|
+
# Get root user name
|
|
400
|
+
root_user = extract_user_from_message(root_msg, workspace)
|
|
401
|
+
|
|
402
|
+
puts output.blue(" #{conversation_label}") + " - thread by " + output.bold(root_user)
|
|
403
|
+
|
|
404
|
+
# Convert to model objects
|
|
405
|
+
message_objects = unread_replies.map { |reply| Models::Message.from_api(reply, channel_id: channel_id) }
|
|
406
|
+
|
|
407
|
+
# Enrich with reaction timestamps if requested
|
|
408
|
+
if @options[:reaction_timestamps]
|
|
409
|
+
enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
|
|
410
|
+
message_objects = enricher.enrich_messages(message_objects, channel_id)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Display unread replies
|
|
414
|
+
message_objects.each do |message|
|
|
415
|
+
formatted = runner.message_formatter.format_simple(message, workspace: workspace, options: format_options)
|
|
416
|
+
puts " #{formatted}"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Track latest reply ts for marking
|
|
420
|
+
latest_ts = unread_replies.map { |r| r["ts"] }.max
|
|
421
|
+
thread_mark_data << { channel: channel_id, thread_ts: thread_ts, ts: latest_ts }
|
|
422
|
+
|
|
423
|
+
puts
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Prompt for action (loop until valid key)
|
|
427
|
+
prompt = output.cyan("[s]kip [r]ead [o]pen [q]uit")
|
|
428
|
+
loop do
|
|
429
|
+
input = prompt_for_action(prompt)
|
|
430
|
+
result = handle_threads_action(input, workspace, thread_mark_data)
|
|
431
|
+
return result if result
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def handle_threads_action(input, workspace, thread_mark_data)
|
|
436
|
+
case input&.downcase
|
|
437
|
+
when "s", "\r", "\n", nil
|
|
438
|
+
:next
|
|
439
|
+
when "\u0003", "\u0004" # Ctrl-C, Ctrl-D
|
|
440
|
+
:quit
|
|
441
|
+
when "r"
|
|
442
|
+
# Mark all threads as read
|
|
443
|
+
threads_api = runner.threads_api(workspace.name)
|
|
444
|
+
marked = 0
|
|
445
|
+
thread_mark_data.each do |data|
|
|
446
|
+
begin
|
|
447
|
+
threads_api.mark(channel: data[:channel], thread_ts: data[:thread_ts], ts: data[:ts])
|
|
448
|
+
marked += 1
|
|
449
|
+
rescue ApiError => e
|
|
450
|
+
debug("Could not mark thread #{data[:thread_ts]} in #{data[:channel]}: #{e.message}")
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
success("Marked #{marked} thread(s) as read")
|
|
454
|
+
:next
|
|
455
|
+
when "o"
|
|
456
|
+
# Open first thread in Slack
|
|
457
|
+
if thread_mark_data.any?
|
|
458
|
+
first = thread_mark_data.first
|
|
459
|
+
team_id = runner.client_api(workspace.name).team_id
|
|
460
|
+
url = "slack://channel?team=#{team_id}&id=#{first[:channel]}&thread_ts=#{first[:thread_ts]}"
|
|
461
|
+
system("open", url)
|
|
462
|
+
success("Opened in Slack")
|
|
463
|
+
end
|
|
464
|
+
:next
|
|
465
|
+
when "q"
|
|
466
|
+
:quit
|
|
467
|
+
else
|
|
468
|
+
print "\r#{output.red("Invalid key")} - #{output.cyan("[s]kip [r]ead [o]pen [q]uit")}"
|
|
469
|
+
nil # Return nil to continue loop
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def read_single_char
|
|
474
|
+
if $stdin.tty?
|
|
475
|
+
$stdin.raw { |io| io.readchar }
|
|
476
|
+
else
|
|
477
|
+
$stdin.gets&.chomp
|
|
478
|
+
end
|
|
479
|
+
rescue Interrupt
|
|
480
|
+
"q"
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/help_formatter"
|
|
4
|
+
|
|
5
|
+
module SlackCli
|
|
6
|
+
module Commands
|
|
7
|
+
class Config < Base
|
|
8
|
+
def execute
|
|
9
|
+
result = validate_options
|
|
10
|
+
return result if result
|
|
11
|
+
|
|
12
|
+
case positional_args
|
|
13
|
+
in ["show"] | []
|
|
14
|
+
show_config
|
|
15
|
+
in ["setup"]
|
|
16
|
+
run_setup
|
|
17
|
+
in ["get", key]
|
|
18
|
+
get_value(key)
|
|
19
|
+
in ["set", key, value]
|
|
20
|
+
set_value(key, value)
|
|
21
|
+
else
|
|
22
|
+
run_setup
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
protected
|
|
27
|
+
|
|
28
|
+
def help_text
|
|
29
|
+
help = Support::HelpFormatter.new("slk config [action]")
|
|
30
|
+
help.description("Manage configuration.")
|
|
31
|
+
|
|
32
|
+
help.section("ACTIONS") do |s|
|
|
33
|
+
s.action("show", "Show current configuration")
|
|
34
|
+
s.action("setup", "Run setup wizard")
|
|
35
|
+
s.action("get <key>", "Get a config value")
|
|
36
|
+
s.action("set <key> <val>", "Set a config value")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
help.section("CONFIG KEYS") do |s|
|
|
40
|
+
s.item("primary_workspace", "Default workspace name")
|
|
41
|
+
s.item("ssh_key", "Path to SSH key for encryption")
|
|
42
|
+
s.item("emoji_dir", "Custom emoji directory")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
help.section("OPTIONS") do |s|
|
|
46
|
+
s.option("-q, --quiet", "Suppress output")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
help.render
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def show_config
|
|
55
|
+
puts "Configuration:"
|
|
56
|
+
puts " Primary workspace: #{config.primary_workspace || "(not set)"}"
|
|
57
|
+
puts " SSH key: #{config.ssh_key || "(not set)"}"
|
|
58
|
+
puts " Emoji dir: #{config.emoji_dir || "(default)"}"
|
|
59
|
+
puts
|
|
60
|
+
puts "Workspaces: #{runner.workspace_names.join(", ")}"
|
|
61
|
+
puts
|
|
62
|
+
paths = Support::XdgPaths.new
|
|
63
|
+
puts "Config dir: #{paths.config_dir}"
|
|
64
|
+
puts "Cache dir: #{paths.cache_dir}"
|
|
65
|
+
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_setup
|
|
70
|
+
puts "Slack CLI Setup"
|
|
71
|
+
puts "==============="
|
|
72
|
+
puts
|
|
73
|
+
|
|
74
|
+
# Check for existing config
|
|
75
|
+
if runner.has_workspaces?
|
|
76
|
+
puts "You already have workspaces configured."
|
|
77
|
+
print "Add another workspace? (y/n): "
|
|
78
|
+
answer = $stdin.gets&.chomp&.downcase
|
|
79
|
+
return 0 unless answer == "y"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Setup encryption
|
|
83
|
+
if config.ssh_key.nil?
|
|
84
|
+
puts
|
|
85
|
+
puts "Encryption Setup (optional)"
|
|
86
|
+
puts "----------------------------"
|
|
87
|
+
puts "You can encrypt your tokens with age using an SSH key."
|
|
88
|
+
print "SSH key path (or press Enter to skip): "
|
|
89
|
+
ssh_key = $stdin.gets&.chomp
|
|
90
|
+
|
|
91
|
+
unless ssh_key.nil? || ssh_key.empty?
|
|
92
|
+
if File.exist?(ssh_key)
|
|
93
|
+
config.ssh_key = ssh_key
|
|
94
|
+
success("SSH key configured")
|
|
95
|
+
else
|
|
96
|
+
warn("File not found: #{ssh_key}")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Add workspace
|
|
102
|
+
puts
|
|
103
|
+
puts "Workspace Setup"
|
|
104
|
+
puts "---------------"
|
|
105
|
+
|
|
106
|
+
print "Workspace name: "
|
|
107
|
+
name = $stdin.gets&.chomp
|
|
108
|
+
return error("Name is required") if name.nil? || name.empty?
|
|
109
|
+
|
|
110
|
+
print "Token (xoxb-... or xoxc-...): "
|
|
111
|
+
token = $stdin.gets&.chomp
|
|
112
|
+
return error("Token is required") if token.nil? || token.empty?
|
|
113
|
+
|
|
114
|
+
cookie = nil
|
|
115
|
+
if token.start_with?("xoxc-")
|
|
116
|
+
puts
|
|
117
|
+
puts "xoxc tokens require a cookie for authentication."
|
|
118
|
+
print "Cookie (d=...): "
|
|
119
|
+
cookie = $stdin.gets&.chomp
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
token_store.add(name, token, cookie)
|
|
123
|
+
|
|
124
|
+
# Set as primary if first
|
|
125
|
+
if config.primary_workspace.nil?
|
|
126
|
+
config.primary_workspace = name
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
puts
|
|
130
|
+
success("Setup complete!")
|
|
131
|
+
puts
|
|
132
|
+
puts "Try these commands:"
|
|
133
|
+
puts " slack status - View your status"
|
|
134
|
+
puts " slack messages #general - Read channel messages"
|
|
135
|
+
puts " slack help - See all commands"
|
|
136
|
+
|
|
137
|
+
0
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def get_value(key)
|
|
141
|
+
value = config[key]
|
|
142
|
+
if value
|
|
143
|
+
puts value
|
|
144
|
+
else
|
|
145
|
+
puts "(not set)"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
0
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def set_value(key, value)
|
|
152
|
+
config[key] = value
|
|
153
|
+
success("Set #{key} = #{value}")
|
|
154
|
+
|
|
155
|
+
0
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|