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,429 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Formatters
|
|
5
|
+
class MessageFormatter
|
|
6
|
+
def initialize(output:, mention_replacer:, emoji_replacer:, cache_store:, api_client: nil, on_debug: nil)
|
|
7
|
+
@output = output
|
|
8
|
+
@mentions = mention_replacer
|
|
9
|
+
@emoji = emoji_replacer
|
|
10
|
+
@cache = cache_store
|
|
11
|
+
@api_client = api_client
|
|
12
|
+
@on_debug = on_debug
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format(message, workspace:, options: {})
|
|
16
|
+
username = resolve_username(message, workspace, options)
|
|
17
|
+
timestamp = format_timestamp(message.timestamp)
|
|
18
|
+
text = process_text(message.text, workspace, options)
|
|
19
|
+
|
|
20
|
+
# Build the header: [timestamp] username:
|
|
21
|
+
header = "#{@output.blue("[#{timestamp}]")} #{@output.bold(username)}:"
|
|
22
|
+
header_visible_width = visible_length("[#{timestamp}] #{username}: ")
|
|
23
|
+
|
|
24
|
+
# Preserve newlines in message text (just strip leading/trailing whitespace)
|
|
25
|
+
display_text = text.strip
|
|
26
|
+
|
|
27
|
+
# Wrap text if width is specified
|
|
28
|
+
width = options[:width]
|
|
29
|
+
if width && width > header_visible_width && !display_text.empty?
|
|
30
|
+
# First line has less space (width minus header), continuation lines use full width
|
|
31
|
+
first_line_width = width - header_visible_width
|
|
32
|
+
display_text = wrap_text(display_text, first_line_width, width)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# If no text but there are files, put first file inline with header
|
|
36
|
+
if display_text.empty? && message.has_files? && !options[:no_files]
|
|
37
|
+
first_file = message.files.first
|
|
38
|
+
file_name = first_file['name'] || 'file'
|
|
39
|
+
display_text = @output.blue("[File: #{file_name}]")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
main_line = "#{header} #{display_text}"
|
|
43
|
+
|
|
44
|
+
lines = [main_line]
|
|
45
|
+
|
|
46
|
+
format_blocks(message, lines, workspace, options)
|
|
47
|
+
format_attachments(message, lines, workspace, options)
|
|
48
|
+
format_files(message, lines, options, skip_first: display_text.include?('[File:'))
|
|
49
|
+
format_reactions(message, lines, workspace, options)
|
|
50
|
+
format_thread_indicator(message, lines, options)
|
|
51
|
+
|
|
52
|
+
lines.join("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def format_simple(message, workspace:, options: {})
|
|
56
|
+
username = resolve_username(message, workspace, options)
|
|
57
|
+
timestamp = format_timestamp(message.timestamp)
|
|
58
|
+
text = process_text(message.text, workspace, options)
|
|
59
|
+
|
|
60
|
+
reaction_text = ""
|
|
61
|
+
unless options[:no_reactions] || message.reactions.empty?
|
|
62
|
+
reaction_text = format_reaction_inline(message, options)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
"#{@output.blue("[#{timestamp}]")} #{@output.bold(username)}: #{text}#{reaction_text}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def format_reaction_inline(message, options)
|
|
69
|
+
parts = message.reactions.map do |r|
|
|
70
|
+
emoji = options[:no_emoji] ? r.emoji_code : (@emoji.lookup_emoji(r.name) || r.emoji_code)
|
|
71
|
+
"#{r.count} #{emoji}"
|
|
72
|
+
end
|
|
73
|
+
" [#{parts.join(", ")}]"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def format_json(message, workspace: nil, options: {})
|
|
77
|
+
reactions_json = message.reactions.map do |r|
|
|
78
|
+
reaction_hash = { name: r.name, count: r.count }
|
|
79
|
+
|
|
80
|
+
# Always return user objects with id, name (if available), and reacted_at (if available)
|
|
81
|
+
reaction_hash[:users] = r.users.map do |user_id|
|
|
82
|
+
user_hash = { id: user_id }
|
|
83
|
+
|
|
84
|
+
# Try to resolve display name
|
|
85
|
+
unless options[:no_names]
|
|
86
|
+
workspace_name = workspace&.name
|
|
87
|
+
if workspace_name
|
|
88
|
+
cached_name = @cache.get_user(workspace_name, user_id)
|
|
89
|
+
user_hash[:name] = cached_name if cached_name
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Add timestamp if available
|
|
94
|
+
if r.has_timestamps?
|
|
95
|
+
timestamp = r.timestamp_for(user_id)
|
|
96
|
+
if timestamp
|
|
97
|
+
user_hash[:reacted_at] = timestamp
|
|
98
|
+
user_hash[:reacted_at_iso8601] = Time.at(timestamp.to_f).iso8601
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
user_hash
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
reaction_hash
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
ts: message.ts,
|
|
110
|
+
user: message.user_id,
|
|
111
|
+
text: message.text,
|
|
112
|
+
reactions: reactions_json,
|
|
113
|
+
reply_count: message.reply_count,
|
|
114
|
+
thread_ts: message.thread_ts,
|
|
115
|
+
attachments: message.attachments,
|
|
116
|
+
files: message.files
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def resolve_username(message, workspace, options = {})
|
|
123
|
+
# Skip lookups if --no-names
|
|
124
|
+
return message.user_id if options[:no_names]
|
|
125
|
+
|
|
126
|
+
# Try embedded profile first
|
|
127
|
+
return message.embedded_username if message.embedded_username
|
|
128
|
+
|
|
129
|
+
# Try cache
|
|
130
|
+
cached = @cache.get_user(workspace.name, message.user_id)
|
|
131
|
+
return cached if cached
|
|
132
|
+
|
|
133
|
+
# For bot IDs (start with B), try bots.info API
|
|
134
|
+
if message.user_id.start_with?("B") && @api_client
|
|
135
|
+
bot_name = lookup_bot_name(workspace, message.user_id)
|
|
136
|
+
return bot_name if bot_name
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Fall back to ID
|
|
140
|
+
message.user_id
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def lookup_bot_name(workspace, bot_id)
|
|
144
|
+
bots_api = Api::Bots.new(@api_client, workspace, on_debug: @on_debug)
|
|
145
|
+
name = bots_api.get_name(bot_id)
|
|
146
|
+
if name
|
|
147
|
+
# Cache for future lookups (persist to disk)
|
|
148
|
+
@cache.set_user(workspace.name, bot_id, name, persist: true)
|
|
149
|
+
end
|
|
150
|
+
name
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def format_timestamp(time)
|
|
154
|
+
time.strftime("%Y-%m-%d %H:%M")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def process_text(text, workspace, options)
|
|
158
|
+
result = text.dup
|
|
159
|
+
|
|
160
|
+
# Decode HTML entities (Slack encodes these)
|
|
161
|
+
result = decode_html_entities(result)
|
|
162
|
+
|
|
163
|
+
# Replace mentions
|
|
164
|
+
result = @mentions.replace(result, workspace)
|
|
165
|
+
|
|
166
|
+
# Replace emoji (unless disabled)
|
|
167
|
+
unless options[:no_emoji]
|
|
168
|
+
result = @emoji.replace(result, workspace)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
result
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def decode_html_entities(text)
|
|
175
|
+
text
|
|
176
|
+
.gsub('&', '&')
|
|
177
|
+
.gsub('<', '<')
|
|
178
|
+
.gsub('>', '>')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Calculate visible length of text (excluding ANSI escape codes)
|
|
182
|
+
def visible_length(text)
|
|
183
|
+
text.gsub(/\e\[[0-9;]*m/, '').length
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Wrap text to width, handling first line differently and preserving existing newlines
|
|
187
|
+
def wrap_text(text, first_line_width, continuation_width)
|
|
188
|
+
result = []
|
|
189
|
+
|
|
190
|
+
text.each_line do |paragraph|
|
|
191
|
+
paragraph = paragraph.chomp
|
|
192
|
+
if paragraph.empty?
|
|
193
|
+
result << ''
|
|
194
|
+
next
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# For each paragraph, wrap to width
|
|
198
|
+
# First paragraph's first line uses first_line_width, all other lines use continuation_width
|
|
199
|
+
current_first_width = result.empty? ? first_line_width : continuation_width
|
|
200
|
+
wrapped = wrap_paragraph(paragraph, current_first_width, continuation_width)
|
|
201
|
+
result << wrapped
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
result.join("\n")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Wrap a single paragraph (no internal newlines)
|
|
208
|
+
def wrap_paragraph(text, first_width, rest_width)
|
|
209
|
+
words = text.split(/(\s+)/)
|
|
210
|
+
lines = []
|
|
211
|
+
current_line = ''
|
|
212
|
+
current_width = first_width
|
|
213
|
+
|
|
214
|
+
words.each do |word|
|
|
215
|
+
word_len = visible_length(word)
|
|
216
|
+
|
|
217
|
+
if current_line.empty?
|
|
218
|
+
current_line = word
|
|
219
|
+
elsif visible_length(current_line) + word_len <= current_width
|
|
220
|
+
current_line += word
|
|
221
|
+
else
|
|
222
|
+
lines << current_line
|
|
223
|
+
current_line = word.lstrip
|
|
224
|
+
current_width = rest_width
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
lines << current_line unless current_line.empty?
|
|
229
|
+
|
|
230
|
+
lines.join("\n")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def format_header(timestamp, username, message, options)
|
|
234
|
+
parts = []
|
|
235
|
+
parts << @output.blue("[#{timestamp}]")
|
|
236
|
+
parts << @output.bold(username)
|
|
237
|
+
|
|
238
|
+
if message.is_reply? && !options[:in_thread]
|
|
239
|
+
parts << @output.cyan("(reply)")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
parts.join(" ")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def indent(text, spaces: 4)
|
|
246
|
+
prefix = " " * spaces
|
|
247
|
+
text.lines.map { |line| "#{prefix}#{line.chomp}" }.join("\n")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def format_attachments(message, lines, workspace, options)
|
|
251
|
+
return if message.attachments.empty?
|
|
252
|
+
return if options[:no_attachments]
|
|
253
|
+
|
|
254
|
+
message.attachments.each do |att|
|
|
255
|
+
att_text = att['text'] || att['fallback']
|
|
256
|
+
image_url = att['image_url'] || att['thumb_url']
|
|
257
|
+
title = att['title']
|
|
258
|
+
|
|
259
|
+
# Skip if no text and no image
|
|
260
|
+
next unless att_text || image_url
|
|
261
|
+
|
|
262
|
+
# Blank line before attachment
|
|
263
|
+
lines << ''
|
|
264
|
+
|
|
265
|
+
# Show author if available (for linked messages, bot messages, etc.)
|
|
266
|
+
author = att['author_name'] || att['author_subname']
|
|
267
|
+
lines << "> #{@output.bold(author)}:" if author
|
|
268
|
+
|
|
269
|
+
# Show text content if present
|
|
270
|
+
if att_text
|
|
271
|
+
# Process attachment text through the same pipeline as message text
|
|
272
|
+
processed_text = process_text(att_text, workspace, options)
|
|
273
|
+
|
|
274
|
+
# Wrap attachment text if width is specified (account for "> " prefix)
|
|
275
|
+
width = options[:width]
|
|
276
|
+
if width && width > 2
|
|
277
|
+
processed_text = wrap_text(processed_text, width - 2, width - 2)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Prefix each line with > to show it's quoted/attachment content
|
|
281
|
+
processed_text.each_line do |line|
|
|
282
|
+
lines << "> #{line.chomp}"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Show image info if present
|
|
287
|
+
if image_url
|
|
288
|
+
# Extract filename from URL or use title
|
|
289
|
+
filename = title || extract_filename_from_url(image_url)
|
|
290
|
+
lines << "> [Image: #{filename}]"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def format_blocks(message, lines, workspace, options)
|
|
296
|
+
return unless message.has_blocks?
|
|
297
|
+
return if options[:no_blocks]
|
|
298
|
+
|
|
299
|
+
# Extract text content from blocks (skip if it duplicates the main text)
|
|
300
|
+
block_texts = extract_block_texts(message.blocks)
|
|
301
|
+
return if block_texts.empty?
|
|
302
|
+
|
|
303
|
+
# Don't show blocks if they just repeat the main message text
|
|
304
|
+
main_text_normalized = message.text.gsub(/\s+/, ' ').strip.downcase
|
|
305
|
+
block_texts.reject! do |bt|
|
|
306
|
+
bt.gsub(/\s+/, ' ').strip.downcase == main_text_normalized
|
|
307
|
+
end
|
|
308
|
+
return if block_texts.empty?
|
|
309
|
+
|
|
310
|
+
# Blank line before blocks
|
|
311
|
+
lines << ''
|
|
312
|
+
|
|
313
|
+
block_texts.each do |block_text|
|
|
314
|
+
# Process text through mention/emoji pipeline
|
|
315
|
+
processed = process_text(block_text, workspace, options)
|
|
316
|
+
|
|
317
|
+
# Wrap if width specified (account for "> " prefix)
|
|
318
|
+
width = options[:width]
|
|
319
|
+
if width && width > 2
|
|
320
|
+
processed = wrap_text(processed, width - 2, width - 2)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Prefix each line with >
|
|
324
|
+
processed.each_line do |line|
|
|
325
|
+
lines << "> #{line.chomp}"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def extract_block_texts(blocks)
|
|
331
|
+
return [] unless blocks.is_a?(Array)
|
|
332
|
+
|
|
333
|
+
blocks.filter_map do |block|
|
|
334
|
+
next unless block['type'] == 'section'
|
|
335
|
+
|
|
336
|
+
block.dig('text', 'text')
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Extract filename from a URL path, returning 'image' if parsing fails
|
|
341
|
+
def extract_filename_from_url(url)
|
|
342
|
+
File.basename(URI.parse(url).path)
|
|
343
|
+
rescue URI::InvalidURIError
|
|
344
|
+
'image'
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def format_files(message, lines, options, skip_first: false)
|
|
348
|
+
return if message.files.empty?
|
|
349
|
+
return if options[:no_files]
|
|
350
|
+
|
|
351
|
+
files_to_show = skip_first ? message.files[1..] : message.files
|
|
352
|
+
return if files_to_show.nil? || files_to_show.empty?
|
|
353
|
+
|
|
354
|
+
files_to_show.each do |file|
|
|
355
|
+
name = file['name'] || 'file'
|
|
356
|
+
lines << @output.blue("[File: #{name}]")
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def format_reactions(message, lines, workspace, options)
|
|
361
|
+
return if message.reactions.empty?
|
|
362
|
+
return if options[:no_reactions]
|
|
363
|
+
|
|
364
|
+
# Check if we should show timestamps and if any reactions have them
|
|
365
|
+
if options[:reaction_timestamps] && message.reactions.any?(&:has_timestamps?)
|
|
366
|
+
format_reactions_with_timestamps(message, lines, workspace, options)
|
|
367
|
+
else
|
|
368
|
+
# Standard reaction display
|
|
369
|
+
reaction_text = message.reactions.map do |r|
|
|
370
|
+
emoji = options[:no_emoji] ? r.emoji_code : (@emoji.lookup_emoji(r.name) || r.emoji_code)
|
|
371
|
+
"#{r.count} #{emoji}"
|
|
372
|
+
end.join(" ")
|
|
373
|
+
|
|
374
|
+
lines << @output.yellow("[#{reaction_text}]")
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def format_reactions_with_timestamps(message, lines, workspace, options)
|
|
379
|
+
workspace_name = workspace&.name
|
|
380
|
+
|
|
381
|
+
message.reactions.each do |reaction|
|
|
382
|
+
emoji = options[:no_emoji] ? reaction.emoji_code : (@emoji.lookup_emoji(reaction.name) || reaction.emoji_code)
|
|
383
|
+
|
|
384
|
+
# Group users with their timestamps
|
|
385
|
+
user_strings = reaction.users.map do |user_id|
|
|
386
|
+
username = resolve_user_for_reaction(user_id, workspace_name, options)
|
|
387
|
+
timestamp = reaction.timestamp_for(user_id)
|
|
388
|
+
|
|
389
|
+
if timestamp
|
|
390
|
+
time_str = format_reaction_time(timestamp)
|
|
391
|
+
"#{username} (#{time_str})"
|
|
392
|
+
else
|
|
393
|
+
username
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
lines << @output.yellow(" ↳ #{emoji} #{user_strings.join(', ')}")
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def resolve_user_for_reaction(user_id, workspace_name, options)
|
|
402
|
+
return user_id if options[:no_names]
|
|
403
|
+
|
|
404
|
+
# Try cache lookup
|
|
405
|
+
if workspace_name
|
|
406
|
+
cached = @cache.get_user(workspace_name, user_id)
|
|
407
|
+
return cached if cached
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Fall back to user ID
|
|
411
|
+
user_id
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def format_reaction_time(slack_timestamp)
|
|
415
|
+
time = Time.at(slack_timestamp.to_f)
|
|
416
|
+
time.strftime("%-I:%M %p") # e.g., "2:45 PM"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def format_thread_indicator(message, lines, options)
|
|
420
|
+
return unless message.has_thread?
|
|
421
|
+
return if options[:in_thread]
|
|
422
|
+
return if options[:no_threads]
|
|
423
|
+
|
|
424
|
+
reply_text = message.reply_count == 1 ? "1 reply" : "#{message.reply_count} replies"
|
|
425
|
+
lines << @output.cyan("[#{reply_text}]")
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Formatters
|
|
5
|
+
class Output
|
|
6
|
+
COLORS = {
|
|
7
|
+
red: "\e[0;31m",
|
|
8
|
+
green: "\e[0;32m",
|
|
9
|
+
yellow: "\e[0;33m",
|
|
10
|
+
blue: "\e[0;34m",
|
|
11
|
+
magenta: "\e[0;35m",
|
|
12
|
+
cyan: "\e[0;36m",
|
|
13
|
+
gray: "\e[0;90m",
|
|
14
|
+
bold: "\e[1m",
|
|
15
|
+
reset: "\e[0m"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :verbose, :quiet
|
|
19
|
+
|
|
20
|
+
def initialize(io: $stdout, err: $stderr, color: nil, verbose: false, quiet: false)
|
|
21
|
+
@io = io
|
|
22
|
+
@err = err
|
|
23
|
+
@color = color.nil? ? io.tty? : color
|
|
24
|
+
@verbose = verbose
|
|
25
|
+
@quiet = quiet
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def puts(message = "")
|
|
29
|
+
@io.puts(message) unless @quiet
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def print(message)
|
|
33
|
+
@io.print(message) unless @quiet
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def error(message)
|
|
37
|
+
@err.puts(colorize("#{red("Error:")} #{message}"))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def warn(message)
|
|
41
|
+
@err.puts(colorize("#{yellow("Warning:")} #{message}")) unless @quiet
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def success(message)
|
|
45
|
+
puts(colorize("#{green("✓")} #{message}"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def info(message)
|
|
49
|
+
puts(colorize(message))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def debug(message)
|
|
53
|
+
return unless @verbose
|
|
54
|
+
|
|
55
|
+
@err.puts(colorize("#{gray("[debug]")} #{message}"))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Color helpers
|
|
59
|
+
def red(text) = wrap(:red, text)
|
|
60
|
+
def green(text) = wrap(:green, text)
|
|
61
|
+
def yellow(text) = wrap(:yellow, text)
|
|
62
|
+
def blue(text) = wrap(:blue, text)
|
|
63
|
+
def magenta(text) = wrap(:magenta, text)
|
|
64
|
+
def cyan(text) = wrap(:cyan, text)
|
|
65
|
+
def gray(text) = wrap(:gray, text)
|
|
66
|
+
def bold(text) = wrap(:bold, text)
|
|
67
|
+
|
|
68
|
+
def with_verbose(value)
|
|
69
|
+
self.class.new(io: @io, err: @err, color: @color, verbose: value, quiet: @quiet)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def with_quiet(value)
|
|
73
|
+
self.class.new(io: @io, err: @err, color: @color, verbose: @verbose, quiet: value)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def wrap(color, text)
|
|
79
|
+
return text.to_s unless @color
|
|
80
|
+
|
|
81
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def colorize(text)
|
|
85
|
+
text
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Models
|
|
5
|
+
Channel = Data.define(:id, :name, :is_private, :is_im, :is_mpim, :is_archived) do
|
|
6
|
+
def self.from_api(data)
|
|
7
|
+
new(
|
|
8
|
+
id: data["id"],
|
|
9
|
+
name: data["name"] || data["name_normalized"],
|
|
10
|
+
is_private: data["is_private"] || false,
|
|
11
|
+
is_im: data["is_im"] || false,
|
|
12
|
+
is_mpim: data["is_mpim"] || false,
|
|
13
|
+
is_archived: data["is_archived"] || false
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(id:, name: nil, is_private: false, is_im: false, is_mpim: false, is_archived: false)
|
|
18
|
+
super(
|
|
19
|
+
id: id.to_s.freeze,
|
|
20
|
+
name: name&.freeze,
|
|
21
|
+
is_private: is_private,
|
|
22
|
+
is_im: is_im,
|
|
23
|
+
is_mpim: is_mpim,
|
|
24
|
+
is_archived: is_archived
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dm?
|
|
29
|
+
is_im || is_mpim
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def public?
|
|
33
|
+
!is_private && !dm?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def display_name
|
|
37
|
+
return name if name
|
|
38
|
+
|
|
39
|
+
case id[0]
|
|
40
|
+
when "C" then "#channel"
|
|
41
|
+
when "G" then "#private"
|
|
42
|
+
when "D" then "DM"
|
|
43
|
+
else id
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_s
|
|
48
|
+
dm? ? display_name : "##{name || id}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Models
|
|
5
|
+
Duration = Data.define(:seconds) do
|
|
6
|
+
class << self
|
|
7
|
+
def parse(input)
|
|
8
|
+
return new(seconds: 0) if input.nil? || input.to_s.strip.empty?
|
|
9
|
+
return new(seconds: input.to_i) if input.to_s.match?(/^\d+$/)
|
|
10
|
+
|
|
11
|
+
total = 0
|
|
12
|
+
str = input.to_s.downcase
|
|
13
|
+
|
|
14
|
+
# Check for duplicate units (e.g., "1h1h" is invalid)
|
|
15
|
+
raise ArgumentError, "Duplicate 'h' unit in duration: #{input}" if str.scan(/\d+h/).length > 1
|
|
16
|
+
raise ArgumentError, "Duplicate 'm' unit in duration: #{input}" if str.scan(/\d+m/).length > 1
|
|
17
|
+
raise ArgumentError, "Duplicate 's' unit in duration: #{input}" if str.scan(/\d+s/).length > 1
|
|
18
|
+
|
|
19
|
+
if (match = str.match(/(\d+)h/))
|
|
20
|
+
total += match[1].to_i * 3600
|
|
21
|
+
end
|
|
22
|
+
if (match = str.match(/(\d+)m/))
|
|
23
|
+
total += match[1].to_i * 60
|
|
24
|
+
end
|
|
25
|
+
if (match = str.match(/(\d+)s/))
|
|
26
|
+
total += match[1].to_i
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
raise ArgumentError, "Invalid duration format: #{input}" if total.zero? && !str.match?(/^0/)
|
|
30
|
+
|
|
31
|
+
new(seconds: total)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def zero = new(seconds: 0)
|
|
35
|
+
|
|
36
|
+
def from_minutes(minutes)
|
|
37
|
+
new(seconds: minutes.to_i * 60)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def zero? = seconds.zero?
|
|
42
|
+
|
|
43
|
+
def to_minutes = (seconds / 60.0).ceil
|
|
44
|
+
|
|
45
|
+
def to_expiration
|
|
46
|
+
return 0 if zero?
|
|
47
|
+
|
|
48
|
+
Time.now.to_i + seconds
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_s
|
|
52
|
+
return "" if zero?
|
|
53
|
+
|
|
54
|
+
parts = []
|
|
55
|
+
remaining = seconds
|
|
56
|
+
|
|
57
|
+
if remaining >= 3600
|
|
58
|
+
hours = remaining / 3600
|
|
59
|
+
parts << "#{hours}h"
|
|
60
|
+
remaining %= 3600
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if remaining >= 60
|
|
64
|
+
minutes = remaining / 60
|
|
65
|
+
parts << "#{minutes}m"
|
|
66
|
+
remaining %= 60
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if remaining > 0 && parts.empty?
|
|
70
|
+
parts << "#{remaining}s"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
parts.join
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def +(other)
|
|
77
|
+
Duration.new(seconds: seconds + other.seconds)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def -(other)
|
|
81
|
+
Duration.new(seconds: [seconds - other.seconds, 0].max)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|