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,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/help_formatter"
|
|
4
|
+
|
|
5
|
+
module SlackCli
|
|
6
|
+
module Commands
|
|
7
|
+
class Messages < Base
|
|
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 messages <channel|@user|url>")
|
|
15
|
+
return 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
workspace, channel_id, thread_ts, msg_ts = resolve_target(target)
|
|
19
|
+
|
|
20
|
+
# Apply default limits based on target type
|
|
21
|
+
apply_default_limit(msg_ts)
|
|
22
|
+
|
|
23
|
+
messages = fetch_messages(workspace, channel_id, thread_ts, oldest: msg_ts)
|
|
24
|
+
|
|
25
|
+
# Enrich with reaction timestamps if requested
|
|
26
|
+
if @options[:reaction_timestamps]
|
|
27
|
+
enricher = Services::ReactionEnricher.new(activity_api: runner.activity_api(workspace.name))
|
|
28
|
+
messages = enricher.enrich_messages(messages, channel_id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if @options[:json]
|
|
32
|
+
format_options = {
|
|
33
|
+
no_names: @options[:no_names],
|
|
34
|
+
reaction_timestamps: @options[:reaction_timestamps]
|
|
35
|
+
}
|
|
36
|
+
output_json(messages.map { |m| runner.message_formatter.format_json(m, workspace: workspace, options: format_options) })
|
|
37
|
+
else
|
|
38
|
+
display_messages(messages, workspace, channel_id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
0
|
|
42
|
+
rescue ApiError => e
|
|
43
|
+
error("Failed to fetch messages: #{e.message}")
|
|
44
|
+
1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
protected
|
|
48
|
+
|
|
49
|
+
def default_options
|
|
50
|
+
super.merge(
|
|
51
|
+
limit: 500,
|
|
52
|
+
limit_set: false,
|
|
53
|
+
threads: false,
|
|
54
|
+
no_emoji: false,
|
|
55
|
+
no_reactions: false,
|
|
56
|
+
no_names: false,
|
|
57
|
+
workspace_emoji: true, # Default to showing workspace emoji as images
|
|
58
|
+
reaction_names: false,
|
|
59
|
+
reaction_timestamps: false
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def handle_option(arg, args, remaining)
|
|
64
|
+
case arg
|
|
65
|
+
when "-n", "--limit"
|
|
66
|
+
@options[:limit] = args.shift.to_i
|
|
67
|
+
@options[:limit_set] = true
|
|
68
|
+
when "--threads"
|
|
69
|
+
@options[:threads] = true
|
|
70
|
+
when "--no-emoji"
|
|
71
|
+
@options[:no_emoji] = true
|
|
72
|
+
when "--no-reactions"
|
|
73
|
+
@options[:no_reactions] = true
|
|
74
|
+
when "--no-names"
|
|
75
|
+
@options[:no_names] = true
|
|
76
|
+
when "--no-workspace-emoji"
|
|
77
|
+
@options[:workspace_emoji] = false
|
|
78
|
+
when "--reaction-names"
|
|
79
|
+
@options[:reaction_names] = true
|
|
80
|
+
when "--reaction-timestamps"
|
|
81
|
+
@options[:reaction_timestamps] = true
|
|
82
|
+
else
|
|
83
|
+
super
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def help_text
|
|
88
|
+
help = Support::HelpFormatter.new("slk messages <target> [options]")
|
|
89
|
+
help.description("Read messages from a channel, DM, or thread.")
|
|
90
|
+
|
|
91
|
+
help.section("TARGET") do |s|
|
|
92
|
+
s.item("#channel", "Channel by name")
|
|
93
|
+
s.item("channel", "Channel by name (without #)")
|
|
94
|
+
s.item("@user", "Direct message with user")
|
|
95
|
+
s.item("C123ABC", "Channel by ID")
|
|
96
|
+
s.item("<slack_url>", "Slack message URL (returns message + subsequent)")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
help.section("OPTIONS") do |s|
|
|
100
|
+
s.option("-n, --limit N", "Number of messages (default: 500, or 50 for message URLs)")
|
|
101
|
+
s.option("--threads", "Show thread replies inline")
|
|
102
|
+
s.option("--no-emoji", "Show :emoji: codes instead of unicode")
|
|
103
|
+
s.option("--no-reactions", "Hide reactions")
|
|
104
|
+
s.option("--no-names", "Skip user name lookups (faster)")
|
|
105
|
+
s.option("--no-workspace-emoji", "Disable workspace emoji images")
|
|
106
|
+
s.option("--reaction-names", "Show reactions with user names")
|
|
107
|
+
s.option("--reaction-timestamps", "Show when each person reacted")
|
|
108
|
+
s.option("--width N", "Wrap text at N columns (default: 72 on TTY, no wrap otherwise)")
|
|
109
|
+
s.option("--no-wrap", "Disable text wrapping")
|
|
110
|
+
s.option("--json", "Output as JSON")
|
|
111
|
+
s.option("-w, --workspace", "Specify workspace")
|
|
112
|
+
s.option("-v, --verbose", "Show debug information")
|
|
113
|
+
s.option("-q, --quiet", "Suppress output")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
help.render
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def resolve_target(target)
|
|
122
|
+
url_parser = Support::SlackUrlParser.new
|
|
123
|
+
|
|
124
|
+
# Check if it's a Slack URL
|
|
125
|
+
if url_parser.slack_url?(target)
|
|
126
|
+
result = url_parser.parse(target)
|
|
127
|
+
if result
|
|
128
|
+
ws = runner.workspace(result.workspace)
|
|
129
|
+
# thread_ts means it's a thread, msg_ts means start from that message
|
|
130
|
+
if result.thread?
|
|
131
|
+
return [ws, result.channel_id, result.thread_ts, nil]
|
|
132
|
+
else
|
|
133
|
+
return [ws, result.channel_id, nil, result.msg_ts]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
workspace = target_workspaces.first
|
|
139
|
+
|
|
140
|
+
# Direct channel ID
|
|
141
|
+
if target.match?(/^[CDG][A-Z0-9]+$/)
|
|
142
|
+
return [workspace, target, nil, nil]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Channel by name
|
|
146
|
+
if target.start_with?("#") || !target.start_with?("@")
|
|
147
|
+
channel_name = target.delete_prefix("#")
|
|
148
|
+
channel_id = resolve_channel(workspace, channel_name)
|
|
149
|
+
return [workspace, channel_id, nil, nil]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# DM by username
|
|
153
|
+
if target.start_with?("@")
|
|
154
|
+
username = target.delete_prefix("@")
|
|
155
|
+
channel_id = resolve_dm(workspace, username)
|
|
156
|
+
return [workspace, channel_id, nil, nil]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
raise ConfigError, "Could not resolve target: #{target}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def resolve_channel(workspace, name)
|
|
163
|
+
# Check cache first
|
|
164
|
+
cached = cache_store.get_channel_id(workspace.name, name)
|
|
165
|
+
return cached if cached
|
|
166
|
+
|
|
167
|
+
# Search via API
|
|
168
|
+
api = runner.conversations_api(workspace.name)
|
|
169
|
+
response = api.list
|
|
170
|
+
|
|
171
|
+
channels = response["channels"] || []
|
|
172
|
+
channel = channels.find { |c| c["name"] == name }
|
|
173
|
+
|
|
174
|
+
if channel
|
|
175
|
+
cache_store.set_channel(workspace.name, name, channel["id"])
|
|
176
|
+
return channel["id"]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
raise ConfigError, "Channel not found: ##{name}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def resolve_dm(workspace, username)
|
|
183
|
+
# Find user ID
|
|
184
|
+
user_id = find_user_id(workspace, username)
|
|
185
|
+
raise ConfigError, "User not found: @#{username}" unless user_id
|
|
186
|
+
|
|
187
|
+
# Open DM
|
|
188
|
+
api = runner.conversations_api(workspace.name)
|
|
189
|
+
response = api.open(users: user_id)
|
|
190
|
+
response.dig("channel", "id")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def find_user_id(workspace, username)
|
|
194
|
+
# Check cache
|
|
195
|
+
# Note: We need reverse lookup, which cache_store doesn't support directly
|
|
196
|
+
# For now, fetch user list and search
|
|
197
|
+
|
|
198
|
+
api = runner.users_api(workspace.name)
|
|
199
|
+
response = api.list
|
|
200
|
+
|
|
201
|
+
users = response["members"] || []
|
|
202
|
+
user = users.find do |u|
|
|
203
|
+
u["name"] == username ||
|
|
204
|
+
u.dig("profile", "display_name") == username ||
|
|
205
|
+
u.dig("profile", "real_name") == username
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
user&.dig("id")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Apply default limit based on target type (50 for message URLs, 500 otherwise)
|
|
212
|
+
def apply_default_limit(msg_ts)
|
|
213
|
+
return if @options[:limit_set]
|
|
214
|
+
|
|
215
|
+
@options[:limit] = msg_ts ? 50 : 500
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def fetch_messages(workspace, channel_id, thread_ts = nil, oldest: nil)
|
|
219
|
+
api = runner.conversations_api(workspace.name)
|
|
220
|
+
|
|
221
|
+
if thread_ts
|
|
222
|
+
# For threads, paginate to fetch all replies
|
|
223
|
+
messages = fetch_all_thread_replies(api, channel_id, thread_ts)
|
|
224
|
+
|
|
225
|
+
# Apply limit (keep parent + last N-1 replies)
|
|
226
|
+
if @options[:limit] > 0 && messages.length > @options[:limit]
|
|
227
|
+
messages = [messages.first] + messages.last(@options[:limit] - 1)
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
# For channel history, use oldest parameter if provided
|
|
231
|
+
# Slack API oldest is exclusive - decrement slightly to include the target message
|
|
232
|
+
oldest_adjusted = oldest ? adjust_timestamp(oldest, -0.000001) : nil
|
|
233
|
+
response = api.history(channel: channel_id, limit: @options[:limit], oldest: oldest_adjusted)
|
|
234
|
+
messages = response["messages"] || []
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Convert to model objects
|
|
238
|
+
messages = messages.map { |m| Models::Message.from_api(m, channel_id: channel_id) }
|
|
239
|
+
|
|
240
|
+
# Reverse to show oldest first
|
|
241
|
+
messages.reverse
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Adjust a Slack timestamp by a small amount while preserving precision
|
|
245
|
+
def adjust_timestamp(ts, delta)
|
|
246
|
+
require 'bigdecimal'
|
|
247
|
+
(BigDecimal(ts) + BigDecimal(delta.to_s)).to_s('F')
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def fetch_all_thread_replies(api, channel_id, thread_ts)
|
|
251
|
+
all_messages = []
|
|
252
|
+
cursor = nil
|
|
253
|
+
|
|
254
|
+
loop do
|
|
255
|
+
response = api.replies(channel: channel_id, ts: thread_ts, limit: 200, cursor: cursor)
|
|
256
|
+
page_messages = response["messages"] || []
|
|
257
|
+
all_messages.concat(page_messages)
|
|
258
|
+
|
|
259
|
+
debug("Fetched #{page_messages.length} messages, total: #{all_messages.length}")
|
|
260
|
+
|
|
261
|
+
cursor = response.dig("response_metadata", "next_cursor")
|
|
262
|
+
break if cursor.nil? || cursor.empty? || !response["has_more"]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Deduplicate and sort by timestamp
|
|
266
|
+
all_messages
|
|
267
|
+
.uniq { |m| m["ts"] }
|
|
268
|
+
.sort_by { |m| m["ts"].to_f }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def display_messages(messages, workspace, channel_id)
|
|
272
|
+
formatter = runner.message_formatter
|
|
273
|
+
format_options = {
|
|
274
|
+
no_emoji: @options[:no_emoji],
|
|
275
|
+
no_reactions: @options[:no_reactions],
|
|
276
|
+
no_names: @options[:no_names],
|
|
277
|
+
workspace_emoji: @options[:workspace_emoji],
|
|
278
|
+
reaction_names: @options[:reaction_names],
|
|
279
|
+
width: @options[:width]
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
messages.each_with_index do |message, index|
|
|
283
|
+
formatted = formatter.format(message, workspace: workspace, options: format_options)
|
|
284
|
+
puts formatted
|
|
285
|
+
puts if index < messages.length - 1
|
|
286
|
+
|
|
287
|
+
# Show thread replies if requested
|
|
288
|
+
if @options[:threads] && message.has_thread? && !message.is_reply?
|
|
289
|
+
show_thread_replies(workspace, channel_id, message, format_options)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def show_thread_replies(workspace, channel_id, parent_message, format_options)
|
|
295
|
+
api = runner.conversations_api(workspace.name)
|
|
296
|
+
formatter = runner.message_formatter
|
|
297
|
+
|
|
298
|
+
# Fetch all replies with pagination
|
|
299
|
+
replies = fetch_all_thread_replies(api, channel_id, parent_message.ts)
|
|
300
|
+
|
|
301
|
+
# Skip the parent message (first one) and show replies
|
|
302
|
+
replies[1..].each do |reply_data|
|
|
303
|
+
reply = Models::Message.from_api(reply_data, channel_id: channel_id)
|
|
304
|
+
formatted = formatter.format(reply, workspace: workspace, options: format_options)
|
|
305
|
+
|
|
306
|
+
# Indent multiline messages so continuation lines align with the first line
|
|
307
|
+
lines = formatted.lines
|
|
308
|
+
first_line = " └ #{lines.first}"
|
|
309
|
+
continuation_lines = lines[1..].map { |line| " #{line}" }
|
|
310
|
+
|
|
311
|
+
puts first_line
|
|
312
|
+
continuation_lines.each { |line| puts line }
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/help_formatter"
|
|
4
|
+
|
|
5
|
+
module SlackCli
|
|
6
|
+
module Commands
|
|
7
|
+
class Presence < Base
|
|
8
|
+
def execute
|
|
9
|
+
result = validate_options
|
|
10
|
+
return result if result
|
|
11
|
+
|
|
12
|
+
case positional_args
|
|
13
|
+
in ["away"]
|
|
14
|
+
set_presence("away")
|
|
15
|
+
in ["auto" | "active"]
|
|
16
|
+
set_presence("auto")
|
|
17
|
+
in []
|
|
18
|
+
get_presence
|
|
19
|
+
else
|
|
20
|
+
error("Unknown presence: #{positional_args.first}")
|
|
21
|
+
error("Valid options: away, auto, active")
|
|
22
|
+
1
|
|
23
|
+
end
|
|
24
|
+
rescue ApiError => e
|
|
25
|
+
error("Failed: #{e.message}")
|
|
26
|
+
1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
protected
|
|
30
|
+
|
|
31
|
+
def help_text
|
|
32
|
+
help = Support::HelpFormatter.new("slk presence [away|auto|active]")
|
|
33
|
+
help.description("Get or set your presence status.")
|
|
34
|
+
help.note("GET shows all workspaces by default. SET applies to primary only.")
|
|
35
|
+
|
|
36
|
+
help.section("ACTIONS") do |s|
|
|
37
|
+
s.action("(none)", "Show current presence (all workspaces)")
|
|
38
|
+
s.action("away", "Set presence to away")
|
|
39
|
+
s.action("auto", "Set presence to auto (active)")
|
|
40
|
+
s.action("active", "Alias for auto")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
help.section("OPTIONS") do |s|
|
|
44
|
+
s.option("-w, --workspace", "Limit to specific workspace")
|
|
45
|
+
s.option("--all", "Set across all workspaces")
|
|
46
|
+
s.option("-q, --quiet", "Suppress output")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
help.render
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def get_presence
|
|
55
|
+
# GET defaults to all workspaces unless -w specified
|
|
56
|
+
workspaces = @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
|
|
57
|
+
|
|
58
|
+
workspaces.each do |workspace|
|
|
59
|
+
data = runner.users_api(workspace.name).get_presence
|
|
60
|
+
|
|
61
|
+
if workspaces.size > 1
|
|
62
|
+
puts output.bold(workspace.name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
presence = data[:presence]
|
|
66
|
+
manual = data[:manual_away]
|
|
67
|
+
|
|
68
|
+
status = case [presence, manual]
|
|
69
|
+
in ["away", true]
|
|
70
|
+
output.yellow("away (manual)")
|
|
71
|
+
in ["away", _]
|
|
72
|
+
output.yellow("away")
|
|
73
|
+
in ["active", _]
|
|
74
|
+
output.green("active")
|
|
75
|
+
else
|
|
76
|
+
presence
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
puts " Presence: #{status}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def set_presence(presence)
|
|
86
|
+
target_workspaces.each do |workspace|
|
|
87
|
+
runner.users_api(workspace.name).set_presence(presence)
|
|
88
|
+
|
|
89
|
+
status_text = presence == "away" ? output.yellow("away") : output.green("active")
|
|
90
|
+
success("Presence set to #{status_text} on #{workspace.name}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
show_all_workspaces_hint
|
|
94
|
+
|
|
95
|
+
0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def show_all_workspaces_hint
|
|
99
|
+
# Show hint if user has multiple workspaces and didn't use --all or -w
|
|
100
|
+
return if @options[:all] || @options[:workspace]
|
|
101
|
+
return if runner.all_workspaces.size <= 1
|
|
102
|
+
|
|
103
|
+
info("Tip: Use --all to set across all workspaces")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
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 Preset < Base
|
|
9
|
+
include Support::InlineImages
|
|
10
|
+
def execute
|
|
11
|
+
result = validate_options
|
|
12
|
+
return result if result
|
|
13
|
+
|
|
14
|
+
case positional_args
|
|
15
|
+
in ["list" | "ls"] | []
|
|
16
|
+
list_presets
|
|
17
|
+
in ["add"]
|
|
18
|
+
add_preset
|
|
19
|
+
in ["edit", name]
|
|
20
|
+
edit_preset(name)
|
|
21
|
+
in ["delete" | "rm", name]
|
|
22
|
+
delete_preset(name)
|
|
23
|
+
in [name, *]
|
|
24
|
+
apply_preset(name)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
protected
|
|
29
|
+
|
|
30
|
+
def help_text
|
|
31
|
+
help = Support::HelpFormatter.new("slk preset <action|name> [options]")
|
|
32
|
+
help.description("Manage and apply status presets.")
|
|
33
|
+
|
|
34
|
+
help.section("ACTIONS") do |s|
|
|
35
|
+
s.action("list", "List all presets")
|
|
36
|
+
s.action("add", "Add a new preset (interactive)")
|
|
37
|
+
s.action("edit <name>", "Edit an existing preset")
|
|
38
|
+
s.action("delete <name>", "Delete a preset")
|
|
39
|
+
s.action("<name>", "Apply a preset")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
help.section("EXAMPLES") do |s|
|
|
43
|
+
s.example("slk preset list")
|
|
44
|
+
s.example("slk preset meeting")
|
|
45
|
+
s.example("slk preset add")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
help.section("OPTIONS") do |s|
|
|
49
|
+
s.option("-w, --workspace", "Specify workspace")
|
|
50
|
+
s.option("--all", "Apply to all workspaces")
|
|
51
|
+
s.option("-q, --quiet", "Suppress output")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
help.render
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def list_presets
|
|
60
|
+
presets = preset_store.all
|
|
61
|
+
|
|
62
|
+
if presets.empty?
|
|
63
|
+
puts "No presets configured."
|
|
64
|
+
return 0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
puts "Presets:"
|
|
68
|
+
presets.each do |preset|
|
|
69
|
+
puts " #{output.bold(preset.name)}"
|
|
70
|
+
display_preset_status(preset) unless preset.text.empty? && preset.emoji.empty?
|
|
71
|
+
puts " Duration: #{preset.duration}" unless preset.duration == "0"
|
|
72
|
+
puts " Presence: #{preset.presence}" if preset.sets_presence?
|
|
73
|
+
puts " DND: #{preset.dnd}" if preset.sets_dnd?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def display_preset_status(preset)
|
|
80
|
+
emoji_name = preset.emoji.delete_prefix(":").delete_suffix(":")
|
|
81
|
+
emoji_path = find_workspace_emoji_any(emoji_name)
|
|
82
|
+
|
|
83
|
+
if emoji_path && inline_images_supported?
|
|
84
|
+
text = " #{preset.text}"
|
|
85
|
+
print_inline_image_with_text(emoji_path, text)
|
|
86
|
+
else
|
|
87
|
+
puts " #{preset.emoji} #{preset.text}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def find_workspace_emoji_any(emoji_name)
|
|
92
|
+
return nil if emoji_name.empty?
|
|
93
|
+
|
|
94
|
+
paths = Support::XdgPaths.new
|
|
95
|
+
emoji_dir = config.emoji_dir || paths.cache_dir
|
|
96
|
+
|
|
97
|
+
# Search across all workspaces
|
|
98
|
+
runner.all_workspaces.each do |workspace|
|
|
99
|
+
workspace_dir = File.join(emoji_dir, workspace.name)
|
|
100
|
+
next unless Dir.exist?(workspace_dir)
|
|
101
|
+
|
|
102
|
+
path = Dir.glob(File.join(workspace_dir, "#{emoji_name}.*")).first
|
|
103
|
+
return path if path
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def add_preset
|
|
110
|
+
print "Preset name: "
|
|
111
|
+
name = $stdin.gets&.chomp
|
|
112
|
+
return error("Name is required") if name.nil? || name.empty?
|
|
113
|
+
|
|
114
|
+
print "Status text: "
|
|
115
|
+
text = $stdin.gets&.chomp || ""
|
|
116
|
+
|
|
117
|
+
print "Emoji (e.g., :calendar:): "
|
|
118
|
+
emoji = $stdin.gets&.chomp || ""
|
|
119
|
+
|
|
120
|
+
print "Duration (e.g., 1h, 30m, or 0 for none): "
|
|
121
|
+
duration = $stdin.gets&.chomp || "0"
|
|
122
|
+
|
|
123
|
+
print "Presence (away/auto or blank): "
|
|
124
|
+
presence = $stdin.gets&.chomp || ""
|
|
125
|
+
|
|
126
|
+
print "DND (e.g., 1h, off, or blank): "
|
|
127
|
+
dnd = $stdin.gets&.chomp || ""
|
|
128
|
+
|
|
129
|
+
preset = Models::Preset.new(
|
|
130
|
+
name: name,
|
|
131
|
+
text: text,
|
|
132
|
+
emoji: emoji,
|
|
133
|
+
duration: duration,
|
|
134
|
+
presence: presence,
|
|
135
|
+
dnd: dnd
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
preset_store.add(preset)
|
|
139
|
+
success("Preset '#{name}' created")
|
|
140
|
+
|
|
141
|
+
0
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def edit_preset(name)
|
|
145
|
+
preset = preset_store.get(name)
|
|
146
|
+
return error("Preset '#{name}' not found") unless preset
|
|
147
|
+
|
|
148
|
+
puts "Editing preset '#{name}' (press Enter to keep current value)"
|
|
149
|
+
|
|
150
|
+
print "Status text [#{preset.text}]: "
|
|
151
|
+
text = $stdin.gets&.chomp
|
|
152
|
+
text = preset.text if text.empty?
|
|
153
|
+
|
|
154
|
+
print "Emoji [#{preset.emoji}]: "
|
|
155
|
+
emoji = $stdin.gets&.chomp
|
|
156
|
+
emoji = preset.emoji if emoji.empty?
|
|
157
|
+
|
|
158
|
+
print "Duration [#{preset.duration}]: "
|
|
159
|
+
duration = $stdin.gets&.chomp
|
|
160
|
+
duration = preset.duration if duration.empty?
|
|
161
|
+
|
|
162
|
+
print "Presence [#{preset.presence}]: "
|
|
163
|
+
presence = $stdin.gets&.chomp
|
|
164
|
+
presence = preset.presence if presence.empty?
|
|
165
|
+
|
|
166
|
+
print "DND [#{preset.dnd}]: "
|
|
167
|
+
dnd = $stdin.gets&.chomp
|
|
168
|
+
dnd = preset.dnd if dnd.empty?
|
|
169
|
+
|
|
170
|
+
updated = Models::Preset.new(
|
|
171
|
+
name: name,
|
|
172
|
+
text: text,
|
|
173
|
+
emoji: emoji,
|
|
174
|
+
duration: duration,
|
|
175
|
+
presence: presence,
|
|
176
|
+
dnd: dnd
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
preset_store.add(updated)
|
|
180
|
+
success("Preset '#{name}' updated")
|
|
181
|
+
|
|
182
|
+
0
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def delete_preset(name)
|
|
186
|
+
unless preset_store.exists?(name)
|
|
187
|
+
return error("Preset '#{name}' not found")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
preset_store.remove(name)
|
|
191
|
+
success("Preset '#{name}' deleted")
|
|
192
|
+
|
|
193
|
+
0
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def apply_preset(name)
|
|
197
|
+
preset = preset_store.get(name)
|
|
198
|
+
return error("Preset '#{name}' not found") unless preset
|
|
199
|
+
|
|
200
|
+
target_workspaces.each do |workspace|
|
|
201
|
+
# Set status
|
|
202
|
+
unless preset.clears_status?
|
|
203
|
+
duration = preset.duration_value
|
|
204
|
+
runner.users_api(workspace.name).set_status(
|
|
205
|
+
text: preset.text,
|
|
206
|
+
emoji: preset.emoji,
|
|
207
|
+
duration: duration
|
|
208
|
+
)
|
|
209
|
+
else
|
|
210
|
+
runner.users_api(workspace.name).clear_status
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Set presence
|
|
214
|
+
if preset.sets_presence?
|
|
215
|
+
runner.users_api(workspace.name).set_presence(preset.presence)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Set DND
|
|
219
|
+
if preset.sets_dnd?
|
|
220
|
+
dnd_api = runner.dnd_api(workspace.name)
|
|
221
|
+
if preset.dnd == "off"
|
|
222
|
+
dnd_api.end_snooze
|
|
223
|
+
else
|
|
224
|
+
duration = Models::Duration.parse(preset.dnd)
|
|
225
|
+
dnd_api.set_snooze(duration)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
success("Applied preset '#{name}' on #{workspace.name}")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
0
|
|
233
|
+
rescue ApiError => e
|
|
234
|
+
error("Failed to apply preset: #{e.message}")
|
|
235
|
+
1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|